v0.50.1 a637010

manager.go RawHistoryFolder

1package lsp
2
3import (
4	"bytes"
5	"context"
6	"crypto/sha256"
7	"encoding/hex"
8	"encoding/json"
9	"errors"
10	"fmt"
11	"os"
12	"os/exec"
13	"path/filepath"
14	"strings"
15	"sync"
16	"time"
17)
18
19// ManagerOptions controls process and command timeouts.
20type ManagerOptions struct {
21	RequestTimeout  time.Duration
22	DiagnosticDelay time.Duration
23	LinterTimeout   time.Duration
24	Settings        map[string]any
25	OnLog           func(server, message string)
26	CheckWritePath  func(path string) error
27}
28
29// Response is one raw request result tagged with the server which produced
30// it. Errors are retained so a partially installed workspace remains useful.
31type Response struct {
32	Server string
33	Result json.RawMessage
34	Error  error
35}
36
37type workspace struct {
38	cwd            string
39	rootReal       string
40	config         Config
41	clients        map[string]*Client
42	starting       map[string]chan struct{}
43	statuses       map[string]string
44	errors         map[string]string
45	diagnostics    map[string]map[string][]Diagnostic
46	cliDiagnostics map[string][]Diagnostic
47	opened         map[string]map[string]string // server -> path -> content hash
48	surfaced       map[string]map[string]bool   // path -> location-independent diagnostic identities
49	logs           []string
50}
51
52// Manager owns per-workspace LSP processes. A process is cached by cwd and
53// server ID until Close or Reload, so a series of model calls does not spawn a
54// new language server for every request.
55type Manager struct {
56	mu         sync.Mutex
57	workspaces map[string]*workspace
58	options    ManagerOptions
59	closed     bool
60}
61
62var errManagerClosed = errors.New("LSP manager is closed")
63
64func NewManager() *Manager {
65	return NewManagerWithOptions(ManagerOptions{})
66}
67
68func NewManagerWithOptions(options ManagerOptions) *Manager {
69	if options.RequestTimeout <= 0 {
70		options.RequestTimeout = 12 * time.Second
71	}
72	if options.DiagnosticDelay <= 0 {
73		options.DiagnosticDelay = 300 * time.Millisecond
74	}
75	if options.LinterTimeout <= 0 {
76		options.LinterTimeout = 30 * time.Second
77	}
78	return &Manager{workspaces: make(map[string]*workspace), options: options}
79}
80
81func (m *Manager) workspace(cwd string) (*workspace, error) {
82	cwd, err := absDir(cwd)
83	if err != nil {
84		return nil, err
85	}
86	m.mu.Lock()
87	if m.closed {
88		m.mu.Unlock()
89		return nil, errManagerClosed
90	}
91	if value := m.workspaces[cwd]; value != nil {
92		m.mu.Unlock()
93		return value, nil
94	}
95	m.mu.Unlock()
96	config, err := LoadConfig(cwd)
97	if err != nil {
98		return nil, err
99	}
100	rootReal := cwd
101	if resolved, resolveErr := filepath.EvalSymlinks(cwd); resolveErr == nil {
102		rootReal = resolved
103	}
104	value := &workspace{cwd: cwd, rootReal: rootReal, config: config, clients: make(map[string]*Client), starting: make(map[string]chan struct{}), statuses: make(map[string]string), errors: make(map[string]string), diagnostics: make(map[string]map[string][]Diagnostic), cliDiagnostics: make(map[string][]Diagnostic), opened: make(map[string]map[string]string), surfaced: make(map[string]map[string]bool)}
105	m.mu.Lock()
106	defer m.mu.Unlock()
107	if m.closed {
108		return nil, errManagerClosed
109	}
110	if existing := m.workspaces[cwd]; existing != nil {
111		value = existing
112	} else {
113		m.workspaces[cwd] = value
114	}
115	return value, nil
116}
117
118// Config returns the effective merged configuration for cwd.
119func (m *Manager) Config(cwd string) (Config, error) {
120	ws, err := m.workspace(cwd)
121	if err != nil {
122		return Config{}, err
123	}
124	return ws.config, nil
125}
126
127func (m *Manager) applicable(ws *workspace, path string) []ServerConfig {
128	return ws.config.ApplicableServers(ws.cwd, path)
129}
130
131func (m *Manager) ensureClient(ctx context.Context, ws *workspace, spec ServerConfig) (*Client, error) {
132	if ctx == nil {
133		ctx = context.Background()
134	}
135	for {
136		m.mu.Lock()
137		if m.closed || m.workspaces[ws.cwd] != ws {
138			m.mu.Unlock()
139			return nil, errManagerClosed
140		}
141		if client := ws.clients[spec.ID]; client != nil {
142			m.mu.Unlock()
143			return client, nil
144		}
145		if waiting := ws.starting[spec.ID]; waiting != nil {
146			m.mu.Unlock()
147			select {
148			case <-waiting:
149				continue
150			case <-ctx.Done():
151				return nil, ctx.Err()
152			}
153		}
154		starting := make(chan struct{})
155		ws.starting[spec.ID] = starting
156		ws.statuses[spec.ID] = "starting"
157		m.mu.Unlock()
158		root := FindRoot(ws.cwd, spec.RootMarkers)
159		opts := ClientOptions{
160			Settings:              wsSettings(m.options.Settings, spec.Settings),
161			InitializationOptions: spec.InitializationOptions,
162			OnDiagnostics:         func(diagnostics []Diagnostic) { m.storeDiagnostics(ws, spec.ID, diagnostics) },
163			OnLog:                 func(message string) { m.log(ws, spec.ID, message) },
164			// A server's LSP root may be an ancestor of the session cwd.
165			// Keep server-initiated edits inside the session workspace rather
166			// than trusting that broader project root.
167			ApplyEdit: func(edit WorkspaceEdit) error {
168				return applyWorkspaceEdit(ws.cwd, edit, m.options.CheckWritePath)
169			},
170		}
171		startCtx, cancel := context.WithTimeout(ctx, m.options.RequestTimeout)
172		client, err := NewClientWithContext(startCtx, spec, root, opts)
173		cancel()
174		m.mu.Lock()
175		delete(ws.starting, spec.ID)
176		close(starting)
177		if err != nil {
178			if m.closed || m.workspaces[ws.cwd] != ws {
179				m.mu.Unlock()
180				return nil, errManagerClosed
181			}
182			if ctx.Err() != nil {
183				ws.statuses[spec.ID] = "stopped"
184				delete(ws.errors, spec.ID)
185				m.mu.Unlock()
186				return nil, err
187			}
188			ws.statuses[spec.ID] = "missing"
189			ws.errors[spec.ID] = err.Error()
190			m.mu.Unlock()
191			return nil, err
192		}
193		if m.closed || m.workspaces[ws.cwd] != ws {
194			m.mu.Unlock()
195			_ = client.Close()
196			return nil, errManagerClosed
197		}
198		if existing := ws.clients[spec.ID]; existing != nil {
199			m.mu.Unlock()
200			_ = client.Close()
201			return existing, nil
202		}
203		ws.clients[spec.ID] = client
204		ws.statuses[spec.ID] = "ready"
205		delete(ws.errors, spec.ID)
206		m.mu.Unlock()
207		return client, nil
208	}
209}
210
211func wsSettings(global, local map[string]any) map[string]any {
212	if len(global) == 0 && len(local) == 0 {
213		return nil
214	}
215	out := make(map[string]any, len(global)+len(local))
216	for key, value := range global {
217		out[key] = value
218	}
219	for key, value := range local {
220		out[key] = value
221	}
222	return out
223}
224
225func (m *Manager) storeDiagnostics(ws *workspace, server string, diagnostics []Diagnostic) {
226	byPath := make(map[string][]Diagnostic)
227	cleared := make(map[string]bool)
228	for _, diagnostic := range diagnostics {
229		if diagnostic.Path == "" || !diagnosticInWorkspace(ws.rootReal, diagnostic.Path) {
230			continue
231		}
232		if diagnostic.Clear {
233			cleared[diagnostic.Path] = true
234			continue
235		}
236		byPath[diagnostic.Path] = append(byPath[diagnostic.Path], diagnostic)
237	}
238	m.mu.Lock()
239	if ws.diagnostics[server] == nil {
240		ws.diagnostics[server] = make(map[string][]Diagnostic)
241	}
242	for path := range cleared {
243		delete(ws.diagnostics[server], path)
244	}
245	for path, values := range byPath {
246		ws.diagnostics[server][path] = values
247	}
248	m.mu.Unlock()
249}
250
251func (m *Manager) log(ws *workspace, server, message string) {
252	message = strings.TrimSpace(message)
253	if message == "" {
254		return
255	}
256	if len(message) > 4096 {
257		message = message[:4095] + "…"
258	}
259	m.mu.Lock()
260	ws.logs = append(ws.logs, server+": "+message)
261	if len(ws.logs) > 100 {
262		ws.logs = ws.logs[len(ws.logs)-100:]
263	}
264	m.mu.Unlock()
265	if m.options.OnLog != nil {
266		m.options.OnLog(server, message)
267	}
268}
269
270// Open opens a document in every applicable LSP server.
271func (m *Manager) Open(ctx context.Context, cwd, path string) error {
272	ws, err := m.workspace(cwd)
273	if err != nil {
274		return err
275	}
276	path, err = absoluteFile(ws.cwd, path)
277	if err != nil {
278		return err
279	}
280	text, err := os.ReadFile(path)
281	if err != nil {
282		return err
283	}
284	for _, spec := range m.applicable(ws, path) {
285		if spec.Kind != "lsp" {
286			continue
287		}
288		client, clientErr := m.ensureClient(ctx, ws, spec)
289		if clientErr != nil {
290			if err == nil {
291				err = clientErr
292			}
293			continue
294		}
295		hash := contentHash(text)
296		m.mu.Lock()
297		if ws.opened[spec.ID] == nil {
298			ws.opened[spec.ID] = make(map[string]string)
299		}
300		old, open := ws.opened[spec.ID][path]
301		m.mu.Unlock()
302		if !open {
303			languageID := spec.LanguageID
304			if languageID == "" {
305				languageID = languageForExtension(filepath.Ext(path))
306			}
307			if clientErr = client.DidOpen(path, languageID, string(text)); clientErr == nil {
308				m.mu.Lock()
309				ws.opened[spec.ID][path] = hash
310				m.mu.Unlock()
311			}
312		} else if old != hash {
313			if clientErr = client.DidChange(path, string(text)); clientErr == nil {
314				m.mu.Lock()
315				ws.opened[spec.ID][path] = hash
316				m.mu.Unlock()
317			}
318		}
319		if clientErr != nil && err == nil {
320			err = clientErr
321		}
322	}
323	return err
324}
325
326func (m *Manager) Save(ctx context.Context, cwd, path string) error {
327	openErr := m.Open(ctx, cwd, path)
328	ws, err := m.workspace(cwd)
329	if err != nil {
330		return err
331	}
332	path, err = absoluteFile(ws.cwd, path)
333	if err != nil {
334		return err
335	}
336	text, err := os.ReadFile(path)
337	if err != nil {
338		return err
339	}
340	for _, spec := range m.applicable(ws, path) {
341		if spec.Kind != "lsp" {
342			continue
343		}
344		m.mu.Lock()
345		client := ws.clients[spec.ID]
346		m.mu.Unlock()
347		if client != nil {
348			_ = client.DidSave(path, string(text))
349		}
350	}
351	return openErr
352}
353
354// Diagnostics obtains current LSP and CLI diagnostics. It deliberately waits
355// only a bounded debounce interval for asynchronous publishDiagnostics.
356func (m *Manager) Diagnostics(ctx context.Context, cwd, path string) ([]Diagnostic, error) {
357	return m.DiagnosticsWithOptions(ctx, cwd, path, true)
358}
359
360func (m *Manager) DiagnosticsWithOptions(ctx context.Context, cwd, path string, runCLI bool) ([]Diagnostic, error) {
361	ws, err := m.workspace(cwd)
362	if err != nil {
363		return nil, err
364	}
365	var firstErr error
366	if path != "" {
367		if saveErr := m.Save(ctx, ws.cwd, path); saveErr != nil && !errors.Is(saveErr, os.ErrNotExist) {
368			// Keep cached diagnostics, but make an unavailable or failed
369			// server visible to the model-facing lsp action.
370			firstErr = saveErr
371		}
372	}
373	for _, spec := range m.applicable(ws, path) {
374		if spec.Kind != "lsp" {
375			continue
376		}
377		m.mu.Lock()
378		client := ws.clients[spec.ID]
379		m.mu.Unlock()
380		if client != nil {
381			if waitErr := client.WaitForDiagnostics(ctx, m.options.DiagnosticDelay); waitErr != nil && firstErr == nil && !errors.Is(waitErr, context.Canceled) {
382				firstErr = waitErr
383			}
384		}
385	}
386	if runCLI {
387		if err := m.RunLinters(ctx, ws.cwd, path); err != nil && firstErr == nil {
388			firstErr = err
389		}
390	}
391	return m.snapshot(ws, path), firstErr
392}
393
394// ReduceDiagnostics returns only diagnostics that have not already been
395// surfaced for this workspace path. The ledger deliberately ignores line
396// and column changes, matching the behavior users want after a harmless edit:
397// unchanged problems do not consume another tool-result context window.
398// Passing an empty snapshot clears the path so a later recurrence is fresh.
399func (m *Manager) ReduceDiagnostics(cwd, path string, diagnostics []Diagnostic) []Diagnostic {
400	ws, err := m.workspace(cwd)
401	if err != nil {
402		return diagnostics
403	}
404	path, err = absoluteFile(ws.cwd, path)
405	if err != nil {
406		return diagnostics
407	}
408	m.mu.Lock()
409	defer m.mu.Unlock()
410	if len(diagnostics) == 0 {
411		delete(ws.surfaced, path)
412		return nil
413	}
414	previous := ws.surfaced[path]
415	current := make(map[string]bool, len(diagnostics))
416	fresh := make([]Diagnostic, 0, len(diagnostics))
417	for _, diagnostic := range diagnostics {
418		identity := DiagnosticIdentity(diagnostic)
419		current[identity] = true
420		if !previous[identity] {
421			fresh = append(fresh, diagnostic)
422		}
423	}
424	ws.surfaced[path] = current
425	return fresh
426}
427
428func (m *Manager) snapshot(ws *workspace, path string) []Diagnostic {
429	m.mu.Lock()
430	defer m.mu.Unlock()
431	var all []Diagnostic
432	for _, files := range ws.diagnostics {
433		for file, diagnostics := range files {
434			if path == "" || samePath(file, path) {
435				all = append(all, diagnostics...)
436			}
437		}
438	}
439	for _, diagnostics := range ws.cliDiagnostics {
440		for _, diagnostic := range diagnostics {
441			if path == "" || samePath(diagnostic.Path, path) {
442				all = append(all, diagnostic)
443			}
444		}
445	}
446	return DeduplicateDiagnostics(all)
447}
448
449// Request sends one request to a named LSP server.
450func (m *Manager) Request(ctx context.Context, cwd, server, method string, params any) (json.RawMessage, error) {
451	ws, err := m.workspace(cwd)
452	if err != nil {
453		return nil, err
454	}
455	spec, ok := ws.config.Servers[server]
456	if !ok || spec.Disabled {
457		return nil, fmt.Errorf("unknown or disabled LSP server %q", server)
458	}
459	if spec.Kind != "lsp" || spec.IsLinter {
460		return nil, fmt.Errorf("%s is a linter and cannot serve navigation requests", server)
461	}
462	client, err := m.ensureClient(ctx, ws, spec)
463	if err != nil {
464		return nil, err
465	}
466	return m.requestWithTimeout(ctx, client, method, params)
467}
468
469func (m *Manager) requestWithTimeout(ctx context.Context, client *Client, method string, params any) (json.RawMessage, error) {
470	if ctx == nil {
471		ctx = context.Background()
472	}
473	requestCtx, cancel := context.WithTimeout(ctx, m.options.RequestTimeout)
474	defer cancel()
475	return client.Request(requestCtx, method, params)
476}
477
478// RequestAll sends a request to every applicable LSP server. If path is set,
479// the document is opened first so servers have current text and URI context.
480func (m *Manager) RequestAll(ctx context.Context, cwd, path, method string, params any) []Response {
481	ws, err := m.workspace(cwd)
482	if err != nil {
483		return []Response{{Error: err}}
484	}
485	if path != "" {
486		_ = m.Open(ctx, ws.cwd, path)
487	}
488	var responses []Response
489	for _, spec := range m.applicable(ws, path) {
490		if spec.Kind != "lsp" || spec.IsLinter {
491			continue
492		}
493		client, clientErr := m.ensureClient(ctx, ws, spec)
494		if clientErr != nil {
495			responses = append(responses, Response{Server: spec.ID, Error: clientErr})
496			continue
497		}
498		result, requestErr := m.requestWithTimeout(ctx, client, method, params)
499		responses = append(responses, Response{Server: spec.ID, Result: result, Error: requestErr})
500	}
501	return responses
502}
503
504// ApplyEdit applies a workspace edit under the selected workspace root.
505func (m *Manager) ApplyEdit(cwd string, edit WorkspaceEdit) error {
506	return m.ApplyEdits(cwd, []WorkspaceEdit{edit})
507}
508
509// ApplyEdits preflights and applies multiple workspace edits as one operation.
510// This prevents a later unsafe edit from leaving earlier edits applied.
511func (m *Manager) ApplyEdits(cwd string, edits []WorkspaceEdit) error {
512	ws, err := m.workspace(cwd)
513	if err != nil {
514		return err
515	}
516	merged := WorkspaceEdit{Changes: make(map[string][]TextEdit)}
517	for _, edit := range edits {
518		for uri, changes := range edit.Changes {
519			merged.Changes[uri] = append(merged.Changes[uri], changes...)
520		}
521		merged.DocumentChanges = append(merged.DocumentChanges, edit.DocumentChanges...)
522	}
523	return applyWorkspaceEdit(ws.cwd, merged, m.options.CheckWritePath)
524}
525
526// Status describes configured and cached servers without starting missing
527// processes. It is safe to call for a workspace with no installed tools.
528type ServerStatus struct {
529	ID           string          `json:"id"`
530	Kind         string          `json:"kind"`
531	Command      string          `json:"command"`
532	Root         string          `json:"root,omitempty"`
533	State        string          `json:"state"`
534	Error        string          `json:"error,omitempty"`
535	Diagnostics  int             `json:"diagnostics,omitempty"`
536	Capabilities json.RawMessage `json:"capabilities,omitempty"`
537}
538
539func (m *Manager) Status(cwd, path string) ([]ServerStatus, error) {
540	ws, err := m.workspace(cwd)
541	if err != nil {
542		return nil, err
543	}
544	specs := m.applicable(ws, path)
545	roots := make(map[string]string, len(specs))
546	resolveErrors := make(map[string]error, len(specs))
547	for _, spec := range specs {
548		roots[spec.ID] = displayWorkspacePath(ws.cwd, FindRoot(ws.cwd, spec.RootMarkers))
549		if _, resolveErr := ResolveCommand(ws.cwd, spec.Command); resolveErr != nil {
550			resolveErrors[spec.ID] = resolveErr
551		}
552	}
553	m.mu.Lock()
554	defer m.mu.Unlock()
555	out := make([]ServerStatus, 0, len(specs))
556	for _, spec := range specs {
557		status := ServerStatus{ID: spec.ID, Kind: spec.Kind, Command: spec.Command, Root: roots[spec.ID], State: ws.statuses[spec.ID]}
558		if status.State == "" {
559			status.State = "stopped"
560		}
561		if status.State == "stopped" {
562			if resolveErr := resolveErrors[spec.ID]; resolveErr != nil {
563				status.State = "missing"
564				status.Error = resolveErr.Error()
565			}
566		}
567		if client := ws.clients[spec.ID]; client != nil {
568			status.Capabilities = client.Capabilities()
569		}
570		for _, values := range ws.diagnostics[spec.ID] {
571			status.Diagnostics += len(values)
572		}
573		status.Diagnostics += len(ws.cliDiagnostics[spec.ID])
574		if configuredErr := ws.errors[spec.ID]; configuredErr != "" {
575			status.Error = configuredErr
576		}
577		out = append(out, status)
578	}
579	return out, nil
580}
581
582// Capabilities starts matching servers when needed. The context bounds server
583// startup and initialization.
584func (m *Manager) Capabilities(ctx context.Context, cwd, path, server string) (map[string]json.RawMessage, error) {
585	if ctx == nil {
586		ctx = context.Background()
587	}
588	ws, err := m.workspace(cwd)
589	if err != nil {
590		return nil, err
591	}
592	ensure := func(spec ServerConfig) (*Client, error) {
593		if spec.Kind != "lsp" || spec.IsLinter {
594			return nil, fmt.Errorf("%s is not a navigable LSP server", spec.ID)
595		}
596		m.mu.Lock()
597		client := ws.clients[spec.ID]
598		m.mu.Unlock()
599		if client != nil {
600			return client, nil
601		}
602		return m.ensureClient(ctx, ws, spec)
603	}
604	if server != "" {
605		spec, ok := ws.config.Servers[server]
606		if !ok || spec.Disabled {
607			return nil, fmt.Errorf("unknown or disabled server %q", server)
608		}
609		client, err := ensure(spec)
610		if err != nil {
611			return nil, err
612		}
613		return map[string]json.RawMessage{server: client.Capabilities()}, nil
614	}
615	statuses, err := m.Status(cwd, path)
616	if err != nil {
617		return nil, err
618	}
619	out := make(map[string]json.RawMessage)
620	var firstErr error
621	for _, status := range statuses {
622		if err := ctx.Err(); err != nil {
623			if firstErr == nil {
624				firstErr = err
625			}
626			break
627		}
628		spec := ws.config.Servers[status.ID]
629		if spec.Kind != "lsp" || spec.IsLinter {
630			continue
631		}
632		client, clientErr := ensure(spec)
633		if clientErr != nil {
634			if firstErr == nil {
635				firstErr = clientErr
636			}
637			continue
638		}
639		if capabilities := client.Capabilities(); len(capabilities) > 0 {
640			out[status.ID] = capabilities
641		}
642	}
643	return out, firstErr
644}
645
646// Reload terminates cached processes for cwd and reloads all lsp.json layers.
647func (m *Manager) Reload(cwd string) error {
648	cwd, err := absDir(cwd)
649	if err != nil {
650		return err
651	}
652	m.mu.Lock()
653	if m.closed {
654		m.mu.Unlock()
655		return errManagerClosed
656	}
657	ws := m.workspaces[cwd]
658	delete(m.workspaces, cwd)
659	m.mu.Unlock()
660	if ws != nil {
661		for _, client := range m.clientsSnapshot(ws) {
662			_ = client.Close()
663		}
664	}
665	return nil
666}
667
668func (m *Manager) clientsSnapshot(ws *workspace) []*Client {
669	m.mu.Lock()
670	defer m.mu.Unlock()
671	clients := make([]*Client, 0, len(ws.clients))
672	for _, client := range ws.clients {
673		clients = append(clients, client)
674	}
675	return clients
676}
677
678func (m *Manager) Close() error {
679	m.mu.Lock()
680	if m.closed {
681		m.mu.Unlock()
682		return nil
683	}
684	m.closed = true
685	workspaces := make([]*workspace, 0, len(m.workspaces))
686	for _, ws := range m.workspaces {
687		workspaces = append(workspaces, ws)
688	}
689	m.workspaces = make(map[string]*workspace)
690	m.mu.Unlock()
691	var first error
692	for _, ws := range workspaces {
693		for _, client := range m.clientsSnapshot(ws) {
694			if err := client.Close(); err != nil && first == nil {
695				first = err
696			}
697		}
698	}
699	return first
700}
701
702// RunLinters executes configured CLI providers without a shell. A non-zero
703// linter exit is normal when it emitted parseable findings.
704func (m *Manager) RunLinters(ctx context.Context, cwd, path string) error {
705	if ctx == nil {
706		ctx = context.Background()
707	}
708	ws, err := m.workspace(cwd)
709	if err != nil {
710		return err
711	}
712	var files []string
713	if path != "" {
714		if abs, pathErr := absoluteFile(ws.cwd, path); pathErr == nil {
715			files = []string{abs}
716		}
717	}
718	var firstErr error
719	for _, spec := range m.applicable(ws, path) {
720		if spec.Kind != "cli" || spec.Disabled {
721			continue
722		}
723		command, resolveErr := ResolveCommand(ws.cwd, spec.Command)
724		if resolveErr != nil {
725			m.mu.Lock()
726			ws.statuses[spec.ID] = "missing"
727			ws.errors[spec.ID] = resolveErr.Error()
728			m.mu.Unlock()
729			if firstErr == nil {
730				firstErr = resolveErr
731			}
732			continue
733		}
734		args := make([]string, 0, len(spec.Args)+len(files))
735		containsFiles := false
736		relative := relativeFiles(ws.cwd, files)
737		for _, arg := range spec.Args {
738			if arg == "{files}" {
739				containsFiles = true
740				args = append(args, relative...)
741				continue
742			}
743			if strings.Contains(arg, "{files}") {
744				containsFiles = true
745				if len(relative) == 0 {
746					args = append(args, strings.ReplaceAll(arg, "{files}", ""))
747				} else {
748					for _, file := range relative {
749						args = append(args, strings.ReplaceAll(arg, "{files}", file))
750					}
751				}
752				continue
753			}
754			args = append(args, arg)
755		}
756		if spec.Mode == "workspace" || (spec.Mode == "" && len(files) == 0) {
757			if !containsFiles && (len(args) == 0 || args[len(args)-1] != ".") {
758				args = append(args, ".")
759			}
760		} else if !containsFiles {
761			args = append(args, relative...)
762		}
763		commandCtx, cancel := context.WithTimeout(ctx, m.options.LinterTimeout)
764		cmd := exec.CommandContext(commandCtx, command, args...)
765		cmd.WaitDelay = 2 * time.Second
766		cmd.Dir = ws.cwd
767		cmd.Env = mergedEnvironment(spec.Env)
768		var stdout, stderr limitedBuffer
769		stdout.limit = 8 * 1024 * 1024
770		stderr.limit = 8 * 1024 * 1024
771		cmd.Stdout = &stdout
772		cmd.Stderr = &stderr
773		m.mu.Lock()
774		ws.statuses[spec.ID] = "running"
775		m.mu.Unlock()
776		runErr := cmd.Run()
777		cancel()
778		text := strings.TrimSpace(stdout.String())
779		if text == "" {
780			text = strings.TrimSpace(stderr.String())
781		}
782		diagnostics := ParseDiagnostics(spec.Parser, spec.ID, ws.cwd, text)
783		filtered := diagnostics[:0]
784		for _, diagnostic := range diagnostics {
785			if diagnostic.Path != "" && diagnosticInWorkspace(ws.rootReal, diagnostic.Path) {
786				filtered = append(filtered, diagnostic)
787			}
788		}
789		diagnostics = filtered
790		m.mu.Lock()
791		ws.cliDiagnostics[spec.ID] = diagnostics
792		if runErr != nil && len(diagnostics) == 0 {
793			ws.errors[spec.ID] = runErr.Error()
794			ws.statuses[spec.ID] = "error"
795		} else {
796			delete(ws.errors, spec.ID)
797			ws.statuses[spec.ID] = "ready"
798		}
799		m.mu.Unlock()
800		if runErr != nil && len(diagnostics) == 0 && firstErr == nil {
801			firstErr = fmt.Errorf("%s: %w", spec.ID, runErr)
802		}
803	}
804	return firstErr
805}
806
807func contentHash(data []byte) string {
808	sum := sha256.Sum256(data)
809	return hex.EncodeToString(sum[:])
810}
811
812func displayWorkspacePath(root, path string) string {
813	if path == "" {
814		return ""
815	}
816	rel, err := filepath.Rel(root, path)
817	if err != nil {
818		return ""
819	}
820	if rel == "." {
821		return "."
822	}
823	return filepath.ToSlash(rel)
824}
825
826func relativeFiles(cwd string, files []string) []string {
827	out := make([]string, len(files))
828	for i, path := range files {
829		rel, err := filepath.Rel(cwd, path)
830		if err != nil {
831			out[i] = path
832		} else {
833			out[i] = filepath.ToSlash(rel)
834		}
835	}
836	return out
837}
838func absoluteFile(cwd, path string) (string, error) {
839	if path == "" {
840		return "", errors.New("path is required")
841	}
842	if !filepath.IsAbs(path) {
843		path = filepath.Join(cwd, path)
844	}
845	return filepath.Abs(path)
846}
847func diagnosticInWorkspace(rootReal, path string) bool {
848	return safeWorkspacePath(rootReal, path) == nil
849}
850
851func samePath(a, b string) bool {
852	aa, _ := filepath.Abs(a)
853	bb, _ := filepath.Abs(b)
854	return filepath.Clean(aa) == filepath.Clean(bb)
855}
856
857type limitedBuffer struct {
858	buffer bytes.Buffer
859	limit  int
860}
861
862func (b *limitedBuffer) Len() int { return b.buffer.Len() }
863
864func (b *limitedBuffer) String() string { return b.buffer.String() }
865
866func (b *limitedBuffer) Write(p []byte) (int, error) {
867	original := len(p)
868	if b.limit <= b.buffer.Len() {
869		return original, nil
870	}
871	room := b.limit - b.buffer.Len()
872	if len(p) > room {
873		p = p[:room]
874	}
875	_, _ = b.buffer.Write(p)
876	return original, nil
877}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close