v0.27.2 5960dd8

session.go RawHistoryFolder

1package bootstrap
2
3import (
4	"context"
5	"errors"
6	"fmt"
7	"os"
8	"path/filepath"
9	"strings"
10	"sync"
11	"syscall"
12	"time"
13
14	"github.com/bnema/dumber/internal/application/port"
15	"github.com/bnema/dumber/internal/application/usecase"
16	"github.com/bnema/dumber/internal/domain/entity"
17	"github.com/bnema/dumber/internal/domain/repository"
18	"github.com/bnema/dumber/internal/infrastructure/config"
19	infralogging "github.com/bnema/dumber/internal/infrastructure/logging"
20	corelogging "github.com/bnema/dumber/internal/logging"
21	"github.com/rs/zerolog"
22)
23
24const (
25	recentSessionsLimit = 200
26	lockDirPerm         = 0o755
27	lockFilePerm        = 0o600
28	markerFilePerm      = 0o644
29)
30
31type BrowserSession struct {
32	Session    *entity.Session
33	Logger     zerolog.Logger
34	LogCleanup func()
35
36	reportsMu                 sync.RWMutex
37	unexpectedCloseReportPath []string
38
39	Persist func(context.Context) error
40	End     func(context.Context) error
41}
42
43func (s *BrowserSession) SetUnexpectedCloseReports(paths []string) {
44	if s == nil {
45		return
46	}
47	s.reportsMu.Lock()
48	defer s.reportsMu.Unlock()
49	s.unexpectedCloseReportPath = append([]string(nil), paths...)
50}
51
52func (s *BrowserSession) UnexpectedCloseReports() []string {
53	if s == nil {
54		return nil
55	}
56	s.reportsMu.RLock()
57	defer s.reportsMu.RUnlock()
58	return append([]string(nil), s.unexpectedCloseReportPath...)
59}
60
61func StartBrowserSession(
62	ctx context.Context,
63	cfg *config.Config,
64	sessionRepo repository.SessionRepository,
65	deferPersist bool,
66) (*BrowserSession, context.Context, error) {
67	log := corelogging.FromContext(ctx)
68	if sessionRepo == nil {
69		return nil, ctx, errors.New("session repository is nil")
70	}
71
72	lockDir := cfg.Logging.LogDir
73	if lockDir == "" {
74		if dir, err := config.GetLogDir(); err == nil {
75			lockDir = dir
76		}
77	}
78
79	sessionLoggerAdapter := infralogging.NewSessionLoggerAdapter()
80	sessionUC := usecase.NewManageSessionUseCase(sessionRepo, sessionLoggerAdapter)
81	cleanupUC := usecase.NewCleanupSessionsUseCase(sessionRepo)
82
83	now := time.Now()
84	session := &entity.Session{
85		ID:        entity.SessionID(corelogging.GenerateSessionID()),
86		Type:      entity.SessionTypeBrowser,
87		StartedAt: now.UTC(),
88	}
89	if err := session.Validate(); err != nil {
90		return nil, ctx, err
91	}
92
93	logger, cleanup, err := sessionLoggerAdapter.CreateLogger(ctx, session.ID, port.SessionLogConfig{
94		Level:         cfg.Logging.Level,
95		Format:        cfg.Logging.Format,
96		TimeFormat:    corelogging.ConsoleTimeFormat,
97		LogDir:        cfg.Logging.LogDir,
98		WriteToStderr: true,
99		EnableFileLog: cfg.Logging.EnableFileLog,
100	})
101	if err != nil {
102		log.Warn().Err(err).Msg("failed to create session logger")
103	}
104
105	sessionCtx := corelogging.WithContext(context.Background(), logger)
106
107	var (
108		lockMu     sync.Mutex
109		lockFile   *os.File
110		lockPath   string
111		persistMu  sync.Once
112		persistErr error
113	)
114
115	browserSession := &BrowserSession{
116		Session:    session,
117		Logger:     logger,
118		LogCleanup: cleanup,
119	}
120
121	// persistFn ensures the session is saved exactly once. Using sync.Once is intentional:
122	// if the first persist fails, we cache the error and return it on subsequent calls
123	// rather than retrying. This prevents duplicate session records and ensures consistent
124	// error handling during shutdown sequences where persistFn may be called multiple times.
125	persistFn := func(persistCtx context.Context) error {
126		persistMu.Do(func() {
127			if lockDir != "" {
128				generatedReports, abruptErr := detectAbruptSessionsAndBuildReports(lockDir, &logger)
129				if abruptErr != nil {
130					logger.Warn().Err(abruptErr).Msg("failed to check abrupt exit markers")
131				} else if len(generatedReports) > 0 {
132					browserSession.SetUnexpectedCloseReports(generatedReports)
133				}
134			}
135
136			saveCtx := corelogging.WithContext(context.Background(), logger)
137			if err := sessionRepo.Save(saveCtx, session); err != nil {
138				persistErr = fmt.Errorf("save session: %w", err)
139				return
140			}
141
142			logger.Info().
143				Str("session_id", string(session.ID)).
144				Str("type", string(session.Type)).
145				Msg("session started")
146			if lockDir != "" {
147				if err := writeStartupMarker(lockDir, string(session.ID), now.UTC()); err != nil {
148					logger.Warn().Err(err).Str("session_id", string(session.ID)).Msg("failed to write startup marker")
149				}
150			}
151
152			lf, lp, lockErr := lockSessionFile(lockDir, session.ID)
153			if lockErr != nil {
154				// Don’t fail startup if locking fails; it only affects stale-session cleanup.
155				logger.Warn().Err(lockErr).Msg("failed to acquire session lock")
156			} else {
157				lockMu.Lock()
158				lockFile = lf
159				lockPath = lp
160				lockMu.Unlock()
161			}
162
163			// Defer stale session cleanup and old session pruning to background.
164			// This avoids blocking startup for non-critical housekeeping tasks.
165			runSessionCleanupAsync(persistCtx, sessionUC, cleanupUC, cfg, lockDir, log)
166		})
167		return persistErr
168	}
169
170	endFn := func(endCtx context.Context) error {
171		_ = persistFn(endCtx)
172		endErr := sessionUC.EndSession(endCtx, session.ID, time.Now())
173
174		lockMu.Lock()
175		lf := lockFile
176		lp := lockPath
177		lockMu.Unlock()
178
179		if lockDir != "" {
180			shutdownAt := time.Now().UTC()
181			if markerErr := writeShutdownMarker(lockDir, string(session.ID), shutdownAt); markerErr != nil {
182				logger.Warn().Err(markerErr).Str("session_id", string(session.ID)).Msg("failed to write clean shutdown marker")
183			}
184		}
185
186		if lf != nil {
187			_ = unlockAndClose(lf)
188			_ = os.Remove(lp)
189		}
190		return endErr
191	}
192
193	browserSession.End = endFn
194
195	if deferPersist {
196		browserSession.Persist = persistFn
197	} else {
198		if err := persistFn(ctx); err != nil {
199			if cleanup != nil {
200				cleanup()
201			}
202			return nil, ctx, err
203		}
204	}
205
206	return browserSession, sessionCtx, nil
207}
208
209func detectAbruptSessionsAndBuildReports(lockDir string, logger *zerolog.Logger) ([]string, error) {
210	abruptSessions, abruptErr := markAbruptExits(lockDir, time.Now().UTC(), logger)
211	if abruptErr != nil {
212		return nil, abruptErr
213	}
214
215	generatedReports := make([]string, 0, len(abruptSessions))
216	for _, abruptSessionID := range abruptSessions {
217		if logger != nil {
218			logger.Warn().
219				Str("session_id", abruptSessionID).
220				Msg("abrupt-exit marker detected (startup without shutdown)")
221		}
222		reportPath, reportErr := writeUnexpectedCloseReport(lockDir, abruptSessionID)
223		if reportErr != nil {
224			if logger != nil {
225				logger.Warn().
226					Err(reportErr).
227					Str("session_id", abruptSessionID).
228					Msg("failed to write unexpected-close report")
229			}
230			continue
231		}
232		if reportPath == "" {
233			continue
234		}
235		generatedReports = append(generatedReports, reportPath)
236		if logger != nil {
237			logger.Warn().
238				Str("session_id", abruptSessionID).
239				Str("report_path", reportPath).
240				Msg("unexpected-close report generated")
241		}
242	}
243
244	return generatedReports, nil
245}
246
247func endStaleActiveBrowserSessions(
248	ctx context.Context, sessionUC *usecase.ManageSessionUseCase, lockDir string, log *zerolog.Logger,
249) error {
250	// We only have GetRecentSessions; fetch enough to cover likely actives.
251	recent, err := sessionUC.GetRecentSessions(ctx, recentSessionsLimit)
252	if err != nil {
253		return err
254	}
255
256	now := time.Now()
257	for _, s := range recent {
258		if s == nil || s.Type != entity.SessionTypeBrowser || !s.IsActive() {
259			continue
260		}
261
262		lockPath := sessionLockPath(lockDir, s.ID)
263		if _, statErr := os.Stat(lockPath); statErr != nil {
264			// No lock file: conservative behavior, don’t auto-end.
265			continue
266		}
267
268		f, err := os.OpenFile(lockPath, os.O_RDWR, lockFilePerm)
269		if err != nil {
270			continue
271		}
272
273		locked, lockErr := tryLockExclusiveNonBlocking(f)
274		if lockErr != nil {
275			_ = f.Close()
276			continue
277		}
278		if !locked {
279			// Another process is holding the lock => session still alive.
280			_ = f.Close()
281			continue
282		}
283
284		// We acquired the lock => no running process holds it => stale session.
285		if endErr := sessionUC.EndSession(ctx, s.ID, now); endErr != nil {
286			if log != nil {
287				log.Warn().Err(endErr).Str("session_id", string(s.ID)).Msg("failed to end stale session")
288			}
289		}
290		_ = unlockAndClose(f)
291		_ = os.Remove(lockPath)
292	}
293
294	return nil
295}
296
297func sessionLockPath(lockDir string, sessionID entity.SessionID) string {
298	return filepath.Join(lockDir, fmt.Sprintf("session_%s.lock", sessionID))
299}
300
301func lockSessionFile(lockDir string, sessionID entity.SessionID) (*os.File, string, error) {
302	if lockDir == "" {
303		return nil, "", errors.New("lock dir is empty")
304	}
305	if err := os.MkdirAll(lockDir, lockDirPerm); err != nil {
306		return nil, "", err
307	}
308
309	lockPath := sessionLockPath(lockDir, sessionID)
310	f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, lockFilePerm)
311	if err != nil {
312		return nil, "", err
313	}
314
315	locked, lockErr := tryLockExclusiveNonBlocking(f)
316	if lockErr != nil {
317		_ = f.Close()
318		return nil, "", lockErr
319	}
320	if !locked {
321		_ = f.Close()
322		return nil, "", errors.New("session lock already held")
323	}
324	return f, lockPath, nil
325}
326
327func tryLockExclusiveNonBlocking(f *os.File) (bool, error) {
328	if f == nil {
329		return false, errors.New("nil file")
330	}
331	err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
332	if err == nil {
333		return true, nil
334	}
335	if errors.Is(err, syscall.EWOULDBLOCK) {
336		return false, nil
337	}
338	return false, err
339}
340
341func unlockAndClose(f *os.File) error {
342	if f == nil {
343		return nil
344	}
345	_ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
346	return f.Close()
347}
348
349func startupMarkerPath(lockDir, sessionID string) string {
350	return filepath.Join(lockDir, fmt.Sprintf("session_%s.startup.marker", sessionID))
351}
352
353func shutdownMarkerPath(lockDir, sessionID string) string {
354	return filepath.Join(lockDir, fmt.Sprintf("session_%s.shutdown.marker", sessionID))
355}
356
357func abruptMarkerPath(lockDir, sessionID string) string {
358	return filepath.Join(lockDir, fmt.Sprintf("session_%s.abrupt.marker", sessionID))
359}
360
361func writeStartupMarker(lockDir, sessionID string, startedAt time.Time) error {
362	if lockDir == "" || sessionID == "" {
363		return errors.New("startup marker requires lockDir and sessionID")
364	}
365	if err := os.MkdirAll(lockDir, lockDirPerm); err != nil {
366		return err
367	}
368	content := []byte(fmt.Sprintf(
369		"%s\npid=%d\nppid=%d\n",
370		startedAt.Format(time.RFC3339Nano),
371		os.Getpid(),
372		os.Getppid(),
373	))
374	if err := os.WriteFile(startupMarkerPath(lockDir, sessionID), content, markerFilePerm); err != nil {
375		return err
376	}
377	_ = os.Remove(shutdownMarkerPath(lockDir, sessionID))
378	_ = os.Remove(abruptMarkerPath(lockDir, sessionID))
379	return nil
380}
381
382func writeShutdownMarker(lockDir, sessionID string, endedAt time.Time) error {
383	if lockDir == "" || sessionID == "" {
384		return errors.New("shutdown marker requires lockDir and sessionID")
385	}
386	if err := os.MkdirAll(lockDir, lockDirPerm); err != nil {
387		return err
388	}
389
390	// Preserve the startup time inside the shutdown marker so
391	// ClassifySessionExitFromMarkers can still read it after the
392	// startup marker file is removed.
393	var startupLine string
394	var pidLine string
395	var ppidLine string
396	startupPath := startupMarkerPath(lockDir, sessionID)
397	if raw, readErr := os.ReadFile(startupPath); readErr == nil {
398		if t := firstNonEmptyLine(raw); t != "" {
399			startupLine = "started_at=" + t + "\n"
400		}
401		if pid := markerValue(raw, "pid="); pid != "" {
402			pidLine = "pid=" + pid + "\n"
403		}
404		if ppid := markerValue(raw, "ppid="); ppid != "" {
405			ppidLine = "ppid=" + ppid + "\n"
406		}
407	}
408
409	content := []byte(endedAt.Format(time.RFC3339Nano) + "\n" + startupLine + pidLine + ppidLine)
410	if err := os.WriteFile(shutdownMarkerPath(lockDir, sessionID), content, markerFilePerm); err != nil {
411		return err
412	}
413	// Clean up the matching startup marker now that shutdown is recorded.
414	// Errors are non-fatal: the marker will be swept later if removal fails.
415	_ = os.Remove(startupPath)
416	return nil
417}
418
419func markAbruptExits(lockDir string, detectedAt time.Time, logger *zerolog.Logger) ([]string, error) {
420	if lockDir == "" {
421		return nil, nil
422	}
423	pattern := filepath.Join(lockDir, "session_*.startup.marker")
424	startupMarkers, err := filepath.Glob(pattern)
425	if err != nil {
426		return nil, err
427	}
428
429	abruptSessions := make([]string, 0, len(startupMarkers))
430	for _, startupPath := range startupMarkers {
431		sessionID := sessionIDFromStartupMarker(startupPath)
432		if sessionID == "" {
433			continue
434		}
435
436		shouldMark, checkErr := shouldMarkAbruptExit(lockDir, sessionID, logger)
437		if checkErr != nil {
438			if logger != nil {
439				logger.Warn().Err(checkErr).Str("session_id", sessionID).Msg("markAbruptExits: marker checks failed")
440			}
441			continue
442		}
443		if !shouldMark {
444			continue
445		}
446
447		startupRaw, _ := os.ReadFile(startupPath)
448		payload := fmt.Sprintf("detected_at=%s\nstartup_marker=%s\n", detectedAt.Format(time.RFC3339Nano), startupPath)
449		if startupAt := firstNonEmptyLine(startupRaw); startupAt != "" {
450			payload += "started_at=" + startupAt + "\n"
451		}
452		if pid := markerValue(startupRaw, "pid="); pid != "" {
453			payload += "pid=" + pid + "\n"
454		}
455		if ppid := markerValue(startupRaw, "ppid="); ppid != "" {
456			payload += "ppid=" + ppid + "\n"
457		}
458
459		if err := os.WriteFile(abruptMarkerPath(lockDir, sessionID), []byte(payload), markerFilePerm); err != nil {
460			return abruptSessions, err
461		}
462		abruptSessions = append(abruptSessions, sessionID)
463	}
464
465	return abruptSessions, nil
466}
467
468func sessionIDFromStartupMarker(startupPath string) string {
469	base := filepath.Base(startupPath)
470	if !strings.HasPrefix(base, "session_") || !strings.HasSuffix(base, ".startup.marker") {
471		return ""
472	}
473	return strings.TrimSuffix(strings.TrimPrefix(base, "session_"), ".startup.marker")
474}
475
476func shouldMarkAbruptExit(lockDir, sessionID string, logger *zerolog.Logger) (bool, error) {
477	active, probeErr := sessionLockHeldByLiveProcess(lockDir, sessionID, logger)
478	if probeErr != nil {
479		return false, probeErr
480	}
481	if active {
482		return false, nil
483	}
484
485	shutdownPath := shutdownMarkerPath(lockDir, sessionID)
486	if exists, statErr := markerExists(shutdownPath); statErr != nil {
487		return false, statErr
488	} else if exists {
489		return false, nil
490	}
491
492	abruptPath := abruptMarkerPath(lockDir, sessionID)
493	if exists, statErr := markerExists(abruptPath); statErr != nil {
494		return false, statErr
495	} else if exists {
496		return false, nil
497	}
498
499	return true, nil
500}
501
502func sessionLockHeldByLiveProcess(lockDir, sessionID string, logger *zerolog.Logger) (bool, error) {
503	lockPath := sessionLockPath(lockDir, entity.SessionID(sessionID))
504	f, openErr := os.OpenFile(lockPath, os.O_RDWR, lockFilePerm)
505	if openErr != nil {
506		if os.IsNotExist(openErr) {
507			return false, nil
508		}
509		if logger != nil {
510			logger.Warn().
511				Err(openErr).
512				Str("session_id", sessionID).
513				Str("path", lockPath).
514				Msg("markAbruptExits: open lock file failed")
515		}
516		return false, openErr
517	}
518
519	locked, lockErr := tryLockExclusiveNonBlocking(f)
520	if lockErr != nil {
521		_ = f.Close()
522		if logger != nil {
523			logger.Warn().
524				Err(lockErr).
525				Str("session_id", sessionID).
526				Str("path", lockPath).
527				Msg("markAbruptExits: lock probe failed")
528		}
529		return false, lockErr
530	}
531	if !locked {
532		_ = f.Close()
533		return true, nil
534	}
535	_ = unlockAndClose(f)
536	return false, nil
537}
538
539func markerExists(path string) (bool, error) {
540	_, err := os.Stat(path)
541	if err == nil {
542		return true, nil
543	}
544	if os.IsNotExist(err) {
545		return false, nil
546	}
547	return false, err
548}
549
550func firstNonEmptyLine(raw []byte) string {
551	trimmed := strings.TrimSpace(string(raw))
552	if trimmed == "" {
553		return ""
554	}
555	first, _, _ := strings.Cut(trimmed, "\n")
556	return strings.TrimSpace(first)
557}
558
559func markerValue(raw []byte, key string) string {
560	if key == "" || len(raw) == 0 {
561		return ""
562	}
563	for _, line := range strings.Split(string(raw), "\n") {
564		line = strings.TrimSpace(line)
565		if strings.HasPrefix(line, key) {
566			return strings.TrimSpace(strings.TrimPrefix(line, key))
567		}
568	}
569	return ""
570}
571
572// runSessionCleanupAsync performs stale session cleanup and old session pruning
573// in a background goroutine. This avoids blocking startup for non-critical tasks.
574// Uses context.Background() to ensure cleanup completes even if startup context is canceled.
575func runSessionCleanupAsync(
576	startupCtx context.Context,
577	sessionUC *usecase.ManageSessionUseCase,
578	cleanupUC *usecase.CleanupSessionsUseCase,
579	cfg *config.Config,
580	lockDir string,
581	log *zerolog.Logger,
582) {
583	// Silence unused parameter warning - startupCtx is intentionally unused.
584	_ = startupCtx
585
586	go func() {
587		// Use a detached background context instead of the startup context:
588		// session cleanup is critical and must run to completion even if the
589		// startup context is canceled or times out.
590		bgCtx := context.Background()
591
592		// End stale active sessions (orphaned from crashed processes)
593		if lockDir != "" {
594			if err := endStaleActiveBrowserSessions(bgCtx, sessionUC, lockDir, log); err != nil {
595				if log != nil {
596					log.Warn().Err(err).Msg("background: failed to mark stale sessions ended")
597				}
598			}
599		}
600
601		// Clean up old exited sessions based on config limits
602		cleanupOutput, err := cleanupUC.Execute(bgCtx, usecase.CleanupSessionsInput{
603			MaxExitedSessions:       cfg.Session.MaxExitedSessions,
604			MaxExitedSessionAgeDays: cfg.Session.MaxExitedSessionAgeDays,
605		})
606		if err != nil {
607			if log != nil {
608				log.Warn().Err(err).Msg("background: failed to cleanup old sessions")
609			}
610		} else if cleanupOutput.TotalDeleted > 0 {
611			if log != nil {
612				log.Info().Int64("deleted", cleanupOutput.TotalDeleted).Msg("background: cleaned up old sessions")
613			}
614		}
615
616		// Sweep paired startup+shutdown markers that are no longer needed.
617		if lockDir != "" {
618			sweepPairedMarkers(lockDir, cfg.Session.MaxExitedSessionAgeDays, log)
619		}
620	}()
621}
622
623// sweepPairedMarkers removes marker files for sessions that have both a
624// shutdown marker and a startup marker older than maxAgeDays. Both markers
625// must exist and be aged past the cutoff before any files are removed.
626// The associated abrupt marker (if present) is also cleaned up.
627// Removal errors are non-fatal (logged and skipped).
628func sweepPairedMarkers(lockDir string, maxAgeDays int, log *zerolog.Logger) {
629	if lockDir == "" {
630		return
631	}
632	if maxAgeDays <= 0 {
633		maxAgeDays = 7
634	}
635	cutoff := time.Now().Add(-time.Duration(maxAgeDays) * 24 * time.Hour)
636
637	shutdownMarkers, err := filepath.Glob(filepath.Join(lockDir, "session_*.shutdown.marker"))
638	if err != nil {
639		if log != nil {
640			log.Warn().Err(err).Msg("sweepPairedMarkers: glob failed")
641		}
642		return
643	}
644
645	swept := 0
646	for _, shutdownPath := range shutdownMarkers {
647		base := filepath.Base(shutdownPath)
648		if !strings.HasPrefix(base, "session_") || !strings.HasSuffix(base, ".shutdown.marker") {
649			continue
650		}
651		sessionID := strings.TrimSuffix(strings.TrimPrefix(base, "session_"), ".shutdown.marker")
652		if sessionID == "" {
653			continue
654		}
655
656		shutdownInfo, err := os.Stat(shutdownPath)
657		if err != nil || shutdownInfo.ModTime().After(cutoff) {
658			continue
659		}
660
661		startupPath := startupMarkerPath(lockDir, sessionID)
662		startupInfo, err := os.Stat(startupPath)
663		if err != nil || startupInfo.ModTime().After(cutoff) {
664			// Startup marker missing or too recent — skip this session.
665			continue
666		}
667
668		abruptPath := abruptMarkerPath(lockDir, sessionID)
669
670		// Remove the trio: shutdown, startup, abrupt (if present).
671		for _, p := range []string{shutdownPath, startupPath, abruptPath} {
672			if rmErr := os.Remove(p); rmErr != nil && !os.IsNotExist(rmErr) {
673				if log != nil {
674					log.Warn().Err(rmErr).Str("path", p).Msg("sweepPairedMarkers: remove failed")
675				}
676			}
677		}
678		swept++
679	}
680
681	if swept > 0 && log != nil {
682		log.Info().Int("swept", swept).Msg("sweepPairedMarkers: cleaned up old marker files")
683	}
684}

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 8

Tags 42

↑↓ moveenter openesc close