main ece7682

proto_convert_session.go RawHistoryFolder

1package sessionwire
2
3import (
4	"bytes"
5	"compress/zlib"
6	"errors"
7	"io"
8	"math"
9	"sync"
10
11	"google.golang.org/protobuf/proto"
12
13	"github.com/bnema/vev/internal/domain"
14	"github.com/bnema/vev/internal/domain/terminalcap"
15	"github.com/bnema/vev/internal/protocol"
16	"github.com/bnema/vev/internal/protocol/wire"
17)
18
19// outputCompressionThreshold preserves the small-snapshot compression threshold: small snapshots stay
20// on the canonical raw path.
21const protoOutputCompressionThreshold = 1024
22
23func helloToWire(message protocol.Hello) (*wire.Hello, error) {
24	cols, rows, pixelWidth, pixelHeight, err := geometryToWire(message.Size, message.PixelWidth, message.PixelHeight)
25	if err != nil {
26		return nil, err
27	}
28	sessionTarget, err := sessionAttachTargetToWire(message.SessionTarget)
29	if err != nil {
30		return nil, err
31	}
32	exact := exactTargetToWire(message.ExactTarget)
33	color, err := colorToWire(message.Color)
34	if err != nil {
35		return nil, err
36	}
37	return &wire.Hello{
38		Version:                uint32(message.Version),
39		Intent:                 uint32(message.Intent),
40		ClientId:               append([]byte(nil), message.ClientID[:]...),
41		ResumeToken:            message.ResumeToken,
42		Name:                   message.Name,
43		Cols:                   cols,
44		Rows:                   rows,
45		PixelWidth:             pixelWidth,
46		PixelHeight:            pixelHeight,
47		TermEnv:                message.TermEnv,
48		Cwd:                    message.Cwd,
49		Color:                  color,
50		MaxOutputInFlight:      uint32(message.MaxOutputInFlight),
51		Env:                    append([]string(nil), message.Env...),
52		SessionTarget:          sessionTarget,
53		EnvironmentPolicy:      uint32(message.EnvironmentPolicy),
54		ExactTarget:            exact,
55		PreferredTabId:         string(message.PreferredTabID),
56		NavigationCapabilities: uint32(message.NavigationCapabilities),
57		Remote:                 message.Remote,
58		KittyDirectGraphics:    message.KittyDirectGraphics,
59		KittyKeyboard:          message.KittyKeyboard,
60		TerminalFocus:          uint32(message.TerminalFocus),
61		ClientPid:              message.ClientPID,
62	}, nil
63}
64
65// colorToWire maps a color capability to its wire message. Unknown semantic
66// values are never encoded.
67func colorToWire(color terminalcap.ColorCapabilities) (*wire.ColorCapabilities, error) {
68	if !color.Valid() {
69		return nil, errProtoConvertRange
70	}
71	var mode wire.ColorMode
72	switch color.Mode {
73	case terminalcap.TrueColor:
74		mode = wire.ColorMode_COLOR_MODE_TRUE_COLOR
75	case terminalcap.ANSI256:
76		mode = wire.ColorMode_COLOR_MODE_ANSI256
77	case terminalcap.ANSI16:
78		mode = wire.ColorMode_COLOR_MODE_ANSI16
79	case terminalcap.Monochrome:
80		mode = wire.ColorMode_COLOR_MODE_MONOCHROME
81	default:
82		return nil, errProtoConvertRange
83	}
84	var source wire.ColorSource
85	switch color.Source {
86	case terminalcap.SourceUnknown:
87		source = wire.ColorSource_COLOR_SOURCE_UNSPECIFIED
88	case terminalcap.SourceHeuristic:
89		source = wire.ColorSource_COLOR_SOURCE_HEURISTIC
90	case terminalcap.SourceDeclared:
91		source = wire.ColorSource_COLOR_SOURCE_DECLARED
92	case terminalcap.SourceForced:
93		source = wire.ColorSource_COLOR_SOURCE_FORCED
94	default:
95		return nil, errProtoConvertRange
96	}
97	return &wire.ColorCapabilities{Mode: mode, Source: source}, nil
98}
99
100// colorFromWire is strict: Hello must claim a color capability, so a missing
101// message, an UNSPECIFIED mode, or any value outside the closed enums is a
102// decode error. An UNSPECIFIED source is the explicit "unknown" source.
103func colorFromWire(message *wire.ColorCapabilities) (terminalcap.ColorCapabilities, error) {
104	if message == nil {
105		return terminalcap.ColorCapabilities{}, errProtoConvertRange
106	}
107	var color terminalcap.ColorCapabilities
108	switch message.GetMode() {
109	case wire.ColorMode_COLOR_MODE_TRUE_COLOR:
110		color.Mode = terminalcap.TrueColor
111	case wire.ColorMode_COLOR_MODE_ANSI256:
112		color.Mode = terminalcap.ANSI256
113	case wire.ColorMode_COLOR_MODE_ANSI16:
114		color.Mode = terminalcap.ANSI16
115	case wire.ColorMode_COLOR_MODE_MONOCHROME:
116		color.Mode = terminalcap.Monochrome
117	default:
118		return terminalcap.ColorCapabilities{}, errProtoConvertRange
119	}
120	switch message.GetSource() {
121	case wire.ColorSource_COLOR_SOURCE_UNSPECIFIED:
122		color.Source = terminalcap.SourceUnknown
123	case wire.ColorSource_COLOR_SOURCE_HEURISTIC:
124		color.Source = terminalcap.SourceHeuristic
125	case wire.ColorSource_COLOR_SOURCE_DECLARED:
126		color.Source = terminalcap.SourceDeclared
127	case wire.ColorSource_COLOR_SOURCE_FORCED:
128		color.Source = terminalcap.SourceForced
129	default:
130		return terminalcap.ColorCapabilities{}, errProtoConvertRange
131	}
132	return color, nil
133}
134
135func geometryToWire(size domain.Size, pixelWidth, pixelHeight int) (uint32, uint32, uint32, uint32, error) {
136	for _, value := range []int{size.Cols, size.Rows, pixelWidth, pixelHeight} {
137		if value < 0 || value > math.MaxUint16 {
138			return 0, 0, 0, 0, errProtoConvertRange
139		}
140	}
141	return uint32(size.Cols), uint32(size.Rows), uint32(pixelWidth), uint32(pixelHeight), nil
142}
143
144func helloFromWire(message *wire.Hello) (protocol.Hello, error) {
145	var hello protocol.Hello
146	if message == nil {
147		return hello, errProtoConvertRange
148	}
149	version, err := mustUint16(message.GetVersion())
150	if err != nil {
151		return protocol.Hello{}, err
152	}
153	hello.Version = version
154	intent, err := mustUint8(message.GetIntent())
155	if err != nil {
156		return protocol.Hello{}, err
157	}
158	hello.Intent = intent
159	if len(message.GetClientId()) != len(hello.ClientID) {
160		return protocol.Hello{}, errProtoConvertRange
161	}
162	copy(hello.ClientID[:], message.GetClientId())
163	hello.ResumeToken = message.GetResumeToken()
164	hello.Name = message.GetName()
165	cols, err := mustUint16(message.GetCols())
166	if err != nil {
167		return protocol.Hello{}, err
168	}
169	rows, err := mustUint16(message.GetRows())
170	if err != nil {
171		return protocol.Hello{}, err
172	}
173	pixelWidth, err := mustUint16(message.GetPixelWidth())
174	if err != nil {
175		return protocol.Hello{}, err
176	}
177	pixelHeight, err := mustUint16(message.GetPixelHeight())
178	if err != nil {
179		return protocol.Hello{}, err
180	}
181	hello.Size = domain.Size{Cols: int(cols), Rows: int(rows)}
182	hello.PixelWidth = int(pixelWidth)
183	hello.PixelHeight = int(pixelHeight)
184	hello.TermEnv = message.GetTermEnv()
185	hello.Cwd = message.GetCwd()
186	hello.Color, err = colorFromWire(message.GetColor())
187	if err != nil {
188		return protocol.Hello{}, err
189	}
190	window, err := mustUint8(message.GetMaxOutputInFlight())
191	if err != nil {
192		return protocol.Hello{}, err
193	}
194	hello.MaxOutputInFlight = window
195	hello.Env = append([]string(nil), message.GetEnv()...)
196	sessionTarget, err := sessionAttachTargetFromWire(message.GetSessionTarget())
197	if err != nil {
198		return protocol.Hello{}, err
199	}
200	hello.SessionTarget = sessionTarget
201	hello.EnvironmentPolicy, err = enum8[protocol.EnvironmentPolicy](message.GetEnvironmentPolicy())
202	if err != nil {
203		return protocol.Hello{}, err
204	}
205	exact, err := exactTargetFromWire(message.GetExactTarget())
206	if err != nil {
207		return protocol.Hello{}, err
208	}
209	hello.ExactTarget = exact
210	hello.PreferredTabID = domain.TabStableID(message.GetPreferredTabId())
211	hello.NavigationCapabilities, err = enum8[protocol.NavigationCapabilities](message.GetNavigationCapabilities())
212	if err != nil {
213		return protocol.Hello{}, err
214	}
215	hello.Remote = message.GetRemote()
216	hello.KittyDirectGraphics = message.GetKittyDirectGraphics()
217	hello.KittyKeyboard = message.GetKittyKeyboard()
218	hello.ClientPID = message.GetClientPid()
219	hello.TerminalFocus, err = enum8[domain.TerminalFocus](message.GetTerminalFocus())
220	if err != nil {
221		return protocol.Hello{}, err
222	}
223	if err := protocol.ValidateHello(hello); err != nil {
224		return protocol.Hello{}, err
225	}
226	return hello, nil
227}
228
229func welcomeToWire(message protocol.Welcome) (*wire.Welcome, error) {
230	var identity *wire.CommittedRouteIdentity
231	if message.CommittedIdentity != nil {
232		converted, err := committedIdentityToWire(*message.CommittedIdentity)
233		if err != nil {
234			return nil, err
235		}
236		identity = converted
237	}
238	return &wire.Welcome{
239		SessionId:         message.SessionID,
240		SessionName:       message.SessionName,
241		Ephemeral:         message.Ephemeral,
242		ResumeToken:       message.ResumeToken,
243		Capabilities:      message.Capabilities,
244		CommittedIdentity: identity,
245	}, nil
246}
247
248func welcomeFromWire(message *wire.Welcome) (protocol.Welcome, error) {
249	var welcome protocol.Welcome
250	if message == nil {
251		return welcome, errProtoConvertRange
252	}
253	welcome.SessionID = message.GetSessionId()
254	welcome.SessionName = message.GetSessionName()
255	welcome.Ephemeral = message.GetEphemeral()
256	welcome.ResumeToken = message.GetResumeToken()
257	welcome.Capabilities = message.GetCapabilities()
258	if message.GetCommittedIdentity() != nil {
259		identity, err := committedIdentityFromWire(message.GetCommittedIdentity())
260		if err != nil {
261			return protocol.Welcome{}, err
262		}
263		welcome.CommittedIdentity = &identity
264		if welcome.CommittedIdentity.Target.SessionName != welcome.SessionName || welcome.CommittedIdentity.Ephemeral != welcome.Ephemeral {
265			return protocol.Welcome{}, protocol.ErrInvalidRouteWire
266		}
267	}
268	return welcome, nil
269}
270
271func committedIdentityToWire(identity protocol.CommittedRouteIdentity) (*wire.CommittedRouteIdentity, error) {
272	if err := identity.Validate(); err != nil {
273		return nil, err
274	}
275	return &wire.CommittedRouteIdentity{Target: exactTargetToWire(&identity.Target), Ephemeral: identity.Ephemeral}, nil
276}
277
278func committedIdentityFromWire(message *wire.CommittedRouteIdentity) (protocol.CommittedRouteIdentity, error) {
279	var identity protocol.CommittedRouteIdentity
280	if message == nil {
281		return identity, protocol.ErrInvalidRouteWire
282	}
283	target, err := exactTargetFromWire(message.GetTarget())
284	if err != nil || target == nil {
285		return protocol.CommittedRouteIdentity{}, protocol.ErrInvalidRouteWire
286	}
287	identity.Target = *target
288	identity.Ephemeral = message.GetEphemeral()
289	if err := identity.Validate(); err != nil {
290		return protocol.CommittedRouteIdentity{}, err
291	}
292	return identity, nil
293}
294
295func sessionAttachTargetToWire(target *protocol.SessionAttachTarget) (*wire.SessionAttachTarget, error) {
296	if target == nil {
297		return nil, nil
298	}
299	if err := target.Validate(); err != nil {
300		return nil, err
301	}
302	message := &wire.SessionAttachTarget{SessionId: string(target.SessionID), LifecycleId: lifecycleToWire(target.LifecycleID), SessionName: target.SessionName, TabId: string(target.TabID), TabRawName: target.TabRawName, TabExpectedCount: uint32(target.TabExpectedCount), Stopped: target.Stopped}
303	if target.TabIndex != protocol.NoTabIndex {
304		message.TabIndex = proto.Int32(target.TabIndex)
305	}
306	return message, nil
307}
308
309func sessionAttachTargetFromWire(message *wire.SessionAttachTarget) (*protocol.SessionAttachTarget, error) {
310	if message == nil {
311		return nil, nil
312	}
313	if message.GetTabExpectedCount() > math.MaxUint16 {
314		return nil, protocol.ErrInvalidAttachTarget
315	}
316	lifecycle, err := lifecycleFromWire(message.GetLifecycleId())
317	if err != nil {
318		return nil, err
319	}
320	tabIndex := protocol.NoTabIndex
321	if message.TabIndex != nil {
322		tabIndex = message.GetTabIndex()
323	}
324	target := &protocol.SessionAttachTarget{SessionID: domain.SessionID(message.GetSessionId()), LifecycleID: lifecycle, SessionName: message.GetSessionName(), TabID: domain.TabStableID(message.GetTabId()), TabIndex: tabIndex, TabRawName: message.GetTabRawName(), TabExpectedCount: uint16(message.GetTabExpectedCount()), Stopped: message.GetStopped()}
325	if err := target.Validate(); err != nil {
326		return nil, err
327	}
328	return target, nil
329}
330
331func attachTargetToWire(message protocol.AttachTarget) (*wire.AttachTarget, error) {
332	if err := protocol.ValidateAttachTarget(message); err != nil {
333		return nil, err
334	}
335	sessionTarget, err := sessionAttachTargetToWire(message.SessionTarget)
336	if err != nil {
337		return nil, err
338	}
339	return &wire.AttachTarget{
340		RequestId:         message.RequestID,
341		Endpoint:          message.Endpoint,
342		Session:           message.Session,
343		Intent:            uint32(message.Intent),
344		SessionTarget:     sessionTarget,
345		EnvironmentPolicy: uint32(message.EnvironmentPolicy),
346		ExactTarget:       exactTargetToWire(message.ExactTarget),
347		SamePeer:          message.SamePeer,
348		PreferredTabId:    string(message.PreferredTabID),
349		CauseActionId:     message.CauseActionID,
350	}, nil
351}
352
353func attachTargetFromWire(message *wire.AttachTarget) (protocol.AttachTarget, error) {
354	var target protocol.AttachTarget
355	if message == nil {
356		return target, protocol.ErrInvalidAttachTarget
357	}
358	target.RequestID = message.GetRequestId()
359	target.Endpoint = message.GetEndpoint()
360	target.Session = message.GetSession()
361	intent, err := mustUint8(message.GetIntent())
362	if err != nil {
363		return protocol.AttachTarget{}, protocol.ErrInvalidAttachTarget
364	}
365	target.Intent = intent
366	sessionTarget, err := sessionAttachTargetFromWire(message.GetSessionTarget())
367	if err != nil {
368		return protocol.AttachTarget{}, protocol.ErrInvalidAttachTarget
369	}
370	target.SessionTarget = sessionTarget
371	target.EnvironmentPolicy, err = enum8[protocol.EnvironmentPolicy](message.GetEnvironmentPolicy())
372	if err != nil {
373		return protocol.AttachTarget{}, protocol.ErrInvalidAttachTarget
374	}
375	exact, err := exactTargetFromWire(message.GetExactTarget())
376	if err != nil {
377		return protocol.AttachTarget{}, protocol.ErrInvalidAttachTarget
378	}
379	target.ExactTarget = exact
380	target.SamePeer = message.GetSamePeer()
381	target.PreferredTabID = domain.TabStableID(message.GetPreferredTabId())
382	target.CauseActionID = message.GetCauseActionId()
383	if err := protocol.ValidateAttachTarget(target); err != nil {
384		return protocol.AttachTarget{}, err
385	}
386	return target, nil
387}
388
389func outputToWire(message protocol.Output) (*wire.Output, error) {
390	if err := protocol.ValidateOutput(message); err != nil {
391		return nil, err
392	}
393	cols, rows, _, _, err := geometryToWire(message.Size, 0, 0)
394	if err != nil {
395		return nil, err
396	}
397	var context *wire.ViewContext
398	if message.Context != nil {
399		converted, err := viewContextToWire(*message.Context)
400		if err != nil {
401			return nil, err
402		}
403		context = converted
404	}
405	encoding, data, err := compressProtoOutput(message)
406	if err != nil {
407		return nil, err
408	}
409	return &wire.Output{
410		Epoch:              message.Epoch,
411		Base:               message.Base,
412		NewState:           message.New,
413		Echo:               message.Echo,
414		ViewRevision:       message.ViewRevision,
415		Cols:               cols,
416		Rows:               rows,
417		Full:               message.Full,
418		Context:            context,
419		Encoding:           encoding,
420		UncompressedLength: uint64(len(message.Data)),
421		Data:               data,
422	}, nil
423}
424
425var protoOutputCompressorPool = sync.Pool{New: func() any {
426	writer, err := zlib.NewWriterLevel(io.Discard, zlib.BestSpeed)
427	if err != nil {
428		panic(err)
429	}
430	return writer
431}}
432
433// compressProtoOutput keeps the frozen policy: only full snapshots at or
434// above the threshold are zlib candidates, retained only when smaller.
435// Callers (use cases) always see uncompressed terminal data.
436func compressProtoOutput(message protocol.Output) (uint32, []byte, error) {
437	if !message.Full || len(message.Data) < protoOutputCompressionThreshold {
438		return 0, append([]byte(nil), message.Data...), nil
439	}
440	var compressed bytes.Buffer
441	writer := protoOutputCompressorPool.Get().(*zlib.Writer)
442	writer.Reset(&compressed)
443	defer func() {
444		writer.Reset(io.Discard)
445		protoOutputCompressorPool.Put(writer)
446	}()
447	if _, err := writer.Write(message.Data); err != nil {
448		return 0, nil, err
449	}
450	if err := writer.Close(); err != nil {
451		return 0, nil, err
452	}
453	if compressed.Len()+5 >= len(message.Data) {
454		return 0, append([]byte(nil), message.Data...), nil
455	}
456	return 1, compressed.Bytes(), nil
457}
458
459func outputFromWire(message *wire.Output) (protocol.Output, error) {
460	var output protocol.Output
461	if message == nil {
462		return output, protocol.ErrInvalidOutput
463	}
464	output.Epoch = message.GetEpoch()
465	output.Base = message.GetBase()
466	output.New = message.GetNewState()
467	output.Echo = message.GetEcho()
468	output.ViewRevision = message.GetViewRevision()
469	cols, err := mustUint16(message.GetCols())
470	if err != nil {
471		return protocol.Output{}, protocol.ErrInvalidOutput
472	}
473	rows, err := mustUint16(message.GetRows())
474	if err != nil {
475		return protocol.Output{}, protocol.ErrInvalidOutput
476	}
477	output.Size = domain.Size{Cols: int(cols), Rows: int(rows)}
478	output.Full = message.GetFull()
479	if message.GetContext() != nil {
480		context, err := viewContextFromWire(message.GetContext())
481		if err != nil {
482			return protocol.Output{}, protocol.ErrInvalidOutput
483		}
484		output.Context = &context
485	}
486	if err := protocol.ValidateOutput(output); err != nil {
487		return protocol.Output{}, err
488	}
489	decodedLen := message.GetUncompressedLength()
490	if decodedLen > uint64(protocol.MaxOutputDataLen) {
491		return protocol.Output{}, protocol.ErrInvalidOutput
492	}
493	switch message.GetEncoding() {
494	case 0:
495		if uint64(len(message.GetData())) != decodedLen {
496			return protocol.Output{}, protocol.ErrInvalidOutput
497		}
498		output.Data = append([]byte(nil), message.GetData()...)
499	case 1:
500		if !output.Full {
501			return protocol.Output{}, protocol.ErrInvalidOutput
502		}
503		decoded, err := decompressProtoOutput(message.GetData(), int(decodedLen))
504		if err != nil {
505			return protocol.Output{}, protocol.ErrInvalidOutput
506		}
507		output.Data = decoded
508	default:
509		return protocol.Output{}, protocol.ErrInvalidOutput
510	}
511	if err := protocol.ValidateOutput(output); err != nil {
512		return protocol.Output{}, err
513	}
514	return output, nil
515}
516
517func decompressProtoOutput(data []byte, decodedLen int) ([]byte, error) {
518	source := bytes.NewReader(data)
519	reader, err := zlib.NewReader(source)
520	if err != nil {
521		return nil, err
522	}
523	decoded := make([]byte, decodedLen)
524	if _, err := io.ReadFull(reader, decoded); err != nil {
525		_ = reader.Close()
526		return nil, err
527	}
528	var extra [1]byte
529	if n, err := reader.Read(extra[:]); n != 0 || !errors.Is(err, io.EOF) {
530		_ = reader.Close()
531		if err == nil || errors.Is(err, io.EOF) {
532			return nil, errors.New("sessionwire: compressed output exceeds declared length")
533		}
534		return nil, err
535	}
536	if err := reader.Close(); err != nil {
537		return nil, err
538	}
539	// The zlib reader stops at the stream end without consuming
540	// appended bytes, so trailing garbage must be rejected explicitly.
541	if source.Len() != 0 {
542		return nil, errors.New("sessionwire: compressed output has trailing bytes")
543	}
544	return decoded, nil
545}
546
547func viewContextToWire(context protocol.ViewContext) (*wire.ViewContext, error) {
548	if err := context.Validate(); err != nil {
549		return nil, err
550	}
551	route, err := committedIdentityToWire(context.Route)
552	if err != nil {
553		return nil, err
554	}
555	return &wire.ViewContext{
556		Publication:   context.Publication,
557		Route:         route,
558		TabId:         string(context.TabID),
559		FocusedPaneId: string(context.FocusedPaneID),
560	}, nil
561}
562
563func viewContextFromWire(message *wire.ViewContext) (protocol.ViewContext, error) {
564	var context protocol.ViewContext
565	if message == nil {
566		return context, protocol.ErrInvalidOutput
567	}
568	context.Publication = message.GetPublication()
569	route, err := committedIdentityFromWire(message.GetRoute())
570	if err != nil {
571		return protocol.ViewContext{}, err
572	}
573	context.Route = route
574	context.TabID = domain.TabStableID(message.GetTabId())
575	context.FocusedPaneID = domain.PaneStableID(message.GetFocusedPaneId())
576	if err := context.Validate(); err != nil {
577		return protocol.ViewContext{}, err
578	}
579	return context, nil
580}
581
582func commandRequestToWire(message protocol.CommandRequest) (*wire.CommandRequest, error) {
583	if len(message.Args) > math.MaxUint16 {
584		return nil, errProtoConvertRange
585	}
586	return &wire.CommandRequest{
587		Version:       uint32(message.Version),
588		RequestId:     message.RequestID,
589		Attached:      message.Attached,
590		Self:          message.Self,
591		Slug:          message.Slug,
592		Args:          append([]string(nil), message.Args...),
593		TargetSession: message.TargetSession,
594		TargetTab:     message.TargetTab,
595		TargetPane:    message.TargetPane,
596		Json:          message.JSON,
597	}, nil
598}
599
600func commandRequestFromWire(message *wire.CommandRequest) (protocol.CommandRequest, error) {
601	var request protocol.CommandRequest
602	if message == nil {
603		return request, errProtoConvertRange
604	}
605	version, err := mustUint16(message.GetVersion())
606	if err != nil {
607		return protocol.CommandRequest{}, err
608	}
609	request.Version = version
610	request.RequestID = message.GetRequestId()
611	request.Attached = message.GetAttached()
612	request.Self = message.GetSelf()
613	request.Slug = message.GetSlug()
614	request.Args = append([]string(nil), message.GetArgs()...)
615	request.TargetSession = message.GetTargetSession()
616	request.TargetTab = message.GetTargetTab()
617	request.TargetPane = message.GetTargetPane()
618	request.JSON = message.GetJson()
619	return request, nil
620}

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 12

Tags 11

↑↓ moveenter openesc close