detached 8e835cc

fix: address follow-up review findings

commit
8e835cc58bf41aec7010db4330e0d70c118617b7
author
brice · · 2026-06-26 03:48 UTC
parent
fd1a5db
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +145 -10
M internal/application/usecase/list_sessions.go +4 -1
@@ -9,6 +9,9 @@ import (
99 "github.com/bnema/dumber/internal/logging"
1010 )
1111
12+// ListAllSessionsLimit asks Execute to return every known session.
13+const ListAllSessionsLimit = -1
14+
1215 // ListSessionsUseCase handles listing sessions with their state information.
1316 type ListSessionsUseCase struct {
1417 sessionRepo repository.SessionRepository
@@ -36,7 +39,7 @@ type ListSessionsOutput struct {
3639 func (uc *ListSessionsUseCase) Execute(ctx context.Context, currentSessionID entity.SessionID, limit int) (*ListSessionsOutput, error) {
3740 log := logging.FromContext(ctx)
3841
39- if limit <= 0 {
42+ if limit == 0 {
4043 limit = 50
4144 }
4245
@@ -9,6 +9,9 @@ import (
9 "github.com/bnema/dumber/internal/logging"9 "github.com/bnema/dumber/internal/logging"
10 )10 )
11 11
12+// ListAllSessionsLimit asks Execute to return every known session.
13+const ListAllSessionsLimit = -1
14+
12 // ListSessionsUseCase handles listing sessions with their state information.15 // ListSessionsUseCase handles listing sessions with their state information.
13 type ListSessionsUseCase struct {16 type ListSessionsUseCase struct {
14 sessionRepo repository.SessionRepository17 sessionRepo repository.SessionRepository
@@ -36,7 +39,7 @@ type ListSessionsOutput struct {
36 func (uc *ListSessionsUseCase) Execute(ctx context.Context, currentSessionID entity.SessionID, limit int) (*ListSessionsOutput, error) {39 func (uc *ListSessionsUseCase) Execute(ctx context.Context, currentSessionID entity.SessionID, limit int) (*ListSessionsOutput, error) {
37 log := logging.FromContext(ctx)40 log := logging.FromContext(ctx)
38 41
39- if limit <= 0 {42+ if limit == 0 {
40 limit = 5043 limit = 50
41 }44 }
42 45
M internal/application/usecase/navigation_url_normalizer.go +16 -1
@@ -3,6 +3,7 @@ package usecase
33 import (
44 "context"
55 stdurl "net/url"
6+ "strings"
67
78 "github.com/bnema/dumber/internal/application/port"
89 domainurl "github.com/bnema/dumber/internal/domain/url"
@@ -55,5 +56,19 @@ func hasPreservedNavigationScheme(input string) bool {
5556 if err != nil || parsed.Scheme == "" {
5657 return false
5758 }
58- return domainurl.Normalize(input) == input
59+
60+ scheme := strings.ToLower(parsed.Scheme)
61+ lowerInput := strings.ToLower(input)
62+
63+ switch scheme {
64+ case "http", "https", "dumb", "file", "vscode", "vscode-insiders", "spotify", "steam":
65+ return strings.HasPrefix(lowerInput, scheme+"://") && domainurl.Normalize(input) == input
66+ case "blob":
67+ return strings.HasPrefix(lowerInput, "blob:") && strings.Contains(lowerInput, "://") &&
68+ domainurl.Normalize(input) == input
69+ case "about", "data", "javascript", "mailto":
70+ return strings.HasPrefix(lowerInput, scheme+":") && domainurl.Normalize(input) == input
71+ default:
72+ return false
73+ }
5974 }
@@ -3,6 +3,7 @@ package usecase
3 import (3 import (
4 "context"4 "context"
5 stdurl "net/url"5 stdurl "net/url"
6+ "strings"
6 7
7 "github.com/bnema/dumber/internal/application/port"8 "github.com/bnema/dumber/internal/application/port"
8 domainurl "github.com/bnema/dumber/internal/domain/url"9 domainurl "github.com/bnema/dumber/internal/domain/url"
@@ -55,5 +56,19 @@ func hasPreservedNavigationScheme(input string) bool {
55 if err != nil || parsed.Scheme == "" {56 if err != nil || parsed.Scheme == "" {
56 return false57 return false
57 }58 }
58- return domainurl.Normalize(input) == input59+
60+ scheme := strings.ToLower(parsed.Scheme)
61+ lowerInput := strings.ToLower(input)
62+
63+ switch scheme {
64+ case "http", "https", "dumb", "file", "vscode", "vscode-insiders", "spotify", "steam":
65+ return strings.HasPrefix(lowerInput, scheme+"://") && domainurl.Normalize(input) == input
66+ case "blob":
67+ return strings.HasPrefix(lowerInput, "blob:") && strings.Contains(lowerInput, "://") &&
68+ domainurl.Normalize(input) == input
69+ case "about", "data", "javascript", "mailto":
70+ return strings.HasPrefix(lowerInput, scheme+":") && domainurl.Normalize(input) == input
71+ default:
72+ return false
73+ }
59 }74 }
M internal/application/usecase/navigation_url_normalizer_test.go +7 -4
@@ -21,12 +21,15 @@ func TestNavigationURLNormalizerResolvesExistingLocalPaths(t *testing.T) {
2121 ctx := context.Background()
2222 absFile := filepath.Join(string(filepath.Separator), "tmp", "page.html")
2323 normalizer := NewNavigationURLNormalizer(fakeLocalPathResolver{paths: map[string]string{
24- absFile: absFile,
25- "page.html": absFile,
26- "~/page.html": absFile,
24+ absFile: absFile,
25+ "page.html": absFile,
26+ "~/page.html": absFile,
27+ "notes:2026.txt": absFile,
28+ "http:notes.txt": absFile,
29+ "http:notes": absFile,
2730 }})
2831
29- for _, input := range []string{absFile, "page.html", "~/page.html"} {
32+ for _, input := range []string{absFile, "page.html", "~/page.html", "notes:2026.txt", "http:notes.txt", "http:notes"} {
3033 t.Run(input, func(t *testing.T) {
3134 got := normalizer.Normalize(ctx, input)
3235 want := (&stdurl.URL{Scheme: "file", Path: absFile}).String()
@@ -21,12 +21,15 @@ func TestNavigationURLNormalizerResolvesExistingLocalPaths(t *testing.T) {
21 ctx := context.Background()21 ctx := context.Background()
22 absFile := filepath.Join(string(filepath.Separator), "tmp", "page.html")22 absFile := filepath.Join(string(filepath.Separator), "tmp", "page.html")
23 normalizer := NewNavigationURLNormalizer(fakeLocalPathResolver{paths: map[string]string{23 normalizer := NewNavigationURLNormalizer(fakeLocalPathResolver{paths: map[string]string{
24- absFile: absFile,24+ absFile: absFile,
25- "page.html": absFile,25+ "page.html": absFile,
26- "~/page.html": absFile,26+ "~/page.html": absFile,
27+ "notes:2026.txt": absFile,
28+ "http:notes.txt": absFile,
29+ "http:notes": absFile,
27 }})30 }})
28 31
29- for _, input := range []string{absFile, "page.html", "~/page.html"} {32+ for _, input := range []string{absFile, "page.html", "~/page.html", "notes:2026.txt", "http:notes.txt", "http:notes"} {
30 t.Run(input, func(t *testing.T) {33 t.Run(input, func(t *testing.T) {
31 got := normalizer.Normalize(ctx, input)34 got := normalizer.Normalize(ctx, input)
32 want := (&stdurl.URL{Scheme: "file", Path: absFile}).String()35 want := (&stdurl.URL{Scheme: "file", Path: absFile}).String()
M internal/cli/cmd/sessions.go +9 -3
@@ -1,6 +1,7 @@
11 package cmd
22
33 import (
4+ "context"
45 "encoding/json"
56 "fmt"
67 "os"
@@ -269,14 +270,19 @@ func findSessionByIDOrSuffix(idOrSuffix string) (*entity.SessionInfo, error) {
269270 return nil, fmt.Errorf("session management not available")
270271 }
271272
273+ ctx := cliApp.Ctx()
274+ if ctx == nil {
275+ ctx = context.Background()
276+ }
277+
272278 // Get current session ID (empty if not running as browser)
273279 var currentSessionID entity.SessionID
274- if active, err := cliApp.SessionUC.GetActiveSession(cliApp.Ctx()); err == nil && active != nil {
280+ if active, err := cliApp.SessionUC.GetActiveSession(ctx); err == nil && active != nil {
275281 currentSessionID = active.ID
276282 }
277283
278- // List all sessions
279- output, err := cliApp.ListSessionsUC.Execute(cliApp.Ctx(), currentSessionID, defaultSessionsLimit*5)
284+ // List all sessions so restore/delete suffix lookup can find older sessions.
285+ output, err := cliApp.ListSessionsUC.Execute(ctx, currentSessionID, usecase.ListAllSessionsLimit)
280286 if err != nil {
281287 return nil, fmt.Errorf("list sessions: %w", err)
282288 }
@@ -1,6 +1,7 @@
1 package cmd1 package cmd
2 2
3 import (3 import (
4+ "context"
4 "encoding/json"5 "encoding/json"
5 "fmt"6 "fmt"
6 "os"7 "os"
@@ -269,14 +270,19 @@ func findSessionByIDOrSuffix(idOrSuffix string) (*entity.SessionInfo, error) {
269 return nil, fmt.Errorf("session management not available")270 return nil, fmt.Errorf("session management not available")
270 }271 }
271 272
273+ ctx := cliApp.Ctx()
274+ if ctx == nil {
275+ ctx = context.Background()
276+ }
277+
272 // Get current session ID (empty if not running as browser)278 // Get current session ID (empty if not running as browser)
273 var currentSessionID entity.SessionID279 var currentSessionID entity.SessionID
274- if active, err := cliApp.SessionUC.GetActiveSession(cliApp.Ctx()); err == nil && active != nil {280+ if active, err := cliApp.SessionUC.GetActiveSession(ctx); err == nil && active != nil {
275 currentSessionID = active.ID281 currentSessionID = active.ID
276 }282 }
277 283
278- // List all sessions284+ // List all sessions so restore/delete suffix lookup can find older sessions.
279- output, err := cliApp.ListSessionsUC.Execute(cliApp.Ctx(), currentSessionID, defaultSessionsLimit*5)285+ output, err := cliApp.ListSessionsUC.Execute(ctx, currentSessionID, usecase.ListAllSessionsLimit)
280 if err != nil {286 if err != nil {
281 return nil, fmt.Errorf("list sessions: %w", err)287 return nil, fmt.Errorf("list sessions: %w", err)
282 }288 }
M internal/cli/cmd/sessions_test.go +104 -0
@@ -1,13 +1,17 @@
11 package cmd
22
33 import (
4+ "context"
5+ "fmt"
46 "io"
57 "os"
68 "testing"
9+ "time"
710
811 "github.com/bnema/dumber/internal/application/usecase"
912 "github.com/bnema/dumber/internal/cli"
1013 "github.com/bnema/dumber/internal/cli/styles"
14+ "github.com/bnema/dumber/internal/domain/entity"
1115 "github.com/bnema/dumber/internal/infrastructure/config"
1216 "github.com/stretchr/testify/require"
1317 )
@@ -59,6 +63,38 @@ func TestSessionRestoreReturnsRestorationErrorWhenSessionUseCaseMissing(t *testi
5963 require.ErrorContains(t, err, "session restoration not available")
6064 }
6165
66+func TestFindSessionByIDOrSuffixSearchesBeyondDefaultLimit(t *testing.T) {
67+ targetID := entity.SessionID("20251224_000000_targetolder")
68+ sessions := make([]*entity.Session, 0, defaultSessionsLimit*5+1)
69+ startedAt := time.Date(2025, 12, 24, 12, 0, 0, 0, time.UTC)
70+ for i := 0; i < defaultSessionsLimit*5; i++ {
71+ sessions = append(sessions, &entity.Session{
72+ ID: entity.SessionID(fmt.Sprintf("20251224_1200%02d_recent", i)),
73+ Type: entity.SessionTypeBrowser,
74+ StartedAt: startedAt.Add(-time.Duration(i) * time.Minute),
75+ })
76+ }
77+ sessions = append(sessions, &entity.Session{
78+ ID: targetID,
79+ Type: entity.SessionTypeBrowser,
80+ StartedAt: startedAt.Add(-time.Hour),
81+ })
82+
83+ sessionRepo := &sessionCommandSessionRepo{sessions: sessions}
84+ cfg := config.DefaultConfig()
85+ withApp(t, &cli.App{
86+ Config: cfg,
87+ Theme: styles.NewTheme(cfg),
88+ SessionUC: usecase.NewManageSessionUseCase(sessionRepo, nil),
89+ ListSessionsUC: usecase.NewListSessionsUseCase(sessionRepo, sessionCommandStateRepo{}),
90+ })
91+
92+ got, err := findSessionByIDOrSuffix("targetolder")
93+
94+ require.NoError(t, err)
95+ require.Equal(t, targetID, got.Session.ID)
96+}
97+
6298 func sessionCommandTestApp() *cli.App {
6399 cfg := config.DefaultConfig()
64100 return &cli.App{
@@ -90,3 +126,71 @@ func discardStderr(t *testing.T) {
90126 require.NoError(t, reader.Close())
91127 })
92128 }
129+
130+type sessionCommandSessionRepo struct {
131+ sessions []*entity.Session
132+}
133+
134+func (*sessionCommandSessionRepo) Save(context.Context, *entity.Session) error {
135+ return nil
136+}
137+
138+func (r *sessionCommandSessionRepo) FindByID(_ context.Context, id entity.SessionID) (*entity.Session, error) {
139+ for _, session := range r.sessions {
140+ if session.ID == id {
141+ return session, nil
142+ }
143+ }
144+ return nil, nil
145+}
146+
147+func (*sessionCommandSessionRepo) GetActive(context.Context) (*entity.Session, error) {
148+ return nil, nil
149+}
150+
151+func (r *sessionCommandSessionRepo) GetRecent(_ context.Context, limit int) ([]*entity.Session, error) {
152+ if limit < 0 || limit > len(r.sessions) {
153+ limit = len(r.sessions)
154+ }
155+ out := make([]*entity.Session, limit)
156+ copy(out, r.sessions[:limit])
157+ return out, nil
158+}
159+
160+func (*sessionCommandSessionRepo) MarkEnded(context.Context, entity.SessionID, time.Time) error {
161+ return nil
162+}
163+
164+func (*sessionCommandSessionRepo) Delete(context.Context, entity.SessionID) error {
165+ return nil
166+}
167+
168+func (*sessionCommandSessionRepo) DeleteOldestExited(context.Context, int) (int64, error) {
169+ return 0, nil
170+}
171+
172+func (*sessionCommandSessionRepo) DeleteExitedBefore(context.Context, time.Time) (int64, error) {
173+ return 0, nil
174+}
175+
176+type sessionCommandStateRepo struct{}
177+
178+func (sessionCommandStateRepo) SaveSnapshot(context.Context, *entity.SessionState) error {
179+ return nil
180+}
181+
182+func (sessionCommandStateRepo) GetSnapshot(context.Context, entity.SessionID) (*entity.SessionState, error) {
183+ return nil, nil
184+}
185+
186+func (sessionCommandStateRepo) DeleteSnapshot(context.Context, entity.SessionID) error {
187+ return nil
188+}
189+
190+func (sessionCommandStateRepo) GetAllSnapshots(context.Context) ([]*entity.SessionState, error) {
191+ return nil, nil
192+}
193+
194+func (sessionCommandStateRepo) GetTotalSnapshotsSize(context.Context) (int64, error) {
195+ return 0, nil
196+}
@@ -1,13 +1,17 @@
1 package cmd1 package cmd
2 2
3 import (3 import (
4+ "context"
5+ "fmt"
4 "io"6 "io"
5 "os"7 "os"
6 "testing"8 "testing"
9+ "time"
7 10
8 "github.com/bnema/dumber/internal/application/usecase"11 "github.com/bnema/dumber/internal/application/usecase"
9 "github.com/bnema/dumber/internal/cli"12 "github.com/bnema/dumber/internal/cli"
10 "github.com/bnema/dumber/internal/cli/styles"13 "github.com/bnema/dumber/internal/cli/styles"
14+ "github.com/bnema/dumber/internal/domain/entity"
11 "github.com/bnema/dumber/internal/infrastructure/config"15 "github.com/bnema/dumber/internal/infrastructure/config"
12 "github.com/stretchr/testify/require"16 "github.com/stretchr/testify/require"
13 )17 )
@@ -59,6 +63,38 @@ func TestSessionRestoreReturnsRestorationErrorWhenSessionUseCaseMissing(t *testi
59 require.ErrorContains(t, err, "session restoration not available")63 require.ErrorContains(t, err, "session restoration not available")
60 }64 }
61 65
66+func TestFindSessionByIDOrSuffixSearchesBeyondDefaultLimit(t *testing.T) {
67+ targetID := entity.SessionID("20251224_000000_targetolder")
68+ sessions := make([]*entity.Session, 0, defaultSessionsLimit*5+1)
69+ startedAt := time.Date(2025, 12, 24, 12, 0, 0, 0, time.UTC)
70+ for i := 0; i < defaultSessionsLimit*5; i++ {
71+ sessions = append(sessions, &entity.Session{
72+ ID: entity.SessionID(fmt.Sprintf("20251224_1200%02d_recent", i)),
73+ Type: entity.SessionTypeBrowser,
74+ StartedAt: startedAt.Add(-time.Duration(i) * time.Minute),
75+ })
76+ }
77+ sessions = append(sessions, &entity.Session{
78+ ID: targetID,
79+ Type: entity.SessionTypeBrowser,
80+ StartedAt: startedAt.Add(-time.Hour),
81+ })
82+
83+ sessionRepo := &sessionCommandSessionRepo{sessions: sessions}
84+ cfg := config.DefaultConfig()
85+ withApp(t, &cli.App{
86+ Config: cfg,
87+ Theme: styles.NewTheme(cfg),
88+ SessionUC: usecase.NewManageSessionUseCase(sessionRepo, nil),
89+ ListSessionsUC: usecase.NewListSessionsUseCase(sessionRepo, sessionCommandStateRepo{}),
90+ })
91+
92+ got, err := findSessionByIDOrSuffix("targetolder")
93+
94+ require.NoError(t, err)
95+ require.Equal(t, targetID, got.Session.ID)
96+}
97+
62 func sessionCommandTestApp() *cli.App {98 func sessionCommandTestApp() *cli.App {
63 cfg := config.DefaultConfig()99 cfg := config.DefaultConfig()
64 return &cli.App{100 return &cli.App{
@@ -90,3 +126,71 @@ func discardStderr(t *testing.T) {
90 require.NoError(t, reader.Close())126 require.NoError(t, reader.Close())
91 })127 })
92 }128 }
129+
130+type sessionCommandSessionRepo struct {
131+ sessions []*entity.Session
132+}
133+
134+func (*sessionCommandSessionRepo) Save(context.Context, *entity.Session) error {
135+ return nil
136+}
137+
138+func (r *sessionCommandSessionRepo) FindByID(_ context.Context, id entity.SessionID) (*entity.Session, error) {
139+ for _, session := range r.sessions {
140+ if session.ID == id {
141+ return session, nil
142+ }
143+ }
144+ return nil, nil
145+}
146+
147+func (*sessionCommandSessionRepo) GetActive(context.Context) (*entity.Session, error) {
148+ return nil, nil
149+}
150+
151+func (r *sessionCommandSessionRepo) GetRecent(_ context.Context, limit int) ([]*entity.Session, error) {
152+ if limit < 0 || limit > len(r.sessions) {
153+ limit = len(r.sessions)
154+ }
155+ out := make([]*entity.Session, limit)
156+ copy(out, r.sessions[:limit])
157+ return out, nil
158+}
159+
160+func (*sessionCommandSessionRepo) MarkEnded(context.Context, entity.SessionID, time.Time) error {
161+ return nil
162+}
163+
164+func (*sessionCommandSessionRepo) Delete(context.Context, entity.SessionID) error {
165+ return nil
166+}
167+
168+func (*sessionCommandSessionRepo) DeleteOldestExited(context.Context, int) (int64, error) {
169+ return 0, nil
170+}
171+
172+func (*sessionCommandSessionRepo) DeleteExitedBefore(context.Context, time.Time) (int64, error) {
173+ return 0, nil
174+}
175+
176+type sessionCommandStateRepo struct{}
177+
178+func (sessionCommandStateRepo) SaveSnapshot(context.Context, *entity.SessionState) error {
179+ return nil
180+}
181+
182+func (sessionCommandStateRepo) GetSnapshot(context.Context, entity.SessionID) (*entity.SessionState, error) {
183+ return nil, nil
184+}
185+
186+func (sessionCommandStateRepo) DeleteSnapshot(context.Context, entity.SessionID) error {
187+ return nil
188+}
189+
190+func (sessionCommandStateRepo) GetAllSnapshots(context.Context) ([]*entity.SessionState, error) {
191+ return nil, nil
192+}
193+
194+func (sessionCommandStateRepo) GetTotalSnapshotsSize(context.Context) (int64, error) {
195+ return 0, nil
196+}
M internal/infrastructure/config/validation_test.go +1 -0
@@ -382,6 +382,7 @@ func TestValidateConfig_WorkspaceNewPaneURLRejectsMissingBareRelativeValue(t *te
382382 validationErr := validateConfig(cfg)
383383 require.Error(t, validationErr)
384384 assert.Contains(t, validationErr.Error(), "workspace.new_pane_url")
385+ assert.Contains(t, validationErr.Error(), "relative local path cannot be resolved without config file context")
385386 }
386387
387388 func TestValidateConfig_WebKitDefaultProfileIgnoresZeroGPUThreads(t *testing.T) {
@@ -382,6 +382,7 @@ func TestValidateConfig_WorkspaceNewPaneURLRejectsMissingBareRelativeValue(t *te
382 validationErr := validateConfig(cfg)382 validationErr := validateConfig(cfg)
383 require.Error(t, validationErr)383 require.Error(t, validationErr)
384 assert.Contains(t, validationErr.Error(), "workspace.new_pane_url")384 assert.Contains(t, validationErr.Error(), "workspace.new_pane_url")
385+ assert.Contains(t, validationErr.Error(), "relative local path cannot be resolved without config file context")
385 }386 }
386 387
387 func TestValidateConfig_WebKitDefaultProfileIgnoresZeroGPUThreads(t *testing.T) {388 func TestValidateConfig_WebKitDefaultProfileIgnoresZeroGPUThreads(t *testing.T) {
M internal/infrastructure/persistence/sqlite/session_repo.go +4 -1
@@ -69,9 +69,12 @@ func (r *sessionRepo) GetActive(ctx context.Context) (*entity.Session, error) {
6969 }
7070
7171 func (r *sessionRepo) GetRecent(ctx context.Context, limit int) ([]*entity.Session, error) {
72- if limit <= 0 {
72+ if limit == 0 {
7373 limit = 20
7474 }
75+ if limit < 0 {
76+ limit = -1
77+ }
7578 rows, err := r.queries.GetRecentSessions(ctx, int64(limit))
7679 if err != nil {
7780 return nil, err
@@ -69,9 +69,12 @@ func (r *sessionRepo) GetActive(ctx context.Context) (*entity.Session, error) {
69 }69 }
70 70
71 func (r *sessionRepo) GetRecent(ctx context.Context, limit int) ([]*entity.Session, error) {71 func (r *sessionRepo) GetRecent(ctx context.Context, limit int) ([]*entity.Session, error) {
72- if limit <= 0 {72+ if limit == 0 {
73 limit = 2073 limit = 20
74 }74 }
75+ if limit < 0 {
76+ limit = -1
77+ }
75 rows, err := r.queries.GetRecentSessions(ctx, int64(limit))78 rows, err := r.queries.GetRecentSessions(ctx, int64(limit))
76 if err != nil {79 if err != nil {
77 return nil, err80 return nil, err

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