v0.33.1 9577fee

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	// queryCtx carries the worker's values (logging) but is detached from
359	// worker cancellation. Every database/sql query derives per-query child
360	// contexts from its parent; deriving those children from workerCtx while
361	// Close cancels workerCtx intermittently crashed in
362	// context.propagateCancel (concurrent map writes, #408). Shutdown
363	// coordination stays on uc.done/workerCtx.Done() in the select below,
364	// and the shutdown drain uses its own bounded timeout context.
365	queryCtx := context.WithoutCancel(workerCtx)
366
367	pending := newPendingHistoryRecords()
368	for {
369		select {
370		case record := <-uc.historyQueue:
371			pending.add(record)
372		case <-ticker.C:
373			_ = uc.flushPendingHistory(queryCtx, pending)
374		case req := <-uc.controlQueue:
375			uc.handleHistoryControl(pending, req)
376		case <-uc.done:
377			uc.shutdownHistoryWorker(pending)
378			return
379		case <-workerCtx.Done():
380			uc.shutdownHistoryWorker(pending)
381			return
382		}
383	}
384}
385
386func (uc *HistoryRecorderUseCase) flushPendingHistory(ctx context.Context, pending *pendingHistoryRecords) error {
387	if ctx == nil {
388		ctx = uc.workerContext()
389	}
390	if pending.empty() {
391		return nil
392	}
393
394	visitCount := len(pending.visits)
395	titleCount := len(pending.titles)
396	logging.FromContext(ctx).Debug().
397		Int("visits", visitCount).
398		Int("titles", titleCount).
399		Msg("flushing pending history")
400
401	var flushErr error
402	successfulVisits := 0
403	for historyURL, visits := range pending.visits {
404		if err := ctx.Err(); err != nil {
405			uc.publishHistoryChange(successfulVisits, 0)
406			return err
407		}
408		record := historyRecord{url: historyURL, visits: visits}
409		writtenVisits := uc.persistHistory(ctx, record)
410		if writtenVisits > 0 {
411			successfulVisits += writtenVisits
412			if writtenVisits >= visits {
413				delete(pending.visits, historyURL)
414			} else {
415				pending.visits[historyURL] = visits - writtenVisits
416			}
417		}
418		if writtenVisits < visits {
419			flushErr = errHistoryPersistenceIncomplete
420		}
421	}
422
423	successfulTitles := 0
424	for historyURL, title := range pending.titles {
425		if err := ctx.Err(); err != nil {
426			uc.publishHistoryChange(successfulVisits, successfulTitles)
427			return err
428		}
429		if _, visitStillPending := pending.visits[historyURL]; visitStillPending {
430			flushErr = errHistoryPersistenceIncomplete
431			continue
432		}
433		switch uc.persistTitleUpdate(ctx, historyURL, title) {
434		case titlePersistenceSaved:
435			successfulTitles++
436			delete(pending.titles, historyURL)
437		case titlePersistenceDiscard:
438			delete(pending.titles, historyURL)
439		case titlePersistenceRetry:
440			flushErr = errHistoryPersistenceIncomplete
441		}
442	}
443	uc.publishHistoryChange(successfulVisits, successfulTitles)
444	if flushErr != nil {
445		return flushErr
446	}
447	return ctx.Err()
448}
449
450func (uc *HistoryRecorderUseCase) handleHistoryControl(pending *pendingHistoryRecords, req historyControlRequest) {
451	var err error
452	switch req.kind {
453	case historyControlFlushAndReset:
454		uc.drainHistoryQueue(pending)
455		err = uc.flushPendingHistory(req.ctx, pending)
456		if err == nil {
457			uc.resetRecentVisits()
458		}
459	}
460	req.done <- err
461	close(req.done)
462}
463
464func (uc *HistoryRecorderUseCase) drainHistoryQueue(pending *pendingHistoryRecords) {
465	for {
466		select {
467		case record := <-uc.historyQueue:
468			pending.add(record)
469		default:
470			return
471		}
472	}
473}
474
475func (uc *HistoryRecorderUseCase) shutdownHistoryWorker(pending *pendingHistoryRecords) {
476	log := logging.FromContext(uc.workerContext()).With().
477		Str("component", "history-worker").
478		Logger()
479	shutdownCtx, cancel := context.WithTimeout(context.Background(), historyWorkerShutdownFlushTimeout)
480	defer cancel()
481
482	for {
483		if err := shutdownCtx.Err(); err != nil {
484			if !pending.empty() {
485				log.Warn().Err(err).Msg("dropping unflushed history during shutdown after timeout")
486				pending.reset()
487			}
488			return
489		}
490		log.Debug().Int("remaining", len(uc.historyQueue)).Msg("draining history queue")
491		uc.drainHistoryQueue(pending)
492		uc.flushPendingHistoryForShutdown(shutdownCtx, log, pending)
493
494		uc.enqueueMu.Lock()
495		uc.drainHistoryQueue(pending)
496		if pending.empty() && len(uc.historyQueue) == 0 {
497			uc.closing = true
498			uc.enqueueMu.Unlock()
499			log.Debug().Msg("history worker shutdown complete")
500			return
501		}
502		uc.enqueueMu.Unlock()
503	}
504}
505
506func (uc *HistoryRecorderUseCase) flushPendingHistoryForShutdown(ctx context.Context, log zerolog.Logger, pending *pendingHistoryRecords) {
507	const shutdownFlushAttempts = 3
508	for attempt := 1; attempt <= shutdownFlushAttempts; attempt++ {
509		err := uc.flushPendingHistory(ctx, pending)
510		if err == nil || pending.empty() {
511			return
512		}
513		if attempt == shutdownFlushAttempts {
514			log.Warn().Err(err).Msg("dropping unflushed history during shutdown after retries")
515			pending.reset()
516			return
517		}
518		log.Warn().Err(err).Int("attempt", attempt).Msg("retrying unflushed history during shutdown")
519		select {
520		case <-ctx.Done():
521			log.Warn().Err(ctx.Err()).Msg("dropping unflushed history during shutdown after timeout")
522			pending.reset()
523			return
524		case <-time.After(historyWorkerFlushInterval):
525		}
526	}
527}
528
529// BeginHistoryMutation flushes pending recorder writes and blocks new recorder enqueues until release is called.
530func (uc *HistoryRecorderUseCase) BeginHistoryMutation(ctx context.Context) (func(), error) {
531	if uc == nil {
532		return func() {}, nil
533	}
534	if ctx == nil {
535		ctx = context.Background()
536	}
537	uc.mutationMu.Lock()
538	if err := uc.flushAndResetHistory(ctx); err != nil {
539		uc.mutationMu.Unlock()
540		if errors.Is(err, errHistoryRecorderClosing) {
541			uc.wg.Wait()
542			uc.resetRecentVisits()
543			return func() {}, nil
544		}
545		return nil, err
546	}
547	return func() { uc.mutationMu.Unlock() }, nil
548}
549
550func (uc *HistoryRecorderUseCase) flushAndResetHistory(ctx context.Context) error {
551	req := historyControlRequest{
552		kind: historyControlFlushAndReset,
553		ctx:  ctx,
554		done: make(chan error, 1),
555	}
556	select {
557	case uc.controlQueue <- req:
558		select {
559		case err := <-req.done:
560			return err
561		case <-uc.done:
562			return uc.waitForAcceptedFlushOrShutdown(ctx, req.done)
563		case <-ctx.Done():
564			return ctx.Err()
565		}
566	case <-uc.done:
567		return errHistoryRecorderClosing
568	case <-ctx.Done():
569		return ctx.Err()
570	}
571}
572
573func (uc *HistoryRecorderUseCase) waitForAcceptedFlushOrShutdown(ctx context.Context, reqDone <-chan error) error {
574	wgDone := make(chan struct{})
575	go func() {
576		uc.wg.Wait()
577		close(wgDone)
578	}()
579
580	select {
581	case err := <-reqDone:
582		return err
583	case <-wgDone:
584		uc.resetRecentVisits()
585		return errHistoryRecorderClosing
586	case <-ctx.Done():
587		return ctx.Err()
588	}
589}
590
591func (uc *HistoryRecorderUseCase) resetRecentVisits() {
592	uc.recentMu.Lock()
593	uc.recentVisits = make(map[string]paneHistoryState)
594	uc.recentMu.Unlock()
595}
596
597func (uc *HistoryRecorderUseCase) publishHistoryChange(successfulVisits, successfulTitles int) {
598	if successfulVisits == 0 && successfulTitles == 0 {
599		return
600	}
601	reasons := make([]dto.HistoryChangeReason, 0, 2)
602	if successfulVisits > 0 {
603		reasons = append(reasons, dto.HistoryChangeReasonVisit)
604	}
605	if successfulTitles > 0 {
606		reasons = append(reasons, dto.HistoryChangeReasonTitle)
607	}
608	sink := uc.historyChangeSink()
609	// Detach the notification from worker cancellation: the sink runs on
610	// another goroutine (main-thread dispatch) and must never derive from
611	// or observe a parent that Close cancels concurrently (see #408).
612	sink.OnHistoryChanged(context.WithoutCancel(uc.workerContext()), dto.HistoryChange{
613		Reasons:    reasons,
614		VisitCount: successfulVisits,
615		TitleCount: successfulTitles,
616		ChangedAt:  time.Now(),
617	})
618}
619
620func (uc *HistoryRecorderUseCase) historyChangeSink() port.HistoryChangeSink {
621	uc.changeSinkMu.RLock()
622	sink := uc.changeSink
623	uc.changeSinkMu.RUnlock()
624	return normalizeHistoryChangeSink(sink)
625}
626
627// persistHistory writes a history record to the database.
628// Called from the background worker goroutine.
629func (uc *HistoryRecorderUseCase) persistHistory(ctx context.Context, record historyRecord) int {
630	log := logging.FromContext(ctx)
631
632	existing, err := uc.historyRepo.FindByURL(ctx, record.url)
633	if err != nil {
634		log.Warn().Err(err).Str("url", logging.RedactURL(record.url)).Msg("failed to check history")
635		return 0
636	}
637
638	delta := max(1, record.visits)
639	if existing != nil {
640		if deltaWriter, ok := uc.historyRepo.(historyVisitDeltaIncrementer); ok {
641			if err := deltaWriter.IncrementVisitCountBy(ctx, record.url, delta); err != nil {
642				log.Warn().Err(err).Str("url", logging.RedactURL(record.url)).Int("delta", delta).Msg("failed to increment visit count by delta")
643				return 0
644			}
645			return delta
646		}
647		written := 0
648		for range delta {
649			if err := uc.historyRepo.IncrementVisitCount(ctx, record.url); err != nil {
650				log.Warn().Err(err).Str("url", logging.RedactURL(record.url)).Msg("failed to increment visit count")
651				return written
652			}
653			written++
654		}
655		return written
656	}
657
658	entry := entity.NewHistoryEntry(record.url, "")
659	entry.VisitCount = int64(delta)
660	if err := uc.historyRepo.Save(ctx, entry); err != nil {
661		log.Warn().Err(err).Str("url", logging.RedactURL(record.url)).Msg("failed to save history")
662		return 0
663	}
664	return delta
665}
666
667// persistTitleUpdate writes a title update to the database.
668// Called from the background worker goroutine.
669func (uc *HistoryRecorderUseCase) persistTitleUpdate(ctx context.Context, historyURL, title string) titlePersistenceResult {
670	log := logging.FromContext(ctx)
671
672	if title == "" {
673		return titlePersistenceDiscard
674	}
675
676	entry, err := uc.historyRepo.FindByURL(ctx, historyURL)
677	if err != nil {
678		log.Warn().Err(err).Str("url", logging.RedactURL(historyURL)).Msg("failed to find history entry for title update")
679		return titlePersistenceRetry
680	}
681	if entry == nil {
682		return titlePersistenceDiscard
683	}
684
685	entry.Title = title
686	if updater, ok := uc.historyRepo.(historyMetadataUpdater); ok {
687		if err := updater.UpdateMetadata(ctx, entry); err != nil {
688			log.Warn().Err(err).Str("url", logging.RedactURL(historyURL)).Msg("failed to update history metadata")
689			return titlePersistenceRetry
690		}
691		return titlePersistenceSaved
692	}
693	if err := uc.historyRepo.Save(ctx, entry); err != nil {
694		log.Warn().Err(err).Str("url", logging.RedactURL(historyURL)).Msg("failed to update history title")
695		return titlePersistenceRetry
696	}
697	return titlePersistenceSaved
698}
699
700func isRecordableHistoryURL(raw string) bool {
701	parsed, err := url.Parse(strings.TrimSpace(raw))
702	if err != nil {
703		return false
704	}
705	if strings.TrimSpace(parsed.Host) == "" {
706		return false
707	}
708	switch strings.ToLower(parsed.Scheme) {
709	case "http", "https":
710		return true
711	default:
712		return false
713	}
714}
715
716func canonicalizeURLForHistory(raw string, opts historyCanonicalizationOptions) string {
717	raw = strings.TrimSpace(raw)
718	if raw == "" {
719		return ""
720	}
721
722	parsed, err := url.Parse(raw)
723	if err != nil {
724		return strings.TrimSuffix(raw, "/")
725	}
726
727	parsed.Scheme = strings.ToLower(parsed.Scheme)
728	parsed.Host = canonicalHistoryHost(parsed)
729	parsed.User = nil
730	parsed.Fragment = ""
731	parsed.Path = normalizePathForHistory(parsed.Path)
732
733	query := parsed.Query()
734	if opts.StripTrackingParams {
735		for key := range query {
736			if isTrackingQueryParam(key) {
737				query.Del(key)
738			}
739		}
740	}
741	parsed.RawQuery = query.Encode()
742
743	return parsed.String()
744}
745
746func canonicalHistoryHost(parsed *url.URL) string {
747	host := strings.ToLower(parsed.Hostname())
748	portValue := parsed.Port()
749	if portValue == "" || (parsed.Scheme == "http" && portValue == "80") || (parsed.Scheme == "https" && portValue == "443") {
750		if strings.Contains(host, ":") {
751			return "[" + host + "]"
752		}
753		return host
754	}
755	return net.JoinHostPort(host, portValue)
756}
757
758func normalizePathForHistory(path string) string {
759	if path == "/" {
760		return ""
761	}
762	if before, ok := strings.CutSuffix(path, "/"); ok {
763		return before
764	}
765	return path
766}
767
768func isTrackingQueryParam(key string) bool {
769	key = strings.ToLower(strings.TrimSpace(key))
770	if key == "" {
771		return false
772	}
773	if strings.HasPrefix(key, "utm_") {
774		return true
775	}
776	switch key {
777	case "fbclid", "gclid", "msclkid", "dclid", "yclid", "mc_cid", "mc_eid", "_hsenc", "_hsmi", "igshid":
778		return true
779	}
780	return false
781}
782
783func isHashOnlyTransition(previous, current string) bool {
784	if previous == "" || current == "" || previous == current {
785		return false
786	}
787
788	prevParsed, prevErr := url.Parse(previous)
789	currParsed, currErr := url.Parse(current)
790	if prevErr != nil || currErr != nil {
791		return false
792	}
793
794	if prevParsed.Fragment == currParsed.Fragment {
795		return false
796	}
797
798	opts := historyCanonicalizationOptions{StripTrackingParams: stripTrackingParamsForHistoryDedup}
799	prevCanonical := canonicalizeURLForHistory(previous, opts)
800	currCanonical := canonicalizeURLForHistory(current, opts)
801	return prevCanonical != "" && prevCanonical == currCanonical
802}
803
804func normalizePaneID(paneID string) string {
805	if strings.TrimSpace(paneID) == "" {
806		return "__default__"
807	}
808	return paneID
809}

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