detached 7be5016

fix(favorites): preserve shortcuts on metadata updates

commit
7be50169f1cb038f45e1a5e42a5d92996c6e4c1c
author
brice · · 2026-06-28 10:03 UTC
parent
87325b3
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +114 -30
M internal/application/dto/favorites.go +5 -4
@@ -12,8 +12,9 @@ type FavoriteCreateInput struct {
1212
1313 // FavoriteUpdateInput contains editable favorite metadata.
1414 type FavoriteUpdateInput struct {
15- ID entity.FavoriteID `json:"id"`
16- Title string `json:"title"`
17- FaviconURL string `json:"favicon_url"`
18- ShortcutKey *int `json:"shortcut_key"`
15+ ID entity.FavoriteID `json:"id"`
16+ Title string `json:"title"`
17+ FaviconURL string `json:"favicon_url"`
18+ ShortcutKey *int `json:"shortcut_key"`
19+ ShortcutKeySet bool `json:"-"`
1920 }
@@ -12,8 +12,9 @@ type FavoriteCreateInput struct {
12 12
13 // FavoriteUpdateInput contains editable favorite metadata.13 // FavoriteUpdateInput contains editable favorite metadata.
14 type FavoriteUpdateInput struct {14 type FavoriteUpdateInput struct {
15- ID entity.FavoriteID `json:"id"`15+ ID entity.FavoriteID `json:"id"`
16- Title string `json:"title"`16+ Title string `json:"title"`
17- FaviconURL string `json:"favicon_url"`17+ FaviconURL string `json:"favicon_url"`
18- ShortcutKey *int `json:"shortcut_key"`18+ ShortcutKey *int `json:"shortcut_key"`
19+ ShortcutKeySet bool `json:"-"`
19 }20 }
M internal/application/usecase/manage_favorites.go +4 -2
@@ -180,7 +180,7 @@ func (uc *ManageFavoritesUseCase) UpdateFavorite(ctx context.Context, input dto.
180180 if input.ID <= 0 {
181181 return nil, fmt.Errorf("favorite ID is required")
182182 }
183- if input.ShortcutKey != nil && (*input.ShortcutKey < 1 || *input.ShortcutKey > 9) {
183+ if input.ShortcutKeySet && input.ShortcutKey != nil && (*input.ShortcutKey < 1 || *input.ShortcutKey > 9) {
184184 return nil, fmt.Errorf("shortcut key must be 1-9, got %d", *input.ShortcutKey)
185185 }
186186
@@ -198,7 +198,9 @@ func (uc *ManageFavoritesUseCase) UpdateFavorite(ctx context.Context, input dto.
198198 }
199199 fav.Title = title
200200 fav.FaviconURL = strings.TrimSpace(input.FaviconURL)
201- fav.ShortcutKey = input.ShortcutKey
201+ if input.ShortcutKeySet {
202+ fav.ShortcutKey = input.ShortcutKey
203+ }
202204
203205 if err := uc.Update(ctx, fav); err != nil {
204206 return nil, err
@@ -180,7 +180,7 @@ func (uc *ManageFavoritesUseCase) UpdateFavorite(ctx context.Context, input dto.
180 if input.ID <= 0 {180 if input.ID <= 0 {
181 return nil, fmt.Errorf("favorite ID is required")181 return nil, fmt.Errorf("favorite ID is required")
182 }182 }
183- if input.ShortcutKey != nil && (*input.ShortcutKey < 1 || *input.ShortcutKey > 9) {183+ if input.ShortcutKeySet && input.ShortcutKey != nil && (*input.ShortcutKey < 1 || *input.ShortcutKey > 9) {
184 return nil, fmt.Errorf("shortcut key must be 1-9, got %d", *input.ShortcutKey)184 return nil, fmt.Errorf("shortcut key must be 1-9, got %d", *input.ShortcutKey)
185 }185 }
186 186
@@ -198,7 +198,9 @@ func (uc *ManageFavoritesUseCase) UpdateFavorite(ctx context.Context, input dto.
198 }198 }
199 fav.Title = title199 fav.Title = title
200 fav.FaviconURL = strings.TrimSpace(input.FaviconURL)200 fav.FaviconURL = strings.TrimSpace(input.FaviconURL)
201- fav.ShortcutKey = input.ShortcutKey201+ if input.ShortcutKeySet {
202+ fav.ShortcutKey = input.ShortcutKey
203+ }
202 204
203 if err := uc.Update(ctx, fav); err != nil {205 if err := uc.Update(ctx, fav); err != nil {
204 return nil, err206 return nil, err
M internal/application/usecase/manage_favorites_test.go +54 -0
@@ -214,6 +214,60 @@ func TestManageFavoritesUseCase_Toggle_DeleteErrorReturnsError(t *testing.T) {
214214 assert.Contains(t, err.Error(), "failed to remove favorite")
215215 }
216216
217+func TestManageFavoritesUseCase_UpdateFavoritePreservesShortcutWhenOmitted(t *testing.T) {
218+ ctx := testContext()
219+ shortcut := 3
220+ existing := &entity.Favorite{
221+ ID: 42,
222+ URL: "https://example.com",
223+ Title: "Example",
224+ ShortcutKey: &shortcut,
225+ }
226+
227+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
228+ tagRepo := repomocks.NewMockTagRepository(t)
229+ favoriteRepo.EXPECT().FindByID(mock.Anything, entity.FavoriteID(42)).Return(existing, nil)
230+ favoriteRepo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(fav *entity.Favorite) bool {
231+ return fav.ID == 42 && fav.Title == "Updated" && fav.ShortcutKey != nil && *fav.ShortcutKey == 3
232+ })).Return(nil)
233+
234+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
235+ updated, err := uc.UpdateFavorite(ctx, dto.FavoriteUpdateInput{
236+ ID: 42,
237+ Title: "Updated",
238+ })
239+ require.NoError(t, err)
240+ require.NotNil(t, updated.ShortcutKey)
241+ assert.Equal(t, 3, *updated.ShortcutKey)
242+}
243+
244+func TestManageFavoritesUseCase_UpdateFavoriteClearsShortcutWhenExplicit(t *testing.T) {
245+ ctx := testContext()
246+ shortcut := 3
247+ existing := &entity.Favorite{
248+ ID: 42,
249+ URL: "https://example.com",
250+ Title: "Example",
251+ ShortcutKey: &shortcut,
252+ }
253+
254+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
255+ tagRepo := repomocks.NewMockTagRepository(t)
256+ favoriteRepo.EXPECT().FindByID(mock.Anything, entity.FavoriteID(42)).Return(existing, nil)
257+ favoriteRepo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(fav *entity.Favorite) bool {
258+ return fav.ID == 42 && fav.Title == "Updated" && fav.ShortcutKey == nil
259+ })).Return(nil)
260+
261+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
262+ updated, err := uc.UpdateFavorite(ctx, dto.FavoriteUpdateInput{
263+ ID: 42,
264+ Title: "Updated",
265+ ShortcutKeySet: true,
266+ })
267+ require.NoError(t, err)
268+ assert.Nil(t, updated.ShortcutKey)
269+}
270+
217271 func TestManageFavoritesUseCase_FilterForOmnibox_RanksPrefixBeforeContains(t *testing.T) {
218272 ctx := testContext()
219273
@@ -214,6 +214,60 @@ func TestManageFavoritesUseCase_Toggle_DeleteErrorReturnsError(t *testing.T) {
214 assert.Contains(t, err.Error(), "failed to remove favorite")214 assert.Contains(t, err.Error(), "failed to remove favorite")
215 }215 }
216 216
217+func TestManageFavoritesUseCase_UpdateFavoritePreservesShortcutWhenOmitted(t *testing.T) {
218+ ctx := testContext()
219+ shortcut := 3
220+ existing := &entity.Favorite{
221+ ID: 42,
222+ URL: "https://example.com",
223+ Title: "Example",
224+ ShortcutKey: &shortcut,
225+ }
226+
227+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
228+ tagRepo := repomocks.NewMockTagRepository(t)
229+ favoriteRepo.EXPECT().FindByID(mock.Anything, entity.FavoriteID(42)).Return(existing, nil)
230+ favoriteRepo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(fav *entity.Favorite) bool {
231+ return fav.ID == 42 && fav.Title == "Updated" && fav.ShortcutKey != nil && *fav.ShortcutKey == 3
232+ })).Return(nil)
233+
234+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
235+ updated, err := uc.UpdateFavorite(ctx, dto.FavoriteUpdateInput{
236+ ID: 42,
237+ Title: "Updated",
238+ })
239+ require.NoError(t, err)
240+ require.NotNil(t, updated.ShortcutKey)
241+ assert.Equal(t, 3, *updated.ShortcutKey)
242+}
243+
244+func TestManageFavoritesUseCase_UpdateFavoriteClearsShortcutWhenExplicit(t *testing.T) {
245+ ctx := testContext()
246+ shortcut := 3
247+ existing := &entity.Favorite{
248+ ID: 42,
249+ URL: "https://example.com",
250+ Title: "Example",
251+ ShortcutKey: &shortcut,
252+ }
253+
254+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
255+ tagRepo := repomocks.NewMockTagRepository(t)
256+ favoriteRepo.EXPECT().FindByID(mock.Anything, entity.FavoriteID(42)).Return(existing, nil)
257+ favoriteRepo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(fav *entity.Favorite) bool {
258+ return fav.ID == 42 && fav.Title == "Updated" && fav.ShortcutKey == nil
259+ })).Return(nil)
260+
261+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
262+ updated, err := uc.UpdateFavorite(ctx, dto.FavoriteUpdateInput{
263+ ID: 42,
264+ Title: "Updated",
265+ ShortcutKeySet: true,
266+ })
267+ require.NoError(t, err)
268+ assert.Nil(t, updated.ShortcutKey)
269+}
270+
217 func TestManageFavoritesUseCase_FilterForOmnibox_RanksPrefixBeforeContains(t *testing.T) {271 func TestManageFavoritesUseCase_FilterForOmnibox_RanksPrefixBeforeContains(t *testing.T) {
218 ctx := testContext()272 ctx := testContext()
219 273
M internal/infrastructure/handlers/homepage/favorites.go +10 -4
@@ -116,6 +116,11 @@ func (h *FavoritesHandlers) HandleUpdate() port.WebUIMessageHandler {
116116 if err := json.Unmarshal(payload, &req); err != nil {
117117 return NewErrorResponse("", err), nil
118118 }
119+ var fields map[string]json.RawMessage
120+ if err := json.Unmarshal(payload, &fields); err != nil {
121+ return NewErrorResponse("", err), nil
122+ }
123+ _, shortcutKeySet := fields["shortcut_key"]
119124
120125 log.Debug().
121126 Str("request_id", req.RequestID).
@@ -127,10 +132,11 @@ func (h *FavoritesHandlers) HandleUpdate() port.WebUIMessageHandler {
127132 }
128133
129134 favorite, err := h.favoritesUC.UpdateFavorite(ctx, dto.FavoriteUpdateInput{
130- ID: entity.FavoriteID(req.ID),
131- Title: req.Title,
132- FaviconURL: req.FaviconURL,
133- ShortcutKey: req.ShortcutKey,
135+ ID: entity.FavoriteID(req.ID),
136+ Title: req.Title,
137+ FaviconURL: req.FaviconURL,
138+ ShortcutKey: req.ShortcutKey,
139+ ShortcutKeySet: shortcutKeySet,
134140 })
135141 if err != nil {
136142 return NewErrorResponse(req.RequestID, err), nil
@@ -116,6 +116,11 @@ func (h *FavoritesHandlers) HandleUpdate() port.WebUIMessageHandler {
116 if err := json.Unmarshal(payload, &req); err != nil {116 if err := json.Unmarshal(payload, &req); err != nil {
117 return NewErrorResponse("", err), nil117 return NewErrorResponse("", err), nil
118 }118 }
119+ var fields map[string]json.RawMessage
120+ if err := json.Unmarshal(payload, &fields); err != nil {
121+ return NewErrorResponse("", err), nil
122+ }
123+ _, shortcutKeySet := fields["shortcut_key"]
119 124
120 log.Debug().125 log.Debug().
121 Str("request_id", req.RequestID).126 Str("request_id", req.RequestID).
@@ -127,10 +132,11 @@ func (h *FavoritesHandlers) HandleUpdate() port.WebUIMessageHandler {
127 }132 }
128 133
129 favorite, err := h.favoritesUC.UpdateFavorite(ctx, dto.FavoriteUpdateInput{134 favorite, err := h.favoritesUC.UpdateFavorite(ctx, dto.FavoriteUpdateInput{
130- ID: entity.FavoriteID(req.ID),135+ ID: entity.FavoriteID(req.ID),
131- Title: req.Title,136+ Title: req.Title,
132- FaviconURL: req.FaviconURL,137+ FaviconURL: req.FaviconURL,
133- ShortcutKey: req.ShortcutKey,138+ ShortcutKey: req.ShortcutKey,
139+ ShortcutKeySet: shortcutKeySet,
134 })140 })
135 if err != nil {141 if err != nil {
136 return NewErrorResponse(req.RequestID, err), nil142 return NewErrorResponse(req.RequestID, err), nil
M internal/infrastructure/handlers/homepage/favorites_test.go +26 -4
@@ -55,10 +55,11 @@ func TestHandleFavoriteUpdatePassesTagsOnlyPayload(t *testing.T) {
5555 favorites := portmocks.NewMockHomepageFavorites(t)
5656 favorites.EXPECT().
5757 UpdateFavorite(mock.Anything, dto.FavoriteUpdateInput{
58- ID: entity.FavoriteID(42),
59- Title: "Updated",
60- FaviconURL: "https://example.com/favicon.ico",
61- ShortcutKey: &shortcut,
58+ ID: entity.FavoriteID(42),
59+ Title: "Updated",
60+ FaviconURL: "https://example.com/favicon.ico",
61+ ShortcutKey: &shortcut,
62+ ShortcutKeySet: true,
6263 }).
6364 Return(&entity.Favorite{ID: 42, URL: "https://example.com", Title: "Updated", ShortcutKey: &shortcut}, nil).
6465 Once()
@@ -71,3 +72,24 @@ func TestHandleFavoriteUpdatePassesTagsOnlyPayload(t *testing.T) {
7172 require.True(t, ok)
7273 require.True(t, resp.Success)
7374 }
75+func TestHandleFavoriteUpdatePreservesShortcutWhenOmitted(t *testing.T) {
76+ t.Parallel()
77+
78+ favorites := portmocks.NewMockHomepageFavorites(t)
79+ favorites.EXPECT().
80+ UpdateFavorite(mock.Anything, dto.FavoriteUpdateInput{
81+ ID: entity.FavoriteID(42),
82+ Title: "Updated",
83+ FaviconURL: "https://example.com/favicon.ico",
84+ }).
85+ Return(&entity.Favorite{ID: 42, URL: "https://example.com", Title: "Updated"}, nil).
86+ Once()
87+
88+ handler := NewFavoritesHandlers(favorites).HandleUpdate()
89+ got, err := handler.Handle(context.Background(), port.WebViewID(0), json.RawMessage(`{"requestId":"req-4","id":42,"title":"Updated","favicon_url":"https://example.com/favicon.ico"}`))
90+ require.NoError(t, err)
91+
92+ resp, ok := got.(Response)
93+ require.True(t, ok)
94+ require.True(t, resp.Success)
95+}
@@ -55,10 +55,11 @@ func TestHandleFavoriteUpdatePassesTagsOnlyPayload(t *testing.T) {
55 favorites := portmocks.NewMockHomepageFavorites(t)55 favorites := portmocks.NewMockHomepageFavorites(t)
56 favorites.EXPECT().56 favorites.EXPECT().
57 UpdateFavorite(mock.Anything, dto.FavoriteUpdateInput{57 UpdateFavorite(mock.Anything, dto.FavoriteUpdateInput{
58- ID: entity.FavoriteID(42),58+ ID: entity.FavoriteID(42),
59- Title: "Updated",59+ Title: "Updated",
60- FaviconURL: "https://example.com/favicon.ico",60+ FaviconURL: "https://example.com/favicon.ico",
61- ShortcutKey: &shortcut,61+ ShortcutKey: &shortcut,
62+ ShortcutKeySet: true,
62 }).63 }).
63 Return(&entity.Favorite{ID: 42, URL: "https://example.com", Title: "Updated", ShortcutKey: &shortcut}, nil).64 Return(&entity.Favorite{ID: 42, URL: "https://example.com", Title: "Updated", ShortcutKey: &shortcut}, nil).
64 Once()65 Once()
@@ -71,3 +72,24 @@ func TestHandleFavoriteUpdatePassesTagsOnlyPayload(t *testing.T) {
71 require.True(t, ok)72 require.True(t, ok)
72 require.True(t, resp.Success)73 require.True(t, resp.Success)
73 }74 }
75+func TestHandleFavoriteUpdatePreservesShortcutWhenOmitted(t *testing.T) {
76+ t.Parallel()
77+
78+ favorites := portmocks.NewMockHomepageFavorites(t)
79+ favorites.EXPECT().
80+ UpdateFavorite(mock.Anything, dto.FavoriteUpdateInput{
81+ ID: entity.FavoriteID(42),
82+ Title: "Updated",
83+ FaviconURL: "https://example.com/favicon.ico",
84+ }).
85+ Return(&entity.Favorite{ID: 42, URL: "https://example.com", Title: "Updated"}, nil).
86+ Once()
87+
88+ handler := NewFavoritesHandlers(favorites).HandleUpdate()
89+ got, err := handler.Handle(context.Background(), port.WebViewID(0), json.RawMessage(`{"requestId":"req-4","id":42,"title":"Updated","favicon_url":"https://example.com/favicon.ico"}`))
90+ require.NoError(t, err)
91+
92+ resp, ok := got.(Response)
93+ require.True(t, ok)
94+ require.True(t, resp.Success)
95+}
M internal/infrastructure/systemviewsbridge/client.go +9 -12
@@ -283,19 +283,16 @@ func favoriteCreatePayload(input dto.FavoriteCreateInput) any {
283283 }
284284
285285 func favoriteUpdatePayload(input dto.FavoriteUpdateInput) any {
286- return struct {
287- RequestID string `json:"requestId"`
288- ID int64 `json:"id"`
289- Title string `json:"title"`
290- FaviconURL string `json:"favicon_url"`
291- ShortcutKey *int `json:"shortcut_key"`
292- }{
293- RequestID: nextRequestID(),
294- ID: int64(input.ID),
295- Title: input.Title,
296- FaviconURL: input.FaviconURL,
297- ShortcutKey: input.ShortcutKey,
286+ payload := map[string]any{
287+ "requestId": nextRequestID(),
288+ "id": int64(input.ID),
289+ "title": input.Title,
290+ "favicon_url": input.FaviconURL,
291+ }
292+ if input.ShortcutKeySet {
293+ payload["shortcut_key"] = input.ShortcutKey
298294 }
295+ return payload
299296 }
300297
301298 func tagIDPayloads(ids []entity.TagID) []int64 {
@@ -283,19 +283,16 @@ func favoriteCreatePayload(input dto.FavoriteCreateInput) any {
283 }283 }
284 284
285 func favoriteUpdatePayload(input dto.FavoriteUpdateInput) any {285 func favoriteUpdatePayload(input dto.FavoriteUpdateInput) any {
286- return struct {286+ payload := map[string]any{
287- RequestID string `json:"requestId"`287+ "requestId": nextRequestID(),
288- ID int64 `json:"id"`288+ "id": int64(input.ID),
289- Title string `json:"title"`289+ "title": input.Title,
290- FaviconURL string `json:"favicon_url"`290+ "favicon_url": input.FaviconURL,
291- ShortcutKey *int `json:"shortcut_key"`291+ }
292- }{292+ if input.ShortcutKeySet {
293- RequestID: nextRequestID(),293+ payload["shortcut_key"] = input.ShortcutKey
294- ID: int64(input.ID),
295- Title: input.Title,
296- FaviconURL: input.FaviconURL,
297- ShortcutKey: input.ShortcutKey,
298 }294 }
295+ return payload
299 }296 }
300 297
301 func tagIDPayloads(ids []entity.TagID) []int64 {298 func tagIDPayloads(ids []entity.TagID) []int64 {
M internal/ui/systemviews/app_test.go +1 -0
@@ -597,6 +597,7 @@ func TestAppHandleFavoriteActionsRefreshesDOM(t *testing.T) {
597597 }))
598598 assert.Equal(t, entity.FavoriteID(42), favorites.updatedFavorite.ID)
599599 assert.Equal(t, "Updated", favorites.updatedFavorite.Title)
600+ assert.True(t, favorites.updatedFavorite.ShortcutKeySet)
600601 require.NotNil(t, favorites.updatedFavorite.ShortcutKey)
601602 assert.Equal(t, 4, *favorites.updatedFavorite.ShortcutKey)
602603 assert.Contains(t, dom.HTML(), "Saved favorite Updated")
@@ -597,6 +597,7 @@ func TestAppHandleFavoriteActionsRefreshesDOM(t *testing.T) {
597 }))597 }))
598 assert.Equal(t, entity.FavoriteID(42), favorites.updatedFavorite.ID)598 assert.Equal(t, entity.FavoriteID(42), favorites.updatedFavorite.ID)
599 assert.Equal(t, "Updated", favorites.updatedFavorite.Title)599 assert.Equal(t, "Updated", favorites.updatedFavorite.Title)
600+ assert.True(t, favorites.updatedFavorite.ShortcutKeySet)
600 require.NotNil(t, favorites.updatedFavorite.ShortcutKey)601 require.NotNil(t, favorites.updatedFavorite.ShortcutKey)
601 assert.Equal(t, 4, *favorites.updatedFavorite.ShortcutKey)602 assert.Equal(t, 4, *favorites.updatedFavorite.ShortcutKey)
602 assert.Contains(t, dom.HTML(), "Saved favorite Updated")603 assert.Contains(t, dom.HTML(), "Saved favorite Updated")
M internal/ui/systemviews/favorite_actions.go +5 -4
@@ -60,10 +60,11 @@ func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error {
6060 return err
6161 }
6262 favorite, err := a.deps.Favorites.UpdateFavorite(ctx, dto.FavoriteUpdateInput{
63- ID: entity.FavoriteID(id),
64- Title: strings.TrimSpace(data["title"]),
65- FaviconURL: strings.TrimSpace(data["favicon_url"]),
66- ShortcutKey: shortcut,
63+ ID: entity.FavoriteID(id),
64+ Title: strings.TrimSpace(data["title"]),
65+ FaviconURL: strings.TrimSpace(data["favicon_url"]),
66+ ShortcutKey: shortcut,
67+ ShortcutKeySet: true,
6768 })
6869 if err != nil {
6970 return err
@@ -60,10 +60,11 @@ func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error {
60 return err60 return err
61 }61 }
62 favorite, err := a.deps.Favorites.UpdateFavorite(ctx, dto.FavoriteUpdateInput{62 favorite, err := a.deps.Favorites.UpdateFavorite(ctx, dto.FavoriteUpdateInput{
63- ID: entity.FavoriteID(id),63+ ID: entity.FavoriteID(id),
64- Title: strings.TrimSpace(data["title"]),64+ Title: strings.TrimSpace(data["title"]),
65- FaviconURL: strings.TrimSpace(data["favicon_url"]),65+ FaviconURL: strings.TrimSpace(data["favicon_url"]),
66- ShortcutKey: shortcut,66+ ShortcutKey: shortcut,
67+ ShortcutKeySet: true,
67 })68 })
68 if err != nil {69 if err != nil {
69 return err70 return 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