v0.29.0 21b3150

app.go RawHistoryFolder

1package systemviews
2
3import (
4	"context"
5	"errors"
6	"fmt"
7	"strings"
8	"sync"
9	"time"
10
11	"github.com/bnema/dumber/internal/application/dto"
12	"github.com/bnema/dumber/internal/application/port"
13	"github.com/bnema/dumber/internal/domain/entity"
14)
15
16type Dependencies struct {
17	DOM         DOM
18	Favorites   port.SystemviewFavoritesService
19	History     port.SystemviewHistoryService
20	Config      port.SystemviewConfigService
21	LocationURI string
22}
23
24type App struct {
25	// mu protects rendered route state; actionMu protects worker channels.
26	// The two locks must not be held at the same time.
27	mu                   sync.Mutex
28	actionMu             sync.Mutex
29	deps                 Dependencies
30	currentRoute         Route
31	shellTheme           shellTheme
32	historyEntries       []*entity.HistoryEntry
33	historyStats         *entity.HistoryStats
34	historyAnalytics     *entity.HistoryAnalytics
35	historyDomainStats   []*entity.DomainStat
36	historyQuery         string
37	historyDomainFilter  string
38	historyOffset        int
39	historyWindowBefore  time.Time
40	historyWindowAfter   time.Time
41	historyHasMore       bool
42	historyNotice        string
43	historyError         string
44	favorites            []*entity.Favorite
45	folders              []*entity.Folder
46	tags                 []*entity.Tag
47	favoriteFolderFilter *entity.FolderID
48	favoriteTagFilter    *entity.TagID
49	favoritesNotice      string
50	favoritesError       string
51	config               *dto.SystemviewConfigPayload
52	keybindings          port.KeybindingsConfig
53	configNotice         string
54	configError          string
55	renderedHTML         string
56	renderGeneration     uint64
57	closed               bool
58	actionQueue          chan DOMAction
59	actionErrorQueue     chan error
60	actionCtx            context.Context
61	actionCancel         context.CancelFunc
62	actionClosed         bool
63	actionWG             sync.WaitGroup
64}
65
66const (
67	historyTimelineLimit = 0
68	historySearchLimit   = 100
69)
70
71func (a *App) lockAction() {
72	// Lock order: actionMu is never acquired while App.mu is held.
73	a.actionMu.Lock()
74}
75
76func (a *App) unlockAction() {
77	a.actionMu.Unlock()
78}
79
80func (a *App) lockState() {
81	// Lock order: App.mu is never held while acquiring actionMu.
82	a.mu.Lock()
83}
84
85func (a *App) unlockState() {
86	a.mu.Unlock()
87}
88
89func NewApp(deps Dependencies) *App {
90	return &App{deps: deps, currentRoute: RouteUnknown}
91}
92
93func (a *App) Run() error {
94	return a.RunWithContext(context.Background())
95}
96
97func (a *App) RunWithContext(ctx context.Context) error {
98	if a == nil {
99		return errors.New("app is nil")
100	}
101	if ctx == nil {
102		return errors.New("context is nil")
103	}
104
105	a.currentRoute = ParseRoute(a.deps.LocationURI)
106	if a.deps.DOM == nil {
107		return errors.New("DOM not configured")
108	}
109	asyncInitial := a.shouldLoadInitialRouteAsync()
110	if asyncInitial {
111		a.LoadInitialShell(ctx)
112	} else if err := a.LoadInitial(ctx); err != nil {
113		return err
114	}
115
116	if err := a.deps.DOM.Mount(a.renderedHTML); err != nil {
117		return err
118	}
119	if binder, ok := a.deps.DOM.(DOMActionBinder); ok {
120		if err := a.bindDOMActions(ctx, binder); err != nil {
121			return err
122		}
123	}
124	if asyncInitial {
125		a.refreshCurrentRouteAsync(ctx)
126	}
127	return nil
128}
129
130func (a *App) shouldLoadInitialRouteAsync() bool {
131	return a != nil && a.currentRoute == RouteHistory && a.deps.History != nil
132}
133
134func (a *App) refreshCurrentRouteAsync(ctx context.Context) {
135	if a == nil {
136		return
137	}
138	a.lockState()
139	snapshot := a.currentHistoryRouteSnapshotLocked()
140	generation := a.renderGeneration
141	closed := a.closed
142	a.unlockState()
143	if closed || snapshot.route != RouteHistory {
144		return
145	}
146
147	a.lockAction()
148	asyncCtx := a.actionCtx
149	if asyncCtx == nil {
150		asyncCtx = ctx
151	}
152	ready := !a.actionClosed && asyncCtx != nil && asyncCtx.Err() == nil
153	if ready {
154		a.actionWG.Add(1)
155	}
156	a.unlockAction()
157	if !ready {
158		return
159	}
160
161	go func() {
162		defer a.actionWG.Done()
163		if asyncCtx.Err() != nil {
164			return
165		}
166		result, err := a.renderHistoryRouteSnapshot(asyncCtx, snapshot)
167
168		a.lockState()
169		if a.closed || asyncCtx.Err() != nil || a.currentRoute != snapshot.route || a.renderGeneration != generation {
170			a.unlockState()
171			return
172		}
173		if err != nil {
174			a.renderRouteError(err)
175		} else {
176			a.commitHistoryRouteResultLocked(result)
177		}
178		a.renderGeneration++
179		mountGeneration := a.renderGeneration
180		html := a.renderedHTML
181		a.unlockState()
182
183		if err := a.mountHTMLIfCurrent(asyncCtx, html, mountGeneration); err != nil {
184			logActionMountError(asyncCtx, err, nil)
185		}
186	}()
187}
188
189func (a *App) bindDOMActions(ctx context.Context, binder DOMActionBinder) error {
190	var (
191		created    bool
192		workerCtx  context.Context
193		cancel     context.CancelFunc
194		queue      chan DOMAction
195		errorQueue chan error
196	)
197
198	a.lockAction()
199	if a.actionClosed {
200		a.unlockAction()
201		return errors.New("systemview app is closed")
202	}
203	if a.actionQueue == nil {
204		workerCtx, cancel = context.WithCancel(ctx)
205		queue = make(chan DOMAction, 64)
206		errorQueue = make(chan error, 1)
207		a.actionQueue = queue
208		a.actionErrorQueue = errorQueue
209		a.actionCtx = workerCtx
210		a.actionCancel = cancel
211		created = true
212	}
213	a.unlockAction()
214
215	if err := binder.BindActions(func(action DOMAction) {
216		if !a.enqueueDOMAction(action) && a.actionWorkerActive() {
217			a.enqueueActionError(fmt.Errorf("systemview is busy; dropped action %q", action.Action))
218		}
219	}); err != nil {
220		if created {
221			a.discardPendingActionWorker(workerCtx, cancel, queue, errorQueue)
222		}
223		return err
224	}
225
226	if created && !a.startActionWorkers(workerCtx, queue, errorQueue) {
227		a.discardPendingActionWorker(workerCtx, cancel, queue, errorQueue)
228		a.releaseDOMBindings()
229		return errors.New("systemview action worker unavailable")
230	}
231	return nil
232}
233
234func (a *App) startActionWorkers(ctx context.Context, queue chan DOMAction, errorQueue chan error) bool {
235	if a == nil || ctx == nil || queue == nil || errorQueue == nil {
236		return false
237	}
238	a.lockAction()
239	ready := !a.actionClosed && a.actionCtx == ctx && a.actionQueue == queue && a.actionErrorQueue == errorQueue && ctx.Err() == nil
240	if ready {
241		a.actionWG.Add(2)
242	}
243	a.unlockAction()
244	if !ready {
245		return false
246	}
247	go func() {
248		defer a.actionWG.Done()
249		a.runActionWorker(ctx, queue)
250	}()
251	go func() {
252		defer a.actionWG.Done()
253		a.runActionErrorWorker(ctx, errorQueue)
254	}()
255	return true
256}
257
258func (a *App) discardPendingActionWorker(ctx context.Context, cancel context.CancelFunc, queue chan DOMAction, errorQueue chan error) {
259	if a == nil {
260		return
261	}
262	a.lockAction()
263	if a.actionCtx == ctx && a.actionQueue == queue {
264		a.actionCancel = nil
265		a.actionCtx = nil
266		a.actionQueue = nil
267		a.actionErrorQueue = nil
268		if cancel != nil {
269			cancel()
270		}
271		close(queue)
272		close(errorQueue)
273	}
274	a.unlockAction()
275}
276
277func (a *App) actionWorkerActive() bool {
278	if a == nil {
279		return false
280	}
281	a.lockAction()
282	closed := a.actionClosed
283	queue := a.actionQueue
284	ctx := a.actionCtx
285	a.unlockAction()
286	return !closed && queue != nil && ctx != nil && ctx.Err() == nil
287}
288
289func (a *App) enqueueDOMAction(action DOMAction) bool {
290	if a == nil {
291		return false
292	}
293	a.lockAction()
294	defer a.unlockAction()
295	queue := a.actionQueue
296	ctx := a.actionCtx
297	if a.actionClosed || queue == nil || ctx == nil {
298		return false
299	}
300	select {
301	case <-ctx.Done():
302		return false
303	case queue <- action:
304		return true
305	default:
306		return false
307	}
308}
309
310func (a *App) enqueueActionError(err error) {
311	if a == nil || err == nil {
312		return
313	}
314	a.lockAction()
315	defer a.unlockAction()
316	queue := a.actionErrorQueue
317	ctx := a.actionCtx
318	if a.actionClosed || queue == nil || ctx == nil {
319		return
320	}
321	select {
322	case <-ctx.Done():
323		return
324	case queue <- err:
325	default:
326	}
327}
328
329func (a *App) runActionWorker(ctx context.Context, queue <-chan DOMAction) {
330	for {
331		select {
332		case <-ctx.Done():
333			return
334		case action, ok := <-queue:
335			if !ok || ctx.Err() != nil {
336				return
337			}
338			if err := a.HandleDOMAction(ctx, action); err != nil {
339				a.enqueueActionError(err)
340			}
341		}
342	}
343}
344
345func (a *App) runActionErrorWorker(ctx context.Context, queue <-chan error) {
346	for {
347		select {
348		case <-ctx.Done():
349			return
350		case err, ok := <-queue:
351			if !ok || ctx.Err() != nil {
352				return
353			}
354			a.surfaceActionError(ctx, err)
355		}
356	}
357}
358
359func (a *App) surfaceActionError(ctx context.Context, err error) {
360	if a == nil || err == nil {
361		return
362	}
363	a.lockState()
364	a.renderRouteError(err)
365	a.renderGeneration++
366	generation := a.renderGeneration
367	html := a.renderedHTML
368	a.unlockState()
369	if mountErr := a.mountHTMLIfCurrent(ctx, html, generation); mountErr != nil {
370		logActionMountError(ctx, mountErr, err)
371	}
372}
373
374func (a *App) Close() {
375	if a == nil {
376		return
377	}
378	a.lockState()
379	a.closed = true
380	a.renderGeneration++
381	a.unlockState()
382
383	a.lockAction()
384	cancel := a.actionCancel
385	actionQueue := a.actionQueue
386	actionErrorQueue := a.actionErrorQueue
387	a.actionClosed = true
388	a.actionCancel = nil
389	a.actionCtx = nil
390	a.actionQueue = nil
391	a.actionErrorQueue = nil
392	a.unlockAction()
393
394	if cancel != nil {
395		cancel()
396	}
397	a.actionWG.Wait()
398	if actionQueue != nil {
399		close(actionQueue)
400	}
401	if actionErrorQueue != nil {
402		close(actionErrorQueue)
403	}
404	a.releaseDOMBindings()
405}
406
407func (a *App) releaseDOMBindings() {
408	if a == nil {
409		return
410	}
411	if releaser, ok := a.deps.DOM.(interface{ Release() }); ok {
412		releaser.Release()
413	}
414}
415
416func (a *App) LoadInitialShell(ctx context.Context) {
417	if a == nil {
418		return
419	}
420	a.loadShellTheme(ctx)
421	if a.currentRoute == "" || a.currentRoute == RouteUnknown {
422		a.currentRoute = ParseRoute(a.deps.LocationURI)
423	}
424	switch a.currentRoute {
425	case RouteHistory:
426		a.loadHistoryLoadingRoute()
427	default:
428		a.resetRouteState()
429		a.renderedHTML = renderAppFrame(renderedPage{
430			route:    a.currentRoute,
431			title:    routeDocumentTitle(a.currentRoute),
432			subtitle: routeSubtitle(a.currentRoute),
433			body:     placeholderHTML(a.currentRoute),
434		}, a.shellTheme)
435	}
436}
437
438func (a *App) LoadInitial(ctx context.Context) error {
439	if a == nil {
440		return errors.New("app is nil")
441	}
442	a.loadShellTheme(ctx)
443	if a.currentRoute == "" || a.currentRoute == RouteUnknown {
444		a.currentRoute = ParseRoute(a.deps.LocationURI)
445	}
446
447	if err := a.loadCurrentRoute(ctx); err != nil {
448		a.renderRouteError(err)
449	}
450	return nil
451}
452
453func (a *App) loadCurrentRoute(ctx context.Context) error {
454	switch a.currentRoute {
455	case RouteHistory:
456		return a.loadHistoryRoute(ctx)
457	case RouteFavorites:
458		return a.loadFavoritesRoute(ctx)
459	case RouteConfig:
460		return a.loadConfigRoute(ctx)
461	default:
462		a.resetRouteState()
463		a.renderedHTML = renderAppFrame(renderedPage{
464			route:    a.currentRoute,
465			title:    routeDocumentTitle(a.currentRoute),
466			subtitle: string(a.currentRoute),
467			body:     placeholderHTML(a.currentRoute),
468		}, a.shellTheme)
469		return nil
470	}
471}
472
473func (a *App) renderRouteError(err error) {
474	a.resetRouteState()
475	message := "unknown error"
476	if err != nil {
477		message = err.Error()
478	}
479	a.renderedHTML = renderAppFrame(renderedPage{
480		route:    a.currentRoute,
481		title:    routeDocumentTitle(a.currentRoute) + " — Error",
482		subtitle: routeSubtitle(a.currentRoute),
483		body:     errorStateHTML(message),
484	}, a.shellTheme)
485}
486
487func routeDocumentTitle(route Route) string {
488	switch route {
489	case RouteHistory:
490		return "History"
491	case RouteFavorites:
492		return "Favorites"
493	case RouteConfig:
494		return "Config"
495	default:
496		return "Dumber System View"
497	}
498}
499
500func routeSubtitle(route Route) string {
501	switch route {
502	case RouteHistory:
503		return "Recent visits"
504	case RouteFavorites:
505		return "Saved bookmarks"
506	case RouteConfig:
507		return "Browser settings"
508	default:
509		return string(route)
510	}
511}
512
513func (a *App) loadHistoryLoadingRoute() {
514	a.favorites = nil
515	a.folders = nil
516	a.tags = nil
517	a.historyEntries = nil
518	a.historyStats = nil
519	a.historyAnalytics = nil
520	a.historyDomainStats = nil
521	a.historyNotice = ""
522	a.historyError = ""
523	data := historyRenderData{
524		Query:        a.historyQuery,
525		DomainFilter: a.historyDomainFilter,
526		Offset:       a.historyOffset,
527		Limit:        historyTimelineLimit,
528		WindowBefore: a.historyWindowBefore,
529		WindowAfter:  a.historyWindowAfter,
530		HasMore:      a.historyHasMore,
531		Notice:       a.historyNotice,
532		Error:        a.historyError,
533		Loading:      true,
534	}
535	title := historyDocumentTitle(data)
536	a.renderedHTML = renderAppFrame(renderedPage{
537		route:          RouteHistory,
538		title:          title,
539		subtitle:       "History",
540		subtitleDetail: historyTitleDetail(data),
541		body:           historyHTML(data),
542	}, a.shellTheme)
543}
544
545func (a *App) loadHistoryRoute(ctx context.Context) error {
546	if a.deps.History == nil {
547		a.resetRouteState()
548		a.renderedHTML = renderAppFrame(renderedPage{
549			route:    a.currentRoute,
550			title:    routeDocumentTitle(RouteHistory),
551			subtitle: routeDocumentTitle(RouteHistory),
552			body:     placeholderHTML(a.currentRoute),
553		}, a.shellTheme)
554		return nil
555	}
556
557	result, err := a.renderHistoryRouteSnapshot(ctx, a.currentHistoryRouteSnapshotLocked())
558	if err != nil {
559		return err
560	}
561	a.commitHistoryRouteResultLocked(result)
562	return nil
563}
564
565type historyRouteSnapshot struct {
566	route        Route
567	history      port.SystemviewHistoryService
568	shellTheme   shellTheme
569	query        string
570	domainFilter string
571	offset       int
572	windowBefore time.Time
573	windowAfter  time.Time
574	notice       string
575	error        string
576}
577
578type historyRouteResult struct {
579	entries      []*entity.HistoryEntry
580	stats        *entity.HistoryStats
581	domains      []*entity.DomainStat
582	windowBefore time.Time
583	windowAfter  time.Time
584	hasMore      bool
585	html         string
586}
587
588func (a *App) currentHistoryRouteSnapshotLocked() historyRouteSnapshot {
589	return historyRouteSnapshot{
590		route:        a.currentRoute,
591		history:      a.deps.History,
592		shellTheme:   a.shellTheme,
593		query:        a.historyQuery,
594		domainFilter: a.historyDomainFilter,
595		offset:       a.historyOffset,
596		windowBefore: a.historyWindowBefore,
597		windowAfter:  a.historyWindowAfter,
598		notice:       a.historyNotice,
599		error:        a.historyError,
600	}
601}
602
603func (a *App) renderHistoryRouteSnapshot(ctx context.Context, snapshot historyRouteSnapshot) (historyRouteResult, error) {
604	if snapshot.history == nil {
605		return historyRouteResult{
606			html: renderAppFrame(renderedPage{
607				route:    snapshot.route,
608				title:    routeDocumentTitle(RouteHistory),
609				subtitle: routeDocumentTitle(RouteHistory),
610				body:     placeholderHTML(snapshot.route),
611			}, snapshot.shellTheme),
612		}, nil
613	}
614
615	window, entries, err := loadHistoryWindowSnapshot(ctx, snapshot)
616	if err != nil {
617		return historyRouteResult{}, err
618	}
619	stats, err := snapshot.history.Stats(ctx)
620	if err != nil {
621		return historyRouteResult{}, fmt.Errorf("failed to load history stats: %w", err)
622	}
623	domains, domainsErr := snapshot.history.DomainStats(ctx, 10)
624	if domainsErr != nil {
625		domains = nil
626	}
627	result := historyRouteResult{
628		entries:      entries,
629		stats:        stats,
630		domains:      domains,
631		windowBefore: snapshot.windowBefore,
632		windowAfter:  snapshot.windowAfter,
633	}
634	if window != nil {
635		result.windowBefore = window.Before
636		result.windowAfter = window.After
637		result.hasMore = window.HasMore
638	}
639	data := historyRenderData{
640		Entries:      entries,
641		Stats:        stats,
642		Analytics:    nil,
643		Domains:      domains,
644		Query:        snapshot.query,
645		DomainFilter: snapshot.domainFilter,
646		Offset:       snapshot.offset,
647		Limit:        historyTimelineLimit,
648		WindowBefore: result.windowBefore,
649		WindowAfter:  result.windowAfter,
650		HasMore:      result.hasMore,
651		Notice:       snapshot.notice,
652		Error:        snapshot.error,
653	}
654	title := historyDocumentTitle(data)
655	result.html = renderAppFrame(renderedPage{
656		route:          RouteHistory,
657		title:          title,
658		subtitle:       "History",
659		subtitleDetail: historyTitleDetail(data),
660		body:           historyHTML(data),
661	}, snapshot.shellTheme)
662	return result, nil
663}
664
665func loadHistoryWindowSnapshot(ctx context.Context, snapshot historyRouteSnapshot) (*entity.HistoryWindow, []*entity.HistoryEntry, error) {
666	query := strings.TrimSpace(snapshot.query)
667	domain := strings.TrimSpace(snapshot.domainFilter)
668	if query != "" {
669		entries, err := snapshot.history.Search(ctx, query, historySearchLimit)
670		return nil, entries, err
671	}
672	window, err := snapshot.history.TimelineWindow(ctx, snapshot.windowBefore, domain)
673	if err != nil {
674		return nil, nil, err
675	}
676	if window == nil {
677		return nil, nil, nil
678	}
679	return window, window.Entries, nil
680}
681
682func (a *App) commitHistoryRouteResultLocked(result historyRouteResult) {
683	a.favorites = nil
684	a.folders = nil
685	a.tags = nil
686	a.historyEntries = result.entries
687	a.historyStats = result.stats
688	a.historyAnalytics = nil
689	a.historyDomainStats = result.domains
690	a.historyWindowBefore = result.windowBefore
691	a.historyWindowAfter = result.windowAfter
692	a.historyHasMore = result.hasMore
693	a.renderedHTML = result.html
694}
695
696func (a *App) loadFavoritesRoute(ctx context.Context) error {
697	if a.deps.Favorites == nil {
698		a.resetRouteState()
699		a.renderedHTML = renderAppFrame(renderedPage{
700			route:    a.currentRoute,
701			title:    routeDocumentTitle(RouteFavorites),
702			subtitle: "Saved bookmarks",
703			body:     placeholderHTML(a.currentRoute),
704		}, a.shellTheme)
705		return nil
706	}
707
708	favorites, err := a.deps.Favorites.List(ctx)
709	if err != nil {
710		return err
711	}
712	folders, err := a.deps.Favorites.ListFolders(ctx)
713	if err != nil {
714		return err
715	}
716	tags, err := a.deps.Favorites.ListTags(ctx)
717	if err != nil {
718		return err
719	}
720
721	a.historyEntries = nil
722	favorites = filterFavorites(favorites, a.favoriteFolderFilter, a.favoriteTagFilter)
723	a.favorites = favorites
724	a.folders = folders
725	a.tags = tags
726	data := favoritesRenderData{
727		Favorites:    favorites,
728		Folders:      folders,
729		Tags:         tags,
730		FolderFilter: a.favoriteFolderFilter,
731		TagFilter:    a.favoriteTagFilter,
732		Notice:       a.favoritesNotice,
733		Error:        a.favoritesError,
734	}
735	a.renderedHTML = renderAppFrame(renderedPage{
736		route:    RouteFavorites,
737		title:    favoritesDocumentTitle(data),
738		subtitle: "Saved bookmarks",
739		body:     favoritesHTML(data),
740	}, a.shellTheme)
741	return nil
742}
743
744func (a *App) loadConfigRoute(ctx context.Context) error {
745	if a.deps.Config == nil {
746		a.resetRouteState()
747		a.renderedHTML = renderAppFrame(renderedPage{
748			route:    a.currentRoute,
749			title:    routeDocumentTitle(RouteConfig),
750			subtitle: "Browser settings",
751			body:     placeholderHTML(a.currentRoute),
752		}, a.shellTheme)
753		return nil
754	}
755
756	config, err := a.deps.Config.Current(ctx)
757	if err != nil {
758		return err
759	}
760	keybindings, err := a.deps.Config.GetKeybindings(ctx)
761	if err != nil {
762		return err
763	}
764
765	a.historyEntries = nil
766	a.favorites = nil
767	a.folders = nil
768	a.tags = nil
769	a.config = &config
770	a.keybindings = keybindings
771	a.renderedHTML = renderAppFrame(renderedPage{
772		route:    RouteConfig,
773		title:    "Config — Dumber",
774		subtitle: "Browser settings",
775		body: configHTML(configRenderData{
776			Config:      config,
777			Keybindings: keybindings,
778			Notice:      a.configNotice,
779			Error:       a.configError,
780		}),
781	}, a.shellTheme)
782	return nil
783}
784
785func (a *App) loadShellTheme(ctx context.Context) {
786	if a == nil {
787		return
788	}
789
790	a.shellTheme = shellTheme{}
791	if a.deps.Config == nil {
792		return
793	}
794
795	config, err := a.deps.Config.Current(ctx)
796	if err == nil {
797		a.shellTheme = resolveShellTheme(config.Appearance)
798	}
799}
800
801func (a *App) resetRouteState() {
802	a.historyEntries = nil
803	a.historyStats = nil
804	a.historyAnalytics = nil
805	a.historyDomainStats = nil
806	a.historyQuery = ""
807	a.historyDomainFilter = ""
808	a.historyOffset = 0
809	a.historyWindowBefore = time.Time{}
810	a.historyWindowAfter = time.Time{}
811	a.historyHasMore = false
812	a.historyNotice = ""
813	a.historyError = ""
814	a.favorites = nil
815	a.folders = nil
816	a.tags = nil
817	a.favoriteFolderFilter = nil
818	a.favoriteTagFilter = nil
819	a.favoritesNotice = ""
820	a.favoritesError = ""
821	a.config = nil
822	a.keybindings = port.KeybindingsConfig{}
823	a.configNotice = ""
824	a.configError = ""
825}
826
827func (a *App) CurrentRoute() Route {
828	if a == nil {
829		return RouteUnknown
830	}
831	return a.currentRoute
832}

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