v0.31.0 bf0756b

history_recorder.go RawHistoryFolder

1package usecase
2
3import (
4	"context"
5	"errors"
6	"net"
7	"net/url"
8	"strings"
9	"sync"
10	"time"
11
12	"github.com/bnema/dumber/internal/application/dto"
13	"github.com/bnema/dumber/internal/application/port"
14	"github.com/bnema/dumber/internal/domain/entity"
15	"github.com/bnema/dumber/internal/domain/repository"
16	"github.com/bnema/dumber/internal/logging"
17	"github.com/rs/zerolog"
18)
19
20const (
21	// historyQueueSize is the buffer size for the async history queue.
22	// If the queue is full, new records are dropped with a warning.
23	historyQueueSize = 100
24
25	// historyWorkerFlushInterval coalesces bursts into fewer persistence writes.
26	historyWorkerFlushInterval = 100 * time.Millisecond
27
28	// historyWorkerShutdownFlushTimeout bounds final persistence attempts during shutdown.
29	historyWorkerShutdownFlushTimeout = 2 * time.Second
30
31	// historyWorkerShutdownWaitTimeout bounds Close even if a repository call ignores context cancellation.
32	historyWorkerShutdownWaitTimeout = historyWorkerShutdownFlushTimeout + 500*time.Millisecond
33)
34
35// historyDeduplicationWindow is the time window for deduplicating history visits.
36// Visits to the same URL within this window count as a single visit.
37// This prevents inflation from redirects and rapid navigation.
38const historyDeduplicationWindow = 2 * time.Second
39
40// stripTrackingParamsForHistoryDedup controls whether known tracking parameters
41// are removed while canonicalizing URLs for history deduplication/storage.
42const stripTrackingParamsForHistoryDedup = true
43
44// historyRecord holds data for async history recording.
45type historyRecord struct {
46	url    string
47	title  string // non-empty for title-update-only records
48	visits int
49}
50
51type historyVisitDeltaIncrementer interface {
52	IncrementVisitCountBy(ctx context.Context, url string, delta int) error
53}
54
55type historyMetadataUpdater interface {
56	UpdateMetadata(ctx context.Context, entry *entity.HistoryEntry) error
57}
58
59type paneHistoryState struct {
60	lastRawURL       string
61	lastCanonicalURL string
62	lastRecordedAt   time.Time
63}
64
65type historyCanonicalizationOptions struct {
66	StripTrackingParams bool
67}
68
69type historyControlRequest struct {
70	kind historyControlKind
71	ctx  context.Context
72	done chan error
73}
74
75type historyControlKind int
76
77type titlePersistenceResult int
78
79const historyControlFlushAndReset historyControlKind = iota
80
81const (
82	titlePersistenceRetry titlePersistenceResult = iota
83	titlePersistenceSaved
84	titlePersistenceDiscard
85)
86
87var (
88	errHistoryRecorderClosing       = errors.New("history recorder closing")
89	errHistoryPersistenceIncomplete = errors.New("history persistence incomplete")
90)
91
92type pendingHistoryRecords struct {
93	visits map[string]int
94	titles map[string]string
95}
96
97func newPendingHistoryRecords() *pendingHistoryRecords {
98	return &pendingHistoryRecords{
99		visits: make(map[string]int),
100		titles: make(map[string]string),
101	}
102}
103
104func (p *pendingHistoryRecords) add(record historyRecord) {
105	if record.title != "" {
106		p.titles[record.url] = record.title
107	}
108	if record.visits > 0 {
109		p.visits[record.url] += record.visits
110	}
111}
112
113func (p *pendingHistoryRecords) empty() bool {
114	return len(p.visits) == 0 && len(p.titles) == 0
115}
116
117func (p *pendingHistoryRecords) reset() {
118	p.visits = make(map[string]int)
119	p.titles = make(map[string]string)
120}
121
122// HistoryRecorderUseCase records persisted history visits and title updates asynchronously.
123type HistoryRecorderUseCase struct {
124	historyRepo  repository.HistoryRepository
125	changeSinkMu sync.RWMutex
126	changeSink   port.HistoryChangeSink
127	recentMu     sync.Mutex
128	recentVisits map[string]paneHistoryState // key: pane ID, value: per-pane history state
129	mutationMu   sync.Mutex
130	closeOnce    sync.Once
131	enqueueMu    sync.Mutex
132	closing      bool
133
134	historyQueue chan historyRecord
135	controlQueue chan historyControlRequest
136	done         chan struct{}
137	wg           sync.WaitGroup
138	ctx          context.Context // Base context for background worker
139	cancel       context.CancelFunc
140}
141
142// NewHistoryRecorderUseCase creates a new history recorder use case.
143func NewHistoryRecorderUseCase(historyRepo repository.HistoryRepository, changeSink port.HistoryChangeSink) *HistoryRecorderUseCase {
144	changeSink = normalizeHistoryChangeSink(changeSink)
145	ctx, cancel := context.WithCancel(context.Background())
146
147	uc := &HistoryRecorderUseCase{
148		historyRepo:  historyRepo,
149		changeSink:   changeSink,
150		recentVisits: make(map[string]paneHistoryState),
151		historyQueue: make(chan historyRecord, historyQueueSize),
152		controlQueue: make(chan historyControlRequest),
153		done:         make(chan struct{}),
154		ctx:          ctx,
155		cancel:       cancel,
156	}
157
158	uc.wg.Add(1)
159	go uc.historyWorker()
160
161	return uc
162}
163
164// SetHistoryChangeSink sets the sink for persisted history change notifications.
165func (uc *HistoryRecorderUseCase) SetHistoryChangeSink(changeSink port.HistoryChangeSink) {
166	if uc == nil {
167		return
168	}
169	changeSink = normalizeHistoryChangeSink(changeSink)
170	uc.changeSinkMu.Lock()
171	uc.changeSink = changeSink
172	uc.changeSinkMu.Unlock()
173}
174
175// Close shuts down the background history worker and drains any pending records.
176func (uc *HistoryRecorderUseCase) Close() {
177	if uc == nil {
178		return
179	}
180	uc.closeOnce.Do(func() {
181		uc.enqueueMu.Lock()
182		uc.closing = true
183		uc.enqueueMu.Unlock()
184		uc.cancelWorkerContext()
185		if uc.done != nil {
186			close(uc.done)
187		}
188	})
189	if uc.waitForHistoryWorker(historyWorkerShutdownWaitTimeout) {
190		return
191	}
192	logging.FromContext(uc.workerContext()).Warn().
193		Dur("timeout", historyWorkerShutdownWaitTimeout).
194		Msg("history worker shutdown timed out")
195}
196
197func (uc *HistoryRecorderUseCase) workerContext() context.Context {
198	if uc == nil || uc.ctx == nil {
199		return context.Background()
200	}
201	return uc.ctx
202}
203
204func (uc *HistoryRecorderUseCase) cancelWorkerContext() {
205	if uc != nil && uc.cancel != nil {
206		uc.cancel()
207	}
208}
209
210func (uc *HistoryRecorderUseCase) waitForHistoryWorker(timeout time.Duration) bool {
211	done := make(chan struct{})
212	go func() {
213		uc.wg.Wait()
214		close(done)
215	}()
216	if timeout <= 0 {
217		<-done
218		return true
219	}
220	timer := time.NewTimer(timeout)
221	defer timer.Stop()
222	select {
223	case <-done:
224		return true
225	case <-timer.C:
226		return false
227	}
228}
229
230// RecordHistory queues a history entry for async recording.
231// This is non-blocking to avoid SQLite I/O on the GTK main thread.
232// Should be called on LoadCommitted when URI is guaranteed correct.
233func (uc *HistoryRecorderUseCase) RecordHistory(ctx context.Context, paneID, rawURL string) {
234	if uc == nil {
235		return
236	}
237	log := logging.FromContext(ctx)
238	rawURL = strings.TrimSpace(rawURL)
239	if rawURL == "" {
240		return
241	}
242
243	if !isRecordableHistoryURL(rawURL) {
244		return
245	}
246
247	canonicalURL := canonicalizeURLForHistory(rawURL, historyCanonicalizationOptions{
248		StripTrackingParams: stripTrackingParamsForHistoryDedup,
249	})
250	if canonicalURL == "" {
251		return
252	}
253
254	paneID = normalizePaneID(paneID)
255
256	now := time.Now()
257
258	uc.mutationMu.Lock()
259	defer uc.mutationMu.Unlock()
260
261	uc.recentMu.Lock()
262	state := uc.recentVisits[paneID]
263
264	// Ignore in-page hash transitions for history persistence.
265	if isHashOnlyTransition(state.lastRawURL, rawURL) {
266		state.lastRawURL = rawURL
267		uc.recentVisits[paneID] = state
268		uc.recentMu.Unlock()
269		return
270	}
271
272	// Per-pane short-window deduplication by canonical URL.
273	if state.lastCanonicalURL == canonicalURL && now.Sub(state.lastRecordedAt) < historyDeduplicationWindow {
274		state.lastRawURL = rawURL
275		uc.recentVisits[paneID] = state
276		uc.recentMu.Unlock()
277		return
278	}
279
280	state.lastRawURL = rawURL
281	state.lastCanonicalURL = canonicalURL
282	state.lastRecordedAt = now
283	enqueued := uc.enqueueHistoryRecord(historyRecord{url: canonicalURL, visits: 1})
284	if enqueued {
285		uc.recentVisits[paneID] = state
286	}
287	uc.recentMu.Unlock()
288
289	if !enqueued {
290		log.Warn().Str("url", logging.RedactURL(canonicalURL)).Msg("history queue full or recorder closed, dropping record")
291	}
292}
293
294// ClearPaneHistory removes per-pane deduplication state to prevent unbounded growth.
295// Callers should invoke this when a pane is closed.
296func (uc *HistoryRecorderUseCase) ClearPaneHistory(paneID string) {
297	if uc == nil {
298		return
299	}
300	paneID = normalizePaneID(paneID)
301	uc.mutationMu.Lock()
302	defer uc.mutationMu.Unlock()
303	uc.recentMu.Lock()
304	delete(uc.recentVisits, paneID)
305	uc.recentMu.Unlock()
306}
307
308// UpdateHistoryTitle queues a title update for a history entry. The actual
309// DB write happens in the background historyWorker to avoid concurrent
310// repo access from the GTK main thread and the worker goroutine.
311func (uc *HistoryRecorderUseCase) UpdateHistoryTitle(_ context.Context, historyURL, title string) {
312	if uc == nil {
313		return
314	}
315	historyURL = strings.TrimSpace(historyURL)
316	if historyURL == "" || !isRecordableHistoryURL(historyURL) {
317		return
318	}
319	historyURL = canonicalizeURLForHistory(historyURL, historyCanonicalizationOptions{
320		StripTrackingParams: stripTrackingParamsForHistoryDedup,
321	})
322	if historyURL == "" {
323		return
324	}
325
326	uc.mutationMu.Lock()
327	defer uc.mutationMu.Unlock()
328
329	if !uc.enqueueHistoryRecord(historyRecord{url: historyURL, title: title}) {
330		logging.FromContext(uc.workerContext()).Warn().
331			Str("url", logging.RedactURL(historyURL)).
332			Msg("history queue full or recorder closed, title update dropped")
333	}
334}
335
336func (uc *HistoryRecorderUseCase) enqueueHistoryRecord(record historyRecord) bool {
337	uc.enqueueMu.Lock()
338	defer uc.enqueueMu.Unlock()
339	if uc.closing {
340		return false
341	}
342	select {
343	case uc.historyQueue <- record:
344		return true
345	default:
346		return false
347	}
348}
349
350// historyWorker is a background goroutine that processes history records.
351// It drains the queue and persists records to the database without blocking the UI.
352func (uc *HistoryRecorderUseCase) historyWorker() {
353	defer uc.wg.Done()
354
355	ticker := time.NewTicker(historyWorkerFlushInterval)
356	defer ticker.Stop()
357	workerCtx := uc.workerContext()
358
359	pending := newPendingHistoryRecords()
360	for {
361		select {
362		case record := <-uc.historyQueue:
363			pending.add(record)
364		case <-ticker.C:
365			_ = uc.flushPendingHistory(workerCtx, pending)
366		case req := <-uc.controlQueue:
367			uc.handleHistoryControl(pending, req)
368		case <-uc.done:
369			uc.shutdownHistoryWorker(pending)
370			return
371		case <-workerCtx.Done():
372			uc.shutdownHistoryWorker(pending)
373			return
374		}
375	}
376}
377
378func (uc *HistoryRecorderUseCase) flushPendingHistory(ctx context.Context, pending *pendingHistoryRecords) error {
379	if ctx == nil {
380		ctx = uc.workerContext()
381	}
382	if pending.empty() {
383		return nil
384	}
385
386	visitCount := len(pending.visits)
387	titleCount := len(pending.titles)
388	logging.FromContext(ctx).Debug().
389		Int("visits", visitCount).
390		Int("titles", titleCount).
391		Msg("flushing pending history")
392
393	var flushErr error
394	successfulVisits := 0
395	for historyURL, visits := range pending.visits {
396		if err := ctx.Err(); err != nil {
397			uc.publishHistoryChange(successfulVisits, 0)
398			return err
399		}
400		record := historyRecord{url: historyURL, visits: visits}
401		writtenVisits := uc.persistHistory(ctx, record)
402		if writtenVisits > 0 {
403			successfulVisits += writtenVisits
404			if writtenVisits >= visits {
405				delete(pending.visits, historyURL)
406			} else {
407				pending.visits[historyURL] = visits - writtenVisits
408			}
409		}
410		if writtenVisits < visits {
411			flushErr = errHistoryPersistenceIncomplete
412		}
413	}
414
415	successfulTitles := 0
416	for historyURL, title := range pending.titles {
417		if err := ctx.Err(); err != nil {
418			uc.publishHistoryChange(successfulVisits, successfulTitles)
419			return err
420		}
421		if _, visitStillPending := pending.visits[historyURL]; visitStillPending {
422			flushErr = errHistoryPersistenceIncomplete
423			continue
424		}
425		switch uc.persistTitleUpdate(ctx, historyURL, title) {
426		case titlePersistenceSaved:
427			successfulTitles++
428			delete(pending.titles, historyURL)
429		case titlePersistenceDiscard:
430			delete(pending.titles, historyURL)
431		case titlePersistenceRetry:
432			flushErr = errHistoryPersistenceIncomplete
433		}
434	}
435	uc.publishHistoryChange(successfulVisits, successfulTitles)
436	if flushErr != nil {
437		return flushErr
438	}
439	return ctx.Err()
440}
441
442func (uc *HistoryRecorderUseCase) handleHistoryControl(pending *pendingHistoryRecords, req historyControlRequest) {
443	var err error
444	switch req.kind {
445	case historyControlFlushAndReset:
446		uc.drainHistoryQueue(pending)
447		err = uc.flushPendingHistory(req.ctx, pending)
448		if err == nil {
449			uc.resetRecentVisits()
450		}
451	}
452	req.done <- err
453	close(req.done)
454}
455
456func (uc *HistoryRecorderUseCase) drainHistoryQueue(pending *pendingHistoryRecords) {
457	for {
458		select {
459		case record := <-uc.historyQueue:
460			pending.add(record)
461		default:
462			return
463		}
464	}
465}
466
467func (uc *HistoryRecorderUseCase) shutdownHistoryWorker(pending *pendingHistoryRecords) {
468	log := logging.FromContext(uc.workerContext()).With().
469		Str("component", "history-worker").
470		Logger()
471	shutdownCtx, cancel := context.WithTimeout(context.Background(), historyWorkerShutdownFlushTimeout)
472	defer cancel()
473
474	for {
475		if err := shutdownCtx.Err(); err != nil {
476			if !pending.empty() {
477				log.Warn().Err(err).Msg("dropping unflushed history during shutdown after timeout")
478				pending.reset()
479			}
480			return
481		}
482		log.Debug().Int("remaining", len(uc.historyQueue)).Msg("draining history queue")
483		uc.drainHistoryQueue(pending)
484		uc.flushPendingHistoryForShutdown(shutdownCtx, log, pending)
485
486		uc.enqueueMu.Lock()
487		uc.drainHistoryQueue(pending)
488		if pending.empty() && len(uc.historyQueue) == 0 {
489			uc.closing = true
490			uc.enqueueMu.Unlock()
491			log.Debug().Msg("history worker shutdown complete")
492			return
493		}
494		uc.enqueueMu.Unlock()
495	}
496}
497
498func (uc *HistoryRecorderUseCase) flushPendingHistoryForShutdown(ctx context.Context, log zerolog.Logger, pending *pendingHistoryRecords) {
499	const shutdownFlushAttempts = 3
500	for attempt := 1; attempt <= shutdownFlushAttempts; attempt++ {
501		err := uc.flushPendingHistory(ctx, pending)
502		if err == nil || pending.empty() {
503			return
504		}
505		if attempt == shutdownFlushAttempts {
506			log.Warn().Err(err).Msg("dropping unflushed history during shutdown after retries")
507			pending.reset()
508			return
509		}
510		log.Warn().Err(err).Int("attempt", attempt).Msg("retrying unflushed history during shutdown")
511		select {
512		case <-ctx.Done():
513			log.Warn().Err(ctx.Err()).Msg("dropping unflushed history during shutdown after timeout")
514			pending.reset()
515			return
516		case <-time.After(historyWorkerFlushInterval):
517		}
518	}
519}
520
521// BeginHistoryMutation flushes pending recorder writes and blocks new recorder enqueues until release is called.
522func (uc *HistoryRecorderUseCase) BeginHistoryMutation(ctx context.Context) (func(), error) {
523	if uc == nil {
524		return func() {}, nil
525	}
526	if ctx == nil {
527		ctx = context.Background()
528	}
529	uc.mutationMu.Lock()
530	if err := uc.flushAndResetHistory(ctx); err != nil {
531		uc.mutationMu.Unlock()
532		if errors.Is(err, errHistoryRecorderClosing) {
533			uc.wg.Wait()
534			uc.resetRecentVisits()
535			return func() {}, nil
536		}
537		return nil, err
538	}
539	return func() { uc.mutationMu.Unlock() }, nil
540}
541
542func (uc *HistoryRecorderUseCase) flushAndResetHistory(ctx context.Context) error {
543	req := historyControlRequest{
544		kind: historyControlFlushAndReset,
545		ctx:  ctx,
546		done: make(chan error, 1),
547	}
548	select {
549	case uc.controlQueue <- req:
550		select {
551		case err := <-req.done:
552			return err
553		case <-uc.done:
554			return uc.waitForAcceptedFlushOrShutdown(ctx, req.done)
555		case <-ctx.Done():
556			return ctx.Err()
557		}
558	case <-uc.done:
559		return errHistoryRecorderClosing
560	case <-ctx.Done():
561		return ctx.Err()
562	}
563}
564
565func (uc *HistoryRecorderUseCase) waitForAcceptedFlushOrShutdown(ctx context.Context, reqDone <-chan error) error {
566	wgDone := make(chan struct{})
567	go func() {
568		uc.wg.Wait()
569		close(wgDone)
570	}()
571
572	select {
573	case err := <-reqDone:
574		return err
575	case <-wgDone:
576		uc.resetRecentVisits()
577		return errHistoryRecorderClosing
578	case <-ctx.Done():
579		return ctx.Err()
580	}
581}
582
583func (uc *HistoryRecorderUseCase) resetRecentVisits() {
584	uc.recentMu.Lock()
585	uc.recentVisits = make(map[string]paneHistoryState)
586	uc.recentMu.Unlock()
587}
588
589func (uc *HistoryRecorderUseCase) publishHistoryChange(successfulVisits, successfulTitles int) {
590	if successfulVisits == 0 && successfulTitles == 0 {
591		return
592	}
593	reasons := make([]dto.HistoryChangeReason, 0, 2)
594	if successfulVisits > 0 {
595		reasons = append(reasons, dto.HistoryChangeReasonVisit)
596	}
597	if successfulTitles > 0 {
598		reasons = append(reasons, dto.HistoryChangeReasonTitle)
599	}
600	sink := uc.historyChangeSink()
601	sink.OnHistoryChanged(uc.workerContext(), dto.HistoryChange{
602		Reasons:    reasons,
603		VisitCount: successfulVisits,
604		TitleCount: successfulTitles,
605		ChangedAt:  time.Now(),
606	})
607}
608
609func (uc *HistoryRecorderUseCase) historyChangeSink() port.HistoryChangeSink {
610	uc.changeSinkMu.RLock()
611	sink := uc.changeSink
612	uc.changeSinkMu.RUnlock()
613	return normalizeHistoryChangeSink(sink)
614}
615
616// persistHistory writes a history record to the database.
617// Called from the background worker goroutine.
618func (uc *HistoryRecorderUseCase) persistHistory(ctx context.Context, record historyRecord) int {
619	log := logging.FromContext(ctx)
620
621	existing, err := uc.historyRepo.FindByURL(ctx, record.url)
622	if err != nil {
623		log.Warn().Err(err).Str("url", logging.RedactURL(record.url)).Msg("failed to check history")
624		return 0
625	}
626
627	delta := max(1, record.visits)
628	if existing != nil {
629		if deltaWriter, ok := uc.historyRepo.(historyVisitDeltaIncrementer); ok {
630			if err := deltaWriter.IncrementVisitCountBy(ctx, record.url, delta); err != nil {
631				log.Warn().Err(err).Str("url", logging.RedactURL(record.url)).Int("delta", delta).Msg("failed to increment visit count by delta")
632				return 0
633			}
634			return delta
635		}
636		written := 0
637		for range delta {
638			if err := uc.historyRepo.IncrementVisitCount(ctx, record.url); err != nil {
639				log.Warn().Err(err).Str("url", logging.RedactURL(record.url)).Msg("failed to increment visit count")
640				return written
641			}
642			written++
643		}
644		return written
645	}
646
647	entry := entity.NewHistoryEntry(record.url, "")
648	entry.VisitCount = int64(delta)
649	if err := uc.historyRepo.Save(ctx, entry); err != nil {
650		log.Warn().Err(err).Str("url", logging.RedactURL(record.url)).Msg("failed to save history")
651		return 0
652	}
653	return delta
654}
655
656// persistTitleUpdate writes a title update to the database.
657// Called from the background worker goroutine.
658func (uc *HistoryRecorderUseCase) persistTitleUpdate(ctx context.Context, historyURL, title string) titlePersistenceResult {
659	log := logging.FromContext(ctx)
660
661	if title == "" {
662		return titlePersistenceDiscard
663	}
664
665	entry, err := uc.historyRepo.FindByURL(ctx, historyURL)
666	if err != nil {
667		log.Warn().Err(err).Str("url", logging.RedactURL(historyURL)).Msg("failed to find history entry for title update")
668		return titlePersistenceRetry
669	}
670	if entry == nil {
671		return titlePersistenceDiscard
672	}
673
674	entry.Title = title
675	if updater, ok := uc.historyRepo.(historyMetadataUpdater); ok {
676		if err := updater.UpdateMetadata(ctx, entry); err != nil {
677			log.Warn().Err(err).Str("url", logging.RedactURL(historyURL)).Msg("failed to update history metadata")
678			return titlePersistenceRetry
679		}
680		return titlePersistenceSaved
681	}
682	if err := uc.historyRepo.Save(ctx, entry); err != nil {
683		log.Warn().Err(err).Str("url", logging.RedactURL(historyURL)).Msg("failed to update history title")
684		return titlePersistenceRetry
685	}
686	return titlePersistenceSaved
687}
688
689func isRecordableHistoryURL(raw string) bool {
690	parsed, err := url.Parse(strings.TrimSpace(raw))
691	if err != nil {
692		return false
693	}
694	if strings.TrimSpace(parsed.Host) == "" {
695		return false
696	}
697	switch strings.ToLower(parsed.Scheme) {
698	case "http", "https":
699		return true
700	default:
701		return false
702	}
703}
704
705func canonicalizeURLForHistory(raw string, opts historyCanonicalizationOptions) string {
706	raw = strings.TrimSpace(raw)
707	if raw == "" {
708		return ""
709	}
710
711	parsed, err := url.Parse(raw)
712	if err != nil {
713		return strings.TrimSuffix(raw, "/")
714	}
715
716	parsed.Scheme = strings.ToLower(parsed.Scheme)
717	parsed.Host = canonicalHistoryHost(parsed)
718	parsed.User = nil
719	parsed.Fragment = ""
720	parsed.Path = normalizePathForHistory(parsed.Path)
721
722	query := parsed.Query()
723	if opts.StripTrackingParams {
724		for key := range query {
725			if isTrackingQueryParam(key) {
726				query.Del(key)
727			}
728		}
729	}
730	parsed.RawQuery = query.Encode()
731
732	return parsed.String()
733}
734
735func canonicalHistoryHost(parsed *url.URL) string {
736	host := strings.ToLower(parsed.Hostname())
737	portValue := parsed.Port()
738	if portValue == "" || (parsed.Scheme == "http" && portValue == "80") || (parsed.Scheme == "https" && portValue == "443") {
739		if strings.Contains(host, ":") {
740			return "[" + host + "]"
741		}
742		return host
743	}
744	return net.JoinHostPort(host, portValue)
745}
746
747func normalizePathForHistory(path string) string {
748	if path == "/" {
749		return ""
750	}
751	if before, ok := strings.CutSuffix(path, "/"); ok {
752		return before
753	}
754	return path
755}
756
757func isTrackingQueryParam(key string) bool {
758	key = strings.ToLower(strings.TrimSpace(key))
759	if key == "" {
760		return false
761	}
762	if strings.HasPrefix(key, "utm_") {
763		return true
764	}
765	switch key {
766	case "fbclid", "gclid", "msclkid", "dclid", "yclid", "mc_cid", "mc_eid", "_hsenc", "_hsmi", "igshid":
767		return true
768	}
769	return false
770}
771
772func isHashOnlyTransition(previous, current string) bool {
773	if previous == "" || current == "" || previous == current {
774		return false
775	}
776
777	prevParsed, prevErr := url.Parse(previous)
778	currParsed, currErr := url.Parse(current)
779	if prevErr != nil || currErr != nil {
780		return false
781	}
782
783	if prevParsed.Fragment == currParsed.Fragment {
784		return false
785	}
786
787	opts := historyCanonicalizationOptions{StripTrackingParams: stripTrackingParamsForHistoryDedup}
788	prevCanonical := canonicalizeURLForHistory(previous, opts)
789	currCanonical := canonicalizeURLForHistory(current, opts)
790	return prevCanonical != "" && prevCanonical == currCanonical
791}
792
793func normalizePaneID(paneID string) string {
794	if strings.TrimSpace(paneID) == "" {
795		return "__default__"
796	}
797	return paneID
798}

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