v0.0.25 5dbbcb9

manager.go RawHistoryFolder

1// Package extensions implements the host side of zot's subprocess
2// extension protocol. The Manager discovers extensions in well-known
3// directories, spawns each one, completes the hello handshake, and
4// routes slash commands to the right extension.
5//
6// Each extension is its own process, communicating with zot over its
7// stdin/stdout in newline-delimited JSON. Stderr is redirected to a
8// per-extension log file under $ZOT_HOME/logs/. Crashing one
9// extension does not affect the others or the host.
10//
11// See docs/extensions.md for the user-facing reference and
12// internal/extproto for the wire format.
13package extensions
14
15import (
16	"bufio"
17	"context"
18	"encoding/json"
19	"errors"
20	"fmt"
21	"io"
22	"os"
23	"os/exec"
24	"path/filepath"
25	"strings"
26	"sync"
27	"syscall"
28	"time"
29
30	"github.com/patriceckhart/zot/internal/extproto"
31)
32
33// Manifest is the extension.json file shipped alongside an
34// extension's executable. It tells zot how to launch the extension
35// and provides display metadata.
36type Manifest struct {
37	Name        string   `json:"name"`
38	Version     string   `json:"version,omitempty"`
39	Exec        string   `json:"exec"`               // executable path, relative to manifest dir
40	Args        []string `json:"args,omitempty"`     // extra argv passed to exec
41	Language    string   `json:"language,omitempty"` // informational ("go", "python", "typescript", ...)
42	Enabled     *bool    `json:"enabled,omitempty"`  // nil = enabled
43	Description string   `json:"description,omitempty"`
44}
45
46// IsEnabled returns the manifest's effective enabled state. Default
47// is true so adding a new extension folder Just Works without an
48// extra zot ext enable command.
49func (m Manifest) IsEnabled() bool {
50	return m.Enabled == nil || *m.Enabled
51}
52
53// Extension is a running extension subprocess and the metadata zot
54// tracks about it.
55type Extension struct {
56	Manifest Manifest
57	Dir      string // absolute path to extension directory
58	LogPath  string
59
60	cmd      *exec.Cmd
61	stdin    io.WriteCloser
62	stdout   io.ReadCloser
63	logFile  *os.File
64	helloAck bool
65	commands []extproto.RegisterCommandFromExt
66	tools    []extproto.RegisterToolFromExt
67
68	// readyCh is closed when the extension sends a ReadyFromExt
69	// frame, or when the host gives up waiting (registrationGrace).
70	readyCh   chan struct{}
71	readyOnce sync.Once
72
73	// pending command invocations waiting on a CommandResponseFromExt
74	// keyed by the id we sent in CommandInvokedFromHost.
75	// pendingTool is the same idea for tool calls.
76	// pendingIntercept is the same idea for event_intercept calls.
77	mu               sync.Mutex
78	pending          map[string]chan extproto.CommandResponseFromExt
79	pendingTool      map[string]chan extproto.ToolResultFromExt
80	pendingIntercept map[string]chan extproto.EventInterceptResponseFromExt
81
82	// eventSubs and interceptSubs are the sets of event names this
83	// extension subscribed to via SubscribeFromExt. Used by
84	// EmitEvent / InterceptToolCall to filter recipients.
85	eventSubs     map[string]struct{}
86	interceptSubs map[string]struct{}
87}
88
89// HostHooks is the small interface the manager calls back into the
90// running TUI through. Decouples extensions from interactive.go.
91type HostHooks interface {
92	// Notify pushes an ext-originated status message into the chat.
93	// Level is one of "info", "warn", "error", "success".
94	Notify(extName, level, message string)
95
96	// Submit feeds text as if the user had typed and pressed enter,
97	// running it through the agent loop.
98	Submit(text string)
99
100	// Insert places text at the cursor in the editor.
101	Insert(text string)
102
103	// Display appends a one-shot styled note to the chat without
104	// invoking the model and without writing to the transcript.
105	Display(extName, text string)
106}
107
108// Manager owns every extension subprocess for the lifetime of zot.
109type Manager struct {
110	zotHome    string
111	cwd        string
112	zotVersion string
113	provider   string
114	model      string
115	hooks      HostHooks
116
117	mu  sync.RWMutex
118	ext map[string]*Extension // keyed by manifest name
119
120	// commandIndex maps a slash-command name (without the leading /)
121	// to the extension that registered it. First-come-first-served:
122	// later registrations of the same command are dropped with a
123	// warning.
124	commandIndex map[string]*Extension
125
126	// toolIndex maps an extension-defined tool name to its owning
127	// extension. Same first-come-first-served rule as commandIndex.
128	toolIndex map[string]*Extension
129}
130
131// New constructs an empty Manager. Call Discover to populate it from
132// the on-disk extension directories.
133func New(zotHome, cwd, zotVersion, provider, model string, hooks HostHooks) *Manager {
134	return &Manager{
135		zotHome:      zotHome,
136		cwd:          cwd,
137		zotVersion:   zotVersion,
138		provider:     provider,
139		model:        model,
140		hooks:        hooks,
141		ext:          map[string]*Extension{},
142		commandIndex: map[string]*Extension{},
143		toolIndex:    map[string]*Extension{},
144	}
145}
146
147// Discover scans the global and project extension dirs and starts
148// every extension whose manifest is enabled. Returns a slice of
149// errors encountered (one per extension); a single bad extension
150// doesn't abort the rest.
151func (m *Manager) Discover(ctx context.Context) []error {
152	var errs []error
153	for _, dir := range m.searchDirs() {
154		entries, err := os.ReadDir(dir)
155		if err != nil {
156			continue // missing directory is fine
157		}
158		for _, e := range entries {
159			if !e.IsDir() {
160				continue
161			}
162			extDir := filepath.Join(dir, e.Name())
163			if err := m.loadOne(ctx, extDir); err != nil {
164				errs = append(errs, fmt.Errorf("%s: %w", extDir, err))
165			}
166		}
167	}
168	return errs
169}
170
171// searchDirs returns the directories the discoverer walks, in
172// priority order: project-local first (so a project can override
173// global behavior), then global.
174func (m *Manager) searchDirs() []string {
175	var dirs []string
176	if m.cwd != "" {
177		dirs = append(dirs, filepath.Join(m.cwd, ".zot", "extensions"))
178	}
179	if m.zotHome != "" {
180		dirs = append(dirs, filepath.Join(m.zotHome, "extensions"))
181	}
182	return dirs
183}
184
185// loadOne reads a single extension's manifest and, if enabled,
186// spawns its subprocess + completes the hello handshake.
187func (m *Manager) loadOne(ctx context.Context, dir string) error {
188	manifestPath := filepath.Join(dir, "extension.json")
189	raw, err := os.ReadFile(manifestPath)
190	if err != nil {
191		return fmt.Errorf("read manifest: %w", err)
192	}
193	var mf Manifest
194	if err := json.Unmarshal(raw, &mf); err != nil {
195		return fmt.Errorf("parse manifest: %w", err)
196	}
197	if mf.Name == "" {
198		return errors.New("manifest: name is required")
199	}
200	if mf.Exec == "" {
201		return errors.New("manifest: exec is required")
202	}
203	if !mf.IsEnabled() {
204		// Quietly skip disabled extensions; zot ext list will show them.
205		return nil
206	}
207
208	m.mu.RLock()
209	_, dup := m.ext[mf.Name]
210	m.mu.RUnlock()
211	if dup {
212		// Project-local copy already won; ignore the global one.
213		return nil
214	}
215
216	ext := &Extension{
217		Manifest:         mf,
218		Dir:              dir,
219		readyCh:          make(chan struct{}),
220		pending:          map[string]chan extproto.CommandResponseFromExt{},
221		pendingTool:      map[string]chan extproto.ToolResultFromExt{},
222		pendingIntercept: map[string]chan extproto.EventInterceptResponseFromExt{},
223		eventSubs:        map[string]struct{}{},
224		interceptSubs:    map[string]struct{}{},
225	}
226	if err := m.spawn(ctx, ext); err != nil {
227		return err
228	}
229
230	m.mu.Lock()
231	m.ext[mf.Name] = ext
232	// Note: ext.commands and ext.tools may be empty here — they're
233	// populated by the read loop as register_* frames arrive after
234	// hello. Indexing happens in the read loop too. Discover()'s
235	// caller can WaitForReady() before relying on the registries.
236	m.mu.Unlock()
237	return nil
238}
239
240// WaitForReady blocks until every loaded extension has sent its
241// ReadyFromExt sentinel, or the per-extension grace period expires.
242// Call after Discover and before relying on tool registrations.
243func (m *Manager) WaitForReady(grace time.Duration) {
244	m.mu.RLock()
245	exts := make([]*Extension, 0, len(m.ext))
246	for _, e := range m.ext {
247		exts = append(exts, e)
248	}
249	m.mu.RUnlock()
250	for _, ext := range exts {
251		select {
252		case <-ext.readyCh:
253		case <-time.After(grace):
254			fmt.Fprintf(ext.logFile, "[zot] timed out waiting for ready frame; proceeding\n")
255			ext.readyOnce.Do(func() { close(ext.readyCh) })
256		}
257	}
258}
259
260// spawn launches the subprocess, hooks up pipes, logs stderr, and
261// runs the synchronous portion of the hello handshake. Asynchronous
262// frames are processed in a goroutine started here.
263func (m *Manager) spawn(ctx context.Context, ext *Extension) error {
264	logsDir := filepath.Join(m.zotHome, "logs")
265	_ = os.MkdirAll(logsDir, 0o755)
266	logPath := filepath.Join(logsDir, "ext-"+ext.Manifest.Name+".log")
267	logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
268	if err != nil {
269		return fmt.Errorf("open log: %w", err)
270	}
271	ext.LogPath = logPath
272	ext.logFile = logFile
273	fmt.Fprintf(logFile, "\n[zot] starting %s/%s at %s\n", ext.Manifest.Name, ext.Manifest.Version, time.Now().Format(time.RFC3339))
274
275	// Exec resolution rules:
276	//   - absolute path:                 used as-is.
277	//   - starts with "." (./ or ../):  resolved relative to ext.Dir.
278	//   - bare name (no path separator): looked up via $PATH so
279	//                                    "node", "npx", "python3",
280	//                                    "tsx" etc. work without
281	//                                    forcing absolute paths.
282	//   - other relative form (foo/bar): resolved relative to ext.Dir.
283	execPath := ext.Manifest.Exec
284	switch {
285	case filepath.IsAbs(execPath):
286		// keep
287	case strings.HasPrefix(execPath, "."+string(filepath.Separator)) ||
288		strings.HasPrefix(execPath, ".."+string(filepath.Separator)) ||
289		execPath == "." || execPath == "..":
290		execPath = filepath.Join(ext.Dir, execPath)
291	case strings.ContainsRune(execPath, filepath.Separator):
292		execPath = filepath.Join(ext.Dir, execPath)
293	default:
294		// bare name: leave as-is for exec.LookPath via exec.Command.
295	}
296	cmd := exec.CommandContext(ctx, execPath, ext.Manifest.Args...)
297	cmd.Dir = ext.Dir
298	cmd.Stderr = logFile
299
300	stdin, err := cmd.StdinPipe()
301	if err != nil {
302		return err
303	}
304	stdout, err := cmd.StdoutPipe()
305	if err != nil {
306		return err
307	}
308	if err := cmd.Start(); err != nil {
309		return fmt.Errorf("spawn: %w", err)
310	}
311	ext.cmd = cmd
312	ext.stdin = stdin
313	ext.stdout = stdout
314
315	// Hello handshake. Read the extension's HelloFromExt synchronously
316	// so we can fail fast on a broken extension; everything after is
317	// processed in the read goroutine.
318	scanner := bufio.NewScanner(stdout)
319	scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
320	if !scanner.Scan() {
321		return fmt.Errorf("extension exited before hello: %w", scanner.Err())
322	}
323	var hello extproto.HelloFromExt
324	if err := json.Unmarshal(scanner.Bytes(), &hello); err != nil {
325		return fmt.Errorf("parse hello: %w", err)
326	}
327	if hello.Type != "hello" || hello.Name == "" {
328		return fmt.Errorf("first frame must be hello (got %q)", hello.Type)
329	}
330	// Trust the manifest's name; ignore mismatch from the hello.
331	ext.helloAck = true
332
333	ack, _ := extproto.Encode(extproto.HelloAckFromHost{
334		Type:            "hello_ack",
335		ProtocolVersion: extproto.ProtocolVersion,
336		ZotVersion:      m.zotVersion,
337		Provider:        m.provider,
338		Model:           m.model,
339		CWD:             m.cwd,
340	})
341	if _, err := stdin.Write(ack); err != nil {
342		return fmt.Errorf("send hello_ack: %w", err)
343	}
344
345	// Spin up the read loop now that the handshake is done.
346	go m.readLoop(ext, scanner)
347	return nil
348}
349
350// readLoop processes every frame the extension sends after hello.
351// Returns when stdout closes.
352func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) {
353	defer func() {
354		// On close, drop every command + tool this extension owned so
355		// future invocations don't dangle. The subprocess is gone; we
356		// won't hear back about its commands or tool calls anymore.
357		m.mu.Lock()
358		for name, owner := range m.commandIndex {
359			if owner == ext {
360				delete(m.commandIndex, name)
361			}
362		}
363		for name, owner := range m.toolIndex {
364			if owner == ext {
365				delete(m.toolIndex, name)
366			}
367		}
368		m.mu.Unlock()
369		ext.readyOnce.Do(func() { close(ext.readyCh) })
370		fmt.Fprintf(ext.logFile, "[zot] extension %s read loop exited at %s\n", ext.Manifest.Name, time.Now().Format(time.RFC3339))
371	}()
372
373	for scanner.Scan() {
374		line := scanner.Bytes()
375		var frame extproto.Frame
376		if err := json.Unmarshal(line, &frame); err != nil {
377			fmt.Fprintf(ext.logFile, "[zot] malformed json from extension: %v\n", err)
378			continue
379		}
380		switch frame.Type {
381		case "register_command":
382			var rc extproto.RegisterCommandFromExt
383			if err := json.Unmarshal(line, &rc); err == nil {
384				ext.commands = append(ext.commands, rc)
385				m.mu.Lock()
386				if _, exists := m.commandIndex[rc.Name]; !exists {
387					m.commandIndex[rc.Name] = ext
388				}
389				m.mu.Unlock()
390			}
391		case "register_tool":
392			var rt extproto.RegisterToolFromExt
393			if err := json.Unmarshal(line, &rt); err != nil {
394				fmt.Fprintf(ext.logFile, "[zot] bad register_tool frame: %v\n", err)
395				continue
396			}
397			// Validate the schema parses as JSON. If not, refuse to
398			// register — a broken schema confuses the model.
399			if len(rt.Schema) > 0 {
400				var tmp any
401				if err := json.Unmarshal(rt.Schema, &tmp); err != nil {
402					fmt.Fprintf(ext.logFile, "[zot] tool %q: schema is not valid json (%v); skipped\n", rt.Name, err)
403					continue
404				}
405			}
406			ext.tools = append(ext.tools, rt)
407			m.mu.Lock()
408			if _, exists := m.toolIndex[rt.Name]; !exists {
409				m.toolIndex[rt.Name] = ext
410			}
411			m.mu.Unlock()
412		case "ready":
413			ext.readyOnce.Do(func() { close(ext.readyCh) })
414		case "subscribe":
415			var sub extproto.SubscribeFromExt
416			if err := json.Unmarshal(line, &sub); err == nil {
417				ext.mu.Lock()
418				for _, ev := range sub.Events {
419					ext.eventSubs[ev] = struct{}{}
420				}
421				for _, ev := range sub.Intercept {
422					if ev == "tool_call" { // only kind supported in v1
423						ext.interceptSubs[ev] = struct{}{}
424					}
425				}
426				ext.mu.Unlock()
427			}
428		case "event_intercept_response":
429			var er extproto.EventInterceptResponseFromExt
430			if err := json.Unmarshal(line, &er); err == nil {
431				ext.mu.Lock()
432				ch, ok := ext.pendingIntercept[er.ID]
433				if ok {
434					delete(ext.pendingIntercept, er.ID)
435				}
436				ext.mu.Unlock()
437				if ok {
438					select {
439					case ch <- er:
440					default:
441					}
442				}
443			}
444		case "tool_result":
445			var tr extproto.ToolResultFromExt
446			if err := json.Unmarshal(line, &tr); err == nil {
447				ext.mu.Lock()
448				ch, ok := ext.pendingTool[tr.ID]
449				if ok {
450					delete(ext.pendingTool, tr.ID)
451				}
452				ext.mu.Unlock()
453				if ok {
454					select {
455					case ch <- tr:
456					default:
457					}
458				}
459			}
460		case "notify":
461			var n extproto.NotifyFromExt
462			if err := json.Unmarshal(line, &n); err == nil {
463				m.hooks.Notify(ext.Manifest.Name, n.Level, n.Message)
464			}
465		case "command_response":
466			var cr extproto.CommandResponseFromExt
467			if err := json.Unmarshal(line, &cr); err == nil {
468				ext.mu.Lock()
469				ch, ok := ext.pending[cr.ID]
470				if ok {
471					delete(ext.pending, cr.ID)
472				}
473				ext.mu.Unlock()
474				if ok {
475					select {
476					case ch <- cr:
477					default:
478					}
479				}
480			}
481		case "shutdown_ack":
482			// Caller of Stop is waiting on the process exit, not this frame.
483		default:
484			fmt.Fprintf(ext.logFile, "[zot] unknown frame type %q\n", frame.Type)
485		}
486	}
487}
488
489// Commands returns a snapshot of every (extension, command) pair
490// currently registered. Used by the slash autocomplete + /help.
491func (m *Manager) Commands() []CommandInfo {
492	m.mu.RLock()
493	defer m.mu.RUnlock()
494	var out []CommandInfo
495	for _, ext := range m.ext {
496		for _, c := range ext.commands {
497			out = append(out, CommandInfo{
498				Extension:   ext.Manifest.Name,
499				Name:        c.Name,
500				Description: c.Description,
501			})
502		}
503	}
504	return out
505}
506
507// CommandInfo is one extension-registered slash command, surfaced to
508// the rest of zot for display purposes.
509type CommandInfo struct {
510	Extension   string
511	Name        string
512	Description string
513}
514
515// ToolInfo is one extension-registered tool. Used by the agent's
516// build step to materialise core.Tool wrappers.
517type ToolInfo struct {
518	Extension   string
519	Name        string
520	Description string
521	Schema      json.RawMessage
522}
523
524// Tools returns a snapshot of every (extension, tool) pair currently
525// registered. Used at agent-build time to fold extension tools into
526// the runtime tool registry.
527func (m *Manager) Tools() []ToolInfo {
528	m.mu.RLock()
529	defer m.mu.RUnlock()
530	var out []ToolInfo
531	for _, ext := range m.ext {
532		for _, t := range ext.tools {
533			out = append(out, ToolInfo{
534				Extension:   ext.Manifest.Name,
535				Name:        t.Name,
536				Description: t.Description,
537				Schema:      t.Schema,
538			})
539		}
540	}
541	return out
542}
543
544// HasTool reports whether name is registered by any extension.
545func (m *Manager) HasTool(name string) bool {
546	m.mu.RLock()
547	defer m.mu.RUnlock()
548	_, ok := m.toolIndex[name]
549	return ok
550}
551
552// InvokeTool sends a tool_call to the owning extension and waits for
553// the matching tool_result. Used by the core.Tool wrapper that the
554// agent registers per extension-defined tool.
555func (m *Manager) InvokeTool(ctx context.Context, name string, args json.RawMessage, timeout time.Duration) (extproto.ToolResultFromExt, error) {
556	m.mu.RLock()
557	ext, ok := m.toolIndex[name]
558	m.mu.RUnlock()
559	if !ok {
560		return extproto.ToolResultFromExt{}, fmt.Errorf("no extension registered for tool %q", name)
561	}
562
563	id := newCorrelationID()
564	ch := make(chan extproto.ToolResultFromExt, 1)
565	ext.mu.Lock()
566	ext.pendingTool[id] = ch
567	ext.mu.Unlock()
568
569	frame, _ := extproto.Encode(extproto.ToolCallFromHost{
570		Type: "tool_call",
571		ID:   id,
572		Name: name,
573		Args: args,
574	})
575	if _, err := ext.stdin.Write(frame); err != nil {
576		ext.mu.Lock()
577		delete(ext.pendingTool, id)
578		ext.mu.Unlock()
579		return extproto.ToolResultFromExt{}, fmt.Errorf("write: %w", err)
580	}
581
582	select {
583	case resp := <-ch:
584		return resp, nil
585	case <-time.After(timeout):
586		ext.mu.Lock()
587		delete(ext.pendingTool, id)
588		ext.mu.Unlock()
589		return extproto.ToolResultFromExt{}, fmt.Errorf("timeout waiting for %s/%s", ext.Manifest.Name, name)
590	case <-ctx.Done():
591		ext.mu.Lock()
592		delete(ext.pendingTool, id)
593		ext.mu.Unlock()
594		return extproto.ToolResultFromExt{}, ctx.Err()
595	}
596}
597
598// HasCommand reports whether name is registered by any extension.
599func (m *Manager) HasCommand(name string) bool {
600	m.mu.RLock()
601	defer m.mu.RUnlock()
602	_, ok := m.commandIndex[name]
603	return ok
604}
605
606// Invoke fires the named slash command's handler in the owning
607// extension and waits up to timeout for the response. Returns the
608// extension's CommandResponse so the caller can act on the action
609// (prompt / insert / display).
610func (m *Manager) Invoke(ctx context.Context, name, args string, timeout time.Duration) (extproto.CommandResponseFromExt, error) {
611	m.mu.RLock()
612	ext, ok := m.commandIndex[name]
613	m.mu.RUnlock()
614	if !ok {
615		return extproto.CommandResponseFromExt{}, fmt.Errorf("no extension registered for /%s", name)
616	}
617
618	id := newCorrelationID()
619	ch := make(chan extproto.CommandResponseFromExt, 1)
620	ext.mu.Lock()
621	ext.pending[id] = ch
622	ext.mu.Unlock()
623
624	frame, _ := extproto.Encode(extproto.CommandInvokedFromHost{
625		Type: "command_invoked",
626		ID:   id,
627		Name: name,
628		Args: args,
629	})
630	if _, err := ext.stdin.Write(frame); err != nil {
631		ext.mu.Lock()
632		delete(ext.pending, id)
633		ext.mu.Unlock()
634		return extproto.CommandResponseFromExt{}, fmt.Errorf("write: %w", err)
635	}
636
637	select {
638	case resp := <-ch:
639		return resp, nil
640	case <-time.After(timeout):
641		ext.mu.Lock()
642		delete(ext.pending, id)
643		ext.mu.Unlock()
644		return extproto.CommandResponseFromExt{}, fmt.Errorf("timeout waiting for %s/%s", ext.Manifest.Name, name)
645	case <-ctx.Done():
646		ext.mu.Lock()
647		delete(ext.pending, id)
648		ext.mu.Unlock()
649		return extproto.CommandResponseFromExt{}, ctx.Err()
650	}
651}
652
653// Stop cleanly terminates every extension. Sends ShutdownFromHost,
654// waits up to gracePeriod for each subprocess to exit, then SIGTERMs
655// (and SIGKILLs after another second) the holdouts.
656func (m *Manager) Stop(gracePeriod time.Duration) {
657	m.mu.RLock()
658	exts := make([]*Extension, 0, len(m.ext))
659	for _, e := range m.ext {
660		exts = append(exts, e)
661	}
662	m.mu.RUnlock()
663
664	for _, ext := range exts {
665		if frame, err := extproto.Encode(extproto.ShutdownFromHost{Type: "shutdown"}); err == nil {
666			_, _ = ext.stdin.Write(frame)
667		}
668		_ = ext.stdin.Close()
669	}
670
671	deadline := time.Now().Add(gracePeriod)
672	for _, ext := range exts {
673		remaining := time.Until(deadline)
674		if remaining <= 0 {
675			remaining = 100 * time.Millisecond
676		}
677		done := make(chan struct{})
678		go func() { _ = ext.cmd.Wait(); close(done) }()
679		select {
680		case <-done:
681		case <-time.After(remaining):
682			_ = ext.cmd.Process.Signal(syscall.SIGTERM)
683			select {
684			case <-done:
685			case <-time.After(time.Second):
686				_ = ext.cmd.Process.Kill()
687				<-done
688			}
689		}
690		if ext.logFile != nil {
691			_ = ext.logFile.Close()
692		}
693	}
694}
695
696// All returns every extension currently tracked, enabled or not.
697// Used by `zot ext list`.
698func (m *Manager) All() []*Extension {
699	m.mu.RLock()
700	defer m.mu.RUnlock()
701	out := make([]*Extension, 0, len(m.ext))
702	for _, e := range m.ext {
703		out = append(out, e)
704	}
705	return out
706}
707
708// newCorrelationID returns a short non-cryptographic id. We don't
709// need uniqueness across processes, just within the lifetime of one
710// extension's pending map.
711func newCorrelationID() string {
712	return strings.ReplaceAll(time.Now().Format("150405.000000"), ".", "")
713}

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 477

↑↓ moveenter openesc close