tenseleyflow/fackr / 05e93ba

Browse files

feat: add LSP server auto-install panel

Add UI panel for discovering and installing language servers.
Detects missing servers based on file types and offers one-click
installation via npm, pip, cargo, or system package managers.
Authored by espadonne
SHA
05e93ba1df8d74e32de9d39621cec9e97a05663f
Parents
69345bd
Tree
779426c

1 changed file

StatusFile+-
A src/lsp/server_manager.rs 760 0
src/lsp/server_manager.rsadded
@@ -0,0 +1,760 @@
1
+//! LSP Server Manager
2
+//!
3
+//! Detects available language servers and helps users install them.
4
+//!
5
+//! Note: Some fields are for planned UI features.
6
+#![allow(dead_code)]
7
+
8
+use std::collections::HashSet;
9
+use std::process::Command;
10
+use std::sync::mpsc::{self, Receiver, Sender};
11
+use std::thread;
12
+
13
+/// Result of an install operation
14
+pub struct InstallResult {
15
+    pub server_index: usize,
16
+    pub server_name: String,
17
+    pub check_cmd: String,
18
+    pub success: bool,
19
+    pub message: String,
20
+}
21
+
22
+/// A known language server with installation instructions
23
+#[derive(Debug, Clone)]
24
+pub struct KnownServer {
25
+    pub name: &'static str,
26
+    pub language: &'static str,
27
+    pub check_cmd: &'static str,
28
+    pub install_cmd: &'static str,
29
+    pub description: &'static str,
30
+    pub is_installed: bool,
31
+}
32
+
33
+impl KnownServer {
34
+    const fn new(
35
+        name: &'static str,
36
+        language: &'static str,
37
+        check_cmd: &'static str,
38
+        install_cmd: &'static str,
39
+        description: &'static str,
40
+    ) -> Self {
41
+        Self {
42
+            name,
43
+            language,
44
+            check_cmd,
45
+            install_cmd,
46
+            description,
47
+            is_installed: false,
48
+        }
49
+    }
50
+}
51
+
52
+/// All known language servers
53
+pub fn get_known_servers() -> Vec<KnownServer> {
54
+    vec![
55
+        // Python
56
+        KnownServer::new(
57
+            "pyright",
58
+            "Python",
59
+            "pyright-langserver",
60
+            "pip install pyright",
61
+            "Python type checker and language server",
62
+        ),
63
+        KnownServer::new(
64
+            "ruff",
65
+            "Python",
66
+            "ruff",
67
+            "pip install ruff",
68
+            "Fast Python linter with LSP support",
69
+        ),
70
+        KnownServer::new(
71
+            "pylsp",
72
+            "Python",
73
+            "pylsp",
74
+            "pip install python-lsp-server",
75
+            "Python LSP server with plugin support",
76
+        ),
77
+        // Rust
78
+        KnownServer::new(
79
+            "rust-analyzer",
80
+            "Rust",
81
+            "rust-analyzer",
82
+            "rustup component add rust-analyzer",
83
+            "Rust language server",
84
+        ),
85
+        // C/C++
86
+        KnownServer::new(
87
+            "clangd",
88
+            "C/C++",
89
+            "clangd",
90
+            "# Install via package manager (apt/brew/etc)",
91
+            "C/C++ language server from LLVM",
92
+        ),
93
+        // Go
94
+        KnownServer::new(
95
+            "gopls",
96
+            "Go",
97
+            "gopls",
98
+            "go install golang.org/x/tools/gopls@latest",
99
+            "Go language server",
100
+        ),
101
+        // TypeScript/JavaScript
102
+        KnownServer::new(
103
+            "typescript-language-server",
104
+            "JS/TS",
105
+            "typescript-language-server",
106
+            "npm i -g typescript-language-server typescript",
107
+            "TypeScript and JavaScript language server",
108
+        ),
109
+        KnownServer::new(
110
+            "vtsls",
111
+            "JS/TS",
112
+            "vtsls",
113
+            "npm i -g @vtsls/language-server",
114
+            "Fast TypeScript language server",
115
+        ),
116
+        // Lua
117
+        KnownServer::new(
118
+            "lua-language-server",
119
+            "Lua",
120
+            "lua-language-server",
121
+            "# Install via package manager",
122
+            "Lua language server",
123
+        ),
124
+        // Ruby
125
+        KnownServer::new(
126
+            "solargraph",
127
+            "Ruby",
128
+            "solargraph",
129
+            "gem install solargraph",
130
+            "Ruby language server",
131
+        ),
132
+        // Java
133
+        KnownServer::new(
134
+            "jdtls",
135
+            "Java",
136
+            "jdtls",
137
+            "# Install via package manager",
138
+            "Eclipse JDT Language Server",
139
+        ),
140
+        // HTML/CSS/JSON
141
+        KnownServer::new(
142
+            "vscode-langservers",
143
+            "HTML/CSS/JSON",
144
+            "vscode-html-language-server",
145
+            "npm i -g vscode-langservers-extracted",
146
+            "HTML, CSS, JSON language servers",
147
+        ),
148
+        // Bash
149
+        KnownServer::new(
150
+            "bash-language-server",
151
+            "Bash",
152
+            "bash-language-server",
153
+            "npm i -g bash-language-server",
154
+            "Bash/Shell language server",
155
+        ),
156
+        // YAML
157
+        KnownServer::new(
158
+            "yaml-language-server",
159
+            "YAML",
160
+            "yaml-language-server",
161
+            "npm i -g yaml-language-server",
162
+            "YAML language server",
163
+        ),
164
+        // Docker
165
+        KnownServer::new(
166
+            "dockerfile-langserver",
167
+            "Docker",
168
+            "docker-langserver",
169
+            "npm i -g dockerfile-language-server-nodejs",
170
+            "Dockerfile language server",
171
+        ),
172
+        // Zig
173
+        KnownServer::new(
174
+            "zls",
175
+            "Zig",
176
+            "zls",
177
+            "# Install via package manager or zigtools",
178
+            "Zig language server",
179
+        ),
180
+        // Haskell
181
+        KnownServer::new(
182
+            "haskell-language-server",
183
+            "Haskell",
184
+            "haskell-language-server-wrapper",
185
+            "ghcup install hls",
186
+            "Haskell language server",
187
+        ),
188
+        // Terraform
189
+        KnownServer::new(
190
+            "terraform-ls",
191
+            "Terraform",
192
+            "terraform-ls",
193
+            "# Install from HashiCorp",
194
+            "Terraform language server",
195
+        ),
196
+        // Fortran
197
+        KnownServer::new(
198
+            "fortls",
199
+            "Fortran",
200
+            "fortls",
201
+            "pip install fortls",
202
+            "Fortran language server",
203
+        ),
204
+        // Elixir
205
+        KnownServer::new(
206
+            "elixir-ls",
207
+            "Elixir",
208
+            "elixir-ls",
209
+            "# Download from GitHub releases",
210
+            "Elixir language server",
211
+        ),
212
+        // Markdown
213
+        KnownServer::new(
214
+            "marksman",
215
+            "Markdown",
216
+            "marksman",
217
+            "# Install from GitHub releases or brew install marksman",
218
+            "Markdown language server with wiki-links support",
219
+        ),
220
+        // Kotlin
221
+        KnownServer::new(
222
+            "kotlin-language-server",
223
+            "Kotlin",
224
+            "kotlin-language-server",
225
+            "# Install from GitHub releases",
226
+            "Kotlin language server",
227
+        ),
228
+        // Swift
229
+        KnownServer::new(
230
+            "sourcekit-lsp",
231
+            "Swift",
232
+            "sourcekit-lsp",
233
+            "# Included with Xcode or Swift toolchain",
234
+            "Swift/Objective-C language server",
235
+        ),
236
+        // PHP
237
+        KnownServer::new(
238
+            "intelephense",
239
+            "PHP",
240
+            "intelephense",
241
+            "npm i -g intelephense",
242
+            "PHP language server",
243
+        ),
244
+        // C#
245
+        KnownServer::new(
246
+            "omnisharp",
247
+            "C#",
248
+            "OmniSharp",
249
+            "# Install from GitHub releases",
250
+            "C# language server",
251
+        ),
252
+        // Scala
253
+        KnownServer::new(
254
+            "metals",
255
+            "Scala",
256
+            "metals",
257
+            "# Install via coursier: cs install metals",
258
+            "Scala language server",
259
+        ),
260
+        // OCaml
261
+        KnownServer::new(
262
+            "ocamllsp",
263
+            "OCaml",
264
+            "ocamllsp",
265
+            "opam install ocaml-lsp-server",
266
+            "OCaml language server",
267
+        ),
268
+        // Nim
269
+        KnownServer::new(
270
+            "nimlangserver",
271
+            "Nim",
272
+            "nimlangserver",
273
+            "nimble install nimlangserver",
274
+            "Nim language server",
275
+        ),
276
+        // Julia
277
+        KnownServer::new(
278
+            "julia-lsp",
279
+            "Julia",
280
+            "julia",
281
+            "# Install LanguageServer.jl package in Julia",
282
+            "Julia language server",
283
+        ),
284
+        // Erlang
285
+        KnownServer::new(
286
+            "erlang_ls",
287
+            "Erlang",
288
+            "erlang_ls",
289
+            "# Install from GitHub releases",
290
+            "Erlang language server",
291
+        ),
292
+        // Clojure
293
+        KnownServer::new(
294
+            "clojure-lsp",
295
+            "Clojure",
296
+            "clojure-lsp",
297
+            "# Install from GitHub releases or brew",
298
+            "Clojure language server",
299
+        ),
300
+        // Perl
301
+        KnownServer::new(
302
+            "perlnavigator",
303
+            "Perl",
304
+            "perlnavigator",
305
+            "npm i -g perlnavigator-server",
306
+            "Perl language server",
307
+        ),
308
+        // R
309
+        KnownServer::new(
310
+            "r-languageserver",
311
+            "R",
312
+            "R",
313
+            "# Install languageserver package in R",
314
+            "R language server",
315
+        ),
316
+        // Dart/Flutter
317
+        KnownServer::new(
318
+            "dart-language-server",
319
+            "Dart",
320
+            "dart",
321
+            "# Included with Dart SDK",
322
+            "Dart language server",
323
+        ),
324
+        // Vue
325
+        KnownServer::new(
326
+            "vue-language-server",
327
+            "Vue",
328
+            "vue-language-server",
329
+            "npm i -g @vue/language-server",
330
+            "Vue.js language server",
331
+        ),
332
+        // Svelte
333
+        KnownServer::new(
334
+            "svelte-language-server",
335
+            "Svelte",
336
+            "svelteserver",
337
+            "npm i -g svelte-language-server",
338
+            "Svelte language server",
339
+        ),
340
+        // TOML
341
+        KnownServer::new(
342
+            "taplo",
343
+            "TOML",
344
+            "taplo",
345
+            "cargo install taplo-cli --features lsp",
346
+            "TOML language server",
347
+        ),
348
+        // Nix
349
+        KnownServer::new(
350
+            "nil",
351
+            "Nix",
352
+            "nil",
353
+            "nix profile install nixpkgs#nil",
354
+            "Nix language server",
355
+        ),
356
+        // GraphQL
357
+        KnownServer::new(
358
+            "graphql-lsp",
359
+            "GraphQL",
360
+            "graphql-lsp",
361
+            "npm i -g graphql-language-service-cli",
362
+            "GraphQL language server",
363
+        ),
364
+        // SQL
365
+        KnownServer::new(
366
+            "sqls",
367
+            "SQL",
368
+            "sqls",
369
+            "go install github.com/sqls-server/sqls@latest",
370
+            "SQL language server",
371
+        ),
372
+        // LaTeX
373
+        KnownServer::new(
374
+            "texlab",
375
+            "LaTeX",
376
+            "texlab",
377
+            "# Install from GitHub releases or cargo install texlab",
378
+            "LaTeX language server",
379
+        ),
380
+        // CMake
381
+        KnownServer::new(
382
+            "cmake-language-server",
383
+            "CMake",
384
+            "cmake-language-server",
385
+            "pip install cmake-language-server",
386
+            "CMake language server",
387
+        ),
388
+        // D
389
+        KnownServer::new(
390
+            "serve-d",
391
+            "D",
392
+            "serve-d",
393
+            "# Install from GitHub releases",
394
+            "D language server",
395
+        ),
396
+        // V
397
+        KnownServer::new(
398
+            "v-analyzer",
399
+            "V",
400
+            "v-analyzer",
401
+            "# Install from GitHub releases",
402
+            "V language server",
403
+        ),
404
+        // Odin
405
+        KnownServer::new(
406
+            "ols",
407
+            "Odin",
408
+            "ols",
409
+            "# Install from GitHub releases",
410
+            "Odin language server",
411
+        ),
412
+        // F#
413
+        KnownServer::new(
414
+            "fsautocomplete",
415
+            "F#",
416
+            "fsautocomplete",
417
+            "dotnet tool install -g fsautocomplete",
418
+            "F# language server",
419
+        ),
420
+        // Groovy
421
+        KnownServer::new(
422
+            "groovy-language-server",
423
+            "Groovy",
424
+            "groovy-language-server",
425
+            "# Install from GitHub releases",
426
+            "Groovy language server",
427
+        ),
428
+    ]
429
+}
430
+
431
+/// Check if a command exists in PATH
432
+pub fn check_command_exists(cmd: &str) -> bool {
433
+    if cmd.is_empty() {
434
+        return false;
435
+    }
436
+
437
+    Command::new("which")
438
+        .arg(cmd)
439
+        .stdout(std::process::Stdio::null())
440
+        .stderr(std::process::Stdio::null())
441
+        .status()
442
+        .map(|s| s.success())
443
+        .unwrap_or(false)
444
+}
445
+
446
+/// Detect which servers are installed
447
+pub fn detect_installed_servers() -> Vec<KnownServer> {
448
+    let mut servers = get_known_servers();
449
+    for server in &mut servers {
450
+        server.is_installed = check_command_exists(server.check_cmd);
451
+    }
452
+    servers
453
+}
454
+
455
+/// Sanitize a string for display - remove ANSI escape codes and control characters
456
+fn sanitize_for_display(s: &str) -> String {
457
+    let mut result = String::new();
458
+    let mut chars = s.chars().peekable();
459
+
460
+    while let Some(c) = chars.next() {
461
+        // Skip ANSI escape sequences (ESC [ ... m and similar)
462
+        if c == '\x1b' {
463
+            // Skip until we hit a letter (end of escape sequence)
464
+            while let Some(&next) = chars.peek() {
465
+                chars.next();
466
+                if next.is_ascii_alphabetic() {
467
+                    break;
468
+                }
469
+            }
470
+            continue;
471
+        }
472
+
473
+        // Skip other control characters except space
474
+        if c.is_control() {
475
+            if c == '\n' || c == '\t' {
476
+                result.push(' '); // Replace newlines/tabs with space
477
+            }
478
+            continue;
479
+        }
480
+
481
+        result.push(c);
482
+    }
483
+
484
+    // Collapse multiple spaces
485
+    let mut prev_space = false;
486
+    let collapsed: String = result.chars().filter(|&c| {
487
+        if c == ' ' {
488
+            if prev_space {
489
+                return false;
490
+            }
491
+            prev_space = true;
492
+        } else {
493
+            prev_space = false;
494
+        }
495
+        true
496
+    }).collect();
497
+
498
+    collapsed.trim().to_string()
499
+}
500
+
501
+/// Run an install command
502
+pub fn run_install_command(cmd: &str) -> Result<String, String> {
503
+    // Skip comments
504
+    if cmd.starts_with('#') {
505
+        return Err("Manual installation required. See instructions.".to_string());
506
+    }
507
+
508
+    let output = Command::new("sh")
509
+        .arg("-c")
510
+        .arg(cmd)
511
+        .output()
512
+        .map_err(|e| format!("Failed to run command: {}", e))?;
513
+
514
+    if output.status.success() {
515
+        Ok(String::from_utf8_lossy(&output.stdout).to_string())
516
+    } else {
517
+        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
518
+        Err(sanitize_for_display(&stderr))
519
+    }
520
+}
521
+
522
+/// LSP Server Manager Panel state
523
+pub struct ServerManagerPanel {
524
+    pub visible: bool,
525
+    pub servers: Vec<KnownServer>,
526
+    pub selected_index: usize,
527
+    pub scroll_offset: usize,
528
+    pub confirm_mode: bool,
529
+    pub confirm_index: usize,
530
+    /// Set of server indices currently being installed
531
+    pub installing_indices: HashSet<usize>,
532
+    pub status_message: Option<String>,
533
+    /// Show manual install info dialog
534
+    pub manual_info_mode: bool,
535
+    pub manual_info_index: usize,
536
+    /// Text that was copied to clipboard (for status message)
537
+    pub copied_to_clipboard: bool,
538
+    /// Channel to receive install completion results
539
+    install_rx: Option<Receiver<InstallResult>>,
540
+    install_tx: Option<Sender<InstallResult>>,
541
+}
542
+
543
+impl Default for ServerManagerPanel {
544
+    fn default() -> Self {
545
+        Self::new()
546
+    }
547
+}
548
+
549
+impl ServerManagerPanel {
550
+    pub fn new() -> Self {
551
+        let (tx, rx) = mpsc::channel();
552
+        Self {
553
+            visible: false,
554
+            servers: Vec::new(),
555
+            selected_index: 0,
556
+            scroll_offset: 0,
557
+            confirm_mode: false,
558
+            confirm_index: 0,
559
+            installing_indices: HashSet::new(),
560
+            status_message: None,
561
+            manual_info_mode: false,
562
+            manual_info_index: 0,
563
+            copied_to_clipboard: false,
564
+            install_rx: Some(rx),
565
+            install_tx: Some(tx),
566
+        }
567
+    }
568
+
569
+    /// Check if a specific server is currently being installed
570
+    pub fn is_installing(&self, index: usize) -> bool {
571
+        self.installing_indices.contains(&index)
572
+    }
573
+
574
+    /// Check if any installs are in progress
575
+    pub fn has_active_installs(&self) -> bool {
576
+        !self.installing_indices.is_empty()
577
+    }
578
+
579
+    /// Poll for completed installs (non-blocking)
580
+    /// Returns true if there was an update (caller should re-render)
581
+    pub fn poll_installs(&mut self) -> bool {
582
+        let rx = match &self.install_rx {
583
+            Some(rx) => rx,
584
+            None => return false,
585
+        };
586
+
587
+        let mut had_update = false;
588
+
589
+        // Drain all available results
590
+        while let Ok(result) = rx.try_recv() {
591
+            had_update = true;
592
+            self.installing_indices.remove(&result.server_index);
593
+
594
+            if result.success {
595
+                // Update the server's installed status
596
+                if let Some(server) = self.servers.get_mut(result.server_index) {
597
+                    // Re-check if it's actually installed now
598
+                    server.is_installed = check_command_exists(&result.check_cmd);
599
+                    if server.is_installed {
600
+                        self.status_message = Some(format!("✓ {} installed successfully", result.server_name));
601
+                    } else {
602
+                        self.status_message = Some(format!("Installed {} (may need shell restart)", result.server_name));
603
+                    }
604
+                }
605
+            } else {
606
+                self.status_message = Some(result.message);
607
+            }
608
+        }
609
+
610
+        had_update
611
+    }
612
+
613
+    pub fn show(&mut self) {
614
+        self.visible = true;
615
+        self.selected_index = 0;
616
+        self.scroll_offset = 0;
617
+        self.confirm_mode = false;
618
+        self.manual_info_mode = false;
619
+        self.status_message = None;
620
+        self.copied_to_clipboard = false;
621
+
622
+        // Detect servers if not already done
623
+        if self.servers.is_empty() {
624
+            self.refresh();
625
+        }
626
+    }
627
+
628
+    pub fn hide(&mut self) {
629
+        self.visible = false;
630
+        self.confirm_mode = false;
631
+        self.manual_info_mode = false;
632
+    }
633
+
634
+    pub fn refresh(&mut self) {
635
+        self.servers = detect_installed_servers();
636
+        self.status_message = Some("Server status refreshed".to_string());
637
+    }
638
+
639
+    pub fn move_up(&mut self) {
640
+        if self.selected_index > 0 {
641
+            self.selected_index -= 1;
642
+            if self.selected_index < self.scroll_offset {
643
+                self.scroll_offset = self.selected_index;
644
+            }
645
+        }
646
+    }
647
+
648
+    pub fn move_down(&mut self, max_visible: usize) {
649
+        if self.selected_index < self.servers.len().saturating_sub(1) {
650
+            self.selected_index += 1;
651
+            if self.selected_index >= self.scroll_offset + max_visible {
652
+                self.scroll_offset = self.selected_index - max_visible + 1;
653
+            }
654
+        }
655
+    }
656
+
657
+    pub fn enter_confirm_mode(&mut self) {
658
+        if self.selected_index < self.servers.len() {
659
+            let server = &self.servers[self.selected_index];
660
+            if server.is_installed {
661
+                self.status_message = Some(format!("{} is already installed", server.name));
662
+            } else if server.install_cmd.starts_with('#') {
663
+                // Show manual install info dialog
664
+                self.manual_info_mode = true;
665
+                self.manual_info_index = self.selected_index;
666
+                self.copied_to_clipboard = false;
667
+            } else {
668
+                self.confirm_mode = true;
669
+                self.confirm_index = self.selected_index;
670
+            }
671
+        }
672
+    }
673
+
674
+    pub fn cancel_confirm(&mut self) {
675
+        self.confirm_mode = false;
676
+        self.manual_info_mode = false;
677
+        self.status_message = None;
678
+        self.copied_to_clipboard = false;
679
+    }
680
+
681
+    /// Get the manual install info for clipboard
682
+    pub fn get_manual_install_text(&self) -> Option<String> {
683
+        self.servers.get(self.manual_info_index).map(|s| {
684
+            // Remove the leading "# " from the install command
685
+            let cmd = s.install_cmd.trim_start_matches('#').trim();
686
+            format!("{} - {}\n{}", s.name, s.language, cmd)
687
+        })
688
+    }
689
+
690
+    /// Mark that text was copied to clipboard
691
+    pub fn mark_copied(&mut self) {
692
+        self.copied_to_clipboard = true;
693
+        self.status_message = Some("Copied to clipboard".to_string());
694
+    }
695
+
696
+    /// Get the server for manual info dialog
697
+    pub fn manual_info_server(&self) -> Option<&KnownServer> {
698
+        self.servers.get(self.manual_info_index)
699
+    }
700
+
701
+    /// Start the install process - spawns a background thread
702
+    pub fn start_install(&mut self) {
703
+        if self.confirm_index >= self.servers.len() {
704
+            self.confirm_mode = false;
705
+            return;
706
+        }
707
+
708
+        // Don't allow installing the same server twice
709
+        if self.installing_indices.contains(&self.confirm_index) {
710
+            self.status_message = Some("Already installing...".to_string());
711
+            self.confirm_mode = false;
712
+            return;
713
+        }
714
+
715
+        let server = &self.servers[self.confirm_index];
716
+        let name = server.name.to_string();
717
+        let cmd = server.install_cmd.to_string();
718
+        let check_cmd = server.check_cmd.to_string();
719
+        let server_index = self.confirm_index;
720
+
721
+        // Mark as installing
722
+        self.installing_indices.insert(server_index);
723
+        self.confirm_mode = false;
724
+        self.status_message = Some(format!("Installing {}...", name));
725
+
726
+        // Clone the sender for the thread
727
+        let tx = match &self.install_tx {
728
+            Some(tx) => tx.clone(),
729
+            None => return,
730
+        };
731
+
732
+        // Spawn install thread
733
+        thread::spawn(move || {
734
+            let result = run_install_command(&cmd);
735
+            let (success, message) = match &result {
736
+                Ok(_) => (true, String::new()),
737
+                Err(e) => {
738
+                    let err_msg = if e.len() > 50 { format!("{}...", &e[..50]) } else { e.clone() };
739
+                    (false, format!("Install failed: {}", err_msg))
740
+                }
741
+            };
742
+
743
+            let _ = tx.send(InstallResult {
744
+                server_index,
745
+                server_name: name,
746
+                check_cmd,
747
+                success,
748
+                message,
749
+            });
750
+        });
751
+    }
752
+
753
+    pub fn selected_server(&self) -> Option<&KnownServer> {
754
+        self.servers.get(self.selected_index)
755
+    }
756
+
757
+    pub fn confirm_server(&self) -> Option<&KnownServer> {
758
+        self.servers.get(self.confirm_index)
759
+    }
760
+}