main 5041353

find_in_page.go RawHistoryFolder

1package usecase
2
3import (
4	"context"
5	"strings"
6	"sync"
7	"time"
8	"unicode"
9
10	"github.com/bnema/dumber/internal/application/port"
11	"github.com/bnema/dumber/internal/logging"
12)
13
14const (
15	defaultFindDebounce = 100 * time.Millisecond
16	maxFindMatchCount   = 1000
17	findHistoryLimit    = 20
18)
19
20type caseMode int
21
22const (
23	caseModeAuto caseMode = iota
24	caseModeManual
25)
26
27// FindState represents the current find-in-page state.
28type FindState struct {
29	Query          string
30	MatchCount     uint
31	CurrentIndex   uint
32	NotFound       bool
33	CaseSensitive  bool
34	AtWordStarts   bool
35	WrapAround     bool
36	HighlightOn    bool
37	HistoryEnabled bool
38}
39
40// FindInPageUseCase manages find-in-page business logic.
41type FindInPageUseCase struct {
42	mu  sync.RWMutex
43	ctx context.Context
44
45	controller port.FindController
46	signalIDs  []uint
47
48	query        string
49	matchCount   uint
50	currentIndex uint
51	notFound     bool
52	navigating   bool // true during SearchNext/SearchPrev to ignore found-text signals
53
54	caseMode      caseMode
55	caseSensitive bool
56	atWordStarts  bool
57	wrapAround    bool
58	highlightOn   bool
59
60	history      []string
61	historyIndex int
62
63	debounceDelay time.Duration
64	debounceTimer *time.Timer
65	debounceMu    sync.Mutex
66
67	onStateChange func(state FindState)
68}
69
70// NewFindInPageUseCase creates a new find-in-page use case.
71func NewFindInPageUseCase(ctx context.Context) *FindInPageUseCase {
72	return &FindInPageUseCase{
73		ctx:           ctx,
74		caseMode:      caseModeAuto,
75		wrapAround:    true,
76		highlightOn:   true,
77		historyIndex:  -1,
78		debounceDelay: defaultFindDebounce,
79	}
80}
81
82// Bind attaches a FindController and connects its signals.
83func (uc *FindInPageUseCase) Bind(controller port.FindController) {
84	log := logging.FromContext(uc.ctx).With().Str("component", "find").Logger()
85
86	uc.mu.Lock()
87	defer uc.mu.Unlock()
88
89	uc.disconnectSignalsLocked()
90	uc.controller = controller
91	uc.signalIDs = uc.signalIDs[:0]
92
93	if controller == nil {
94		log.Debug().Msg("bind called with nil controller")
95		return
96	}
97
98	uc.signalIDs = append(uc.signalIDs,
99		controller.OnFoundText(func(matchCount uint) {
100			uc.onFoundText(matchCount)
101		}),
102		controller.OnFailedToFindText(func() {
103			uc.onFailedToFind()
104		}),
105		controller.OnCountedMatches(func(matchCount uint) {
106			uc.onCountedMatches(matchCount)
107		}),
108	)
109
110	log.Debug().Int("signalCount", len(uc.signalIDs)).Msg("controller bound")
111}
112
113// Unbind detaches the FindController and disconnects signals.
114func (uc *FindInPageUseCase) Unbind() {
115	log := logging.FromContext(uc.ctx).With().Str("component", "find").Logger()
116
117	uc.mu.Lock()
118	defer uc.mu.Unlock()
119	uc.disconnectSignalsLocked()
120	uc.controller = nil
121	log.Debug().Msg("controller unbound")
122}
123
124// SetOnStateChange sets a callback for state changes.
125func (uc *FindInPageUseCase) SetOnStateChange(fn func(state FindState)) {
126	uc.mu.Lock()
127	defer uc.mu.Unlock()
128	uc.onStateChange = fn
129}
130
131// SetQuery updates the search query with debouncing.
132func (uc *FindInPageUseCase) SetQuery(text string) {
133	log := logging.FromContext(uc.ctx).With().Str("component", "find").Logger()
134
135	uc.mu.Lock()
136	uc.query = text
137	uc.notFound = false
138	uc.currentIndex = 0
139	uc.navigating = false // New search, accept found-text signals again
140	uc.mu.Unlock()
141
142	log.Debug().Str("query", text).Msg("query set")
143
144	if strings.TrimSpace(text) == "" {
145		uc.finishSearch()
146		uc.notifyState()
147		return
148	}
149
150	uc.debounceMu.Lock()
151	if uc.debounceTimer != nil {
152		uc.debounceTimer.Stop()
153	}
154	uc.debounceTimer = time.AfterFunc(uc.debounceDelay, func() {
155		uc.executeSearch(text)
156	})
157	uc.debounceMu.Unlock()
158
159	uc.notifyState()
160}
161
162// SearchNext selects the next match.
163func (uc *FindInPageUseCase) SearchNext() {
164	log := logging.FromContext(uc.ctx).With().Str("component", "find").Logger()
165
166	uc.mu.Lock()
167	controller := uc.controller
168	matchCount := uc.matchCount
169	query := uc.query
170	uc.mu.Unlock()
171
172	log.Debug().
173		Str("query", query).
174		Uint("matchCount", matchCount).
175		Bool("hasController", controller != nil).
176		Msg("SearchNext called")
177
178	if controller == nil || strings.TrimSpace(query) == "" {
179		log.Debug().Msg("SearchNext: no controller or empty query")
180		return
181	}
182	if matchCount == 0 {
183		log.Debug().Msg("SearchNext: no matches, executing fresh search")
184		uc.flushSearch(query)
185		return
186	}
187
188	uc.mu.Lock()
189	uc.navigating = true
190	uc.mu.Unlock()
191
192	log.Debug().Msg("SearchNext: calling controller.SearchNext()")
193	controller.SearchNext()
194	uc.advanceIndex(1)
195}
196
197// SearchPrevious selects the previous match.
198func (uc *FindInPageUseCase) SearchPrevious() {
199	log := logging.FromContext(uc.ctx).With().Str("component", "find").Logger()
200
201	uc.mu.Lock()
202	controller := uc.controller
203	matchCount := uc.matchCount
204	query := uc.query
205	uc.mu.Unlock()
206
207	log.Debug().
208		Str("query", query).
209		Uint("matchCount", matchCount).
210		Bool("hasController", controller != nil).
211		Msg("SearchPrevious called")
212
213	if controller == nil || strings.TrimSpace(query) == "" {
214		return
215	}
216	if matchCount == 0 {
217		uc.flushSearch(query)
218		return
219	}
220
221	uc.mu.Lock()
222	uc.navigating = true
223	uc.mu.Unlock()
224
225	log.Debug().Msg("SearchPrevious: calling controller.SearchPrevious()")
226	controller.SearchPrevious()
227	uc.advanceIndex(-1)
228}
229
230// Finish clears the current search and highlights.
231func (uc *FindInPageUseCase) Finish() {
232	uc.finishSearch()
233}
234
235// SetCaseSensitiveEnabled toggles manual case sensitivity.
236func (uc *FindInPageUseCase) SetCaseSensitiveEnabled(enabled bool) {
237	uc.mu.Lock()
238	if enabled {
239		uc.caseMode = caseModeManual
240		uc.caseSensitive = true
241	} else {
242		uc.caseMode = caseModeAuto
243		uc.caseSensitive = false
244	}
245	uc.mu.Unlock()
246
247	uc.executeSearch(uc.currentQuery())
248}
249
250// SetAtWordStarts toggles word-start matching.
251func (uc *FindInPageUseCase) SetAtWordStarts(enabled bool) {
252	uc.mu.Lock()
253	uc.atWordStarts = enabled
254	uc.mu.Unlock()
255
256	uc.executeSearch(uc.currentQuery())
257}
258
259// SetWrapAround toggles wrap-around searching.
260func (uc *FindInPageUseCase) SetWrapAround(enabled bool) {
261	uc.mu.Lock()
262	uc.wrapAround = enabled
263	uc.mu.Unlock()
264
265	uc.executeSearch(uc.currentQuery())
266}
267
268// SetHighlightEnabled toggles match highlighting.
269func (uc *FindInPageUseCase) SetHighlightEnabled(enabled bool) {
270	uc.mu.Lock()
271	uc.highlightOn = enabled
272	uc.mu.Unlock()
273
274	uc.executeSearch(uc.currentQuery())
275}
276
277// History returns a copy of the search history.
278func (uc *FindInPageUseCase) History() []string {
279	uc.mu.RLock()
280	defer uc.mu.RUnlock()
281
282	history := make([]string, len(uc.history))
283	copy(history, uc.history)
284	return history
285}
286
287// PrevHistory selects the previous history item.
288func (uc *FindInPageUseCase) PrevHistory() (string, bool) {
289	uc.mu.Lock()
290	defer uc.mu.Unlock()
291
292	if len(uc.history) == 0 {
293		return "", false
294	}
295	if uc.historyIndex < 0 {
296		uc.historyIndex = 0
297	} else if uc.historyIndex < len(uc.history)-1 {
298		uc.historyIndex++
299	}
300	return uc.history[uc.historyIndex], true
301}
302
303// NextHistory selects the next history item.
304func (uc *FindInPageUseCase) NextHistory() (string, bool) {
305	uc.mu.Lock()
306	defer uc.mu.Unlock()
307
308	if len(uc.history) == 0 || uc.historyIndex < 0 {
309		return "", false
310	}
311	if uc.historyIndex > 0 {
312		uc.historyIndex--
313	} else {
314		uc.historyIndex = -1
315		return "", true
316	}
317	return uc.history[uc.historyIndex], true
318}
319
320func (uc *FindInPageUseCase) executeSearch(text string) {
321	log := logging.FromContext(uc.ctx).With().Str("component", "find").Logger()
322
323	if strings.TrimSpace(text) == "" {
324		uc.finishSearch()
325		uc.notifyState()
326		return
327	}
328
329	uc.mu.RLock()
330	controller := uc.controller
331	uc.mu.RUnlock()
332
333	if controller == nil {
334		log.Debug().Msg("executeSearch: no controller")
335		return
336	}
337
338	uc.mu.RLock()
339	opts := uc.buildOptionsLocked(text)
340	highlight := uc.highlightOn
341	uc.mu.RUnlock()
342
343	if !highlight {
344		controller.SearchFinish()
345		uc.setMatchCount(0, true)
346		return
347	}
348
349	log.Debug().
350		Str("text", text).
351		Bool("caseInsensitive", opts.CaseInsensitive).
352		Bool("wrapAround", opts.WrapAround).
353		Msg("executing search")
354
355	controller.Search(text, opts, maxFindMatchCount)
356	uc.addHistory(text)
357	uc.notifyState()
358}
359
360func (uc *FindInPageUseCase) flushSearch(text string) {
361	uc.debounceMu.Lock()
362	if uc.debounceTimer != nil {
363		uc.debounceTimer.Stop()
364	}
365	uc.debounceTimer = nil
366	uc.debounceMu.Unlock()
367
368	uc.executeSearch(text)
369}
370
371func (uc *FindInPageUseCase) finishSearch() {
372	uc.debounceMu.Lock()
373	if uc.debounceTimer != nil {
374		uc.debounceTimer.Stop()
375	}
376	uc.debounceMu.Unlock()
377
378	uc.mu.Lock()
379	controller := uc.controller
380	uc.matchCount = 0
381	uc.currentIndex = 0
382	uc.notFound = false
383	uc.mu.Unlock()
384
385	if controller != nil {
386		controller.SearchFinish()
387	}
388	uc.notifyState()
389}
390
391func (uc *FindInPageUseCase) buildOptionsLocked(text string) port.FindOptions {
392	var caseInsensitive bool
393	if uc.caseMode == caseModeManual {
394		caseInsensitive = !uc.caseSensitive
395	} else {
396		caseInsensitive = !containsUpper(text)
397	}
398
399	return port.FindOptions{
400		CaseInsensitive: caseInsensitive,
401		AtWordStarts:    uc.atWordStarts,
402		WrapAround:      uc.wrapAround,
403	}
404}
405
406func (uc *FindInPageUseCase) currentQuery() string {
407	uc.mu.RLock()
408	defer uc.mu.RUnlock()
409	return uc.query
410}
411
412func (uc *FindInPageUseCase) onFoundText(matchCount uint) {
413	log := logging.FromContext(uc.ctx).With().Str("component", "find").Logger()
414
415	uc.mu.Lock()
416	navigating := uc.navigating
417	if navigating {
418		// During navigation, WebKit emits found-text with matchCount=1 (the current match)
419		// NOT the total count. Ignore it to preserve our actual total.
420		log.Debug().
421			Uint("signalMatchCount", matchCount).
422			Uint("preservedTotal", uc.matchCount).
423			Msg("onFoundText (navigating, ignoring)")
424		uc.mu.Unlock()
425		return
426	}
427	uc.mu.Unlock()
428
429	log.Debug().
430		Uint("matchCount", matchCount).
431		Msg("onFoundText")
432
433	uc.setMatchCount(matchCount, false)
434}
435
436func (uc *FindInPageUseCase) onFailedToFind() {
437	log := logging.FromContext(uc.ctx).With().Str("component", "find").Logger()
438
439	uc.mu.Lock()
440	navigating := uc.navigating
441	if navigating {
442		log.Debug().Msg("onFailedToFind (navigating, ignoring)")
443		uc.mu.Unlock()
444		return
445	}
446	uc.matchCount = 0
447	uc.currentIndex = 0
448	uc.notFound = true
449	uc.mu.Unlock()
450
451	log.Debug().Msg("onFailedToFind")
452	uc.notifyState()
453}
454
455func (uc *FindInPageUseCase) onCountedMatches(matchCount uint) {
456	log := logging.FromContext(uc.ctx).With().Str("component", "find").Logger()
457	log.Debug().Uint("matchCount", matchCount).Msg("onCountedMatches")
458	uc.setMatchCount(matchCount, true)
459}
460
461func (uc *FindInPageUseCase) setMatchCount(matchCount uint, counted bool) {
462	uc.mu.Lock()
463	uc.matchCount = matchCount
464	if matchCount == 0 {
465		uc.currentIndex = 0
466		uc.notFound = counted
467	} else if uc.currentIndex == 0 {
468		uc.currentIndex = 1
469		uc.notFound = false
470	}
471	uc.mu.Unlock()
472	uc.notifyState()
473}
474
475func (uc *FindInPageUseCase) advanceIndex(delta int) {
476	log := logging.FromContext(uc.ctx).With().Str("component", "find").Logger()
477
478	uc.mu.Lock()
479	defer uc.mu.Unlock()
480
481	if uc.matchCount == 0 {
482		return
483	}
484
485	index := int(uc.currentIndex)
486	if index == 0 {
487		index = 1
488	}
489	index += delta
490	if index <= 0 {
491		index = int(uc.matchCount)
492	} else if index > int(uc.matchCount) {
493		index = 1
494	}
495	uc.currentIndex = uint(index)
496
497	log.Debug().
498		Int("delta", delta).
499		Uint("newIndex", uc.currentIndex).
500		Uint("matchCount", uc.matchCount).
501		Msg("advanceIndex")
502
503	uc.notifyStateLocked()
504}
505
506func (uc *FindInPageUseCase) addHistory(text string) {
507	uc.mu.Lock()
508	defer uc.mu.Unlock()
509
510	if strings.TrimSpace(text) == "" {
511		return
512	}
513
514	// Remove existing entry
515	for i, item := range uc.history {
516		if item == text {
517			uc.history = append(uc.history[:i], uc.history[i+1:]...)
518			break
519		}
520	}
521
522	uc.history = append([]string{text}, uc.history...)
523	if len(uc.history) > findHistoryLimit {
524		uc.history = uc.history[:findHistoryLimit]
525	}
526	uc.historyIndex = -1
527}
528
529func (uc *FindInPageUseCase) notifyState() {
530	uc.mu.RLock()
531	defer uc.mu.RUnlock()
532	uc.notifyStateLocked()
533}
534
535func (uc *FindInPageUseCase) notifyStateLocked() {
536	if uc.onStateChange == nil {
537		return
538	}
539
540	query := uc.query
541	caseSensitive := uc.caseSensitive
542	if uc.caseMode == caseModeAuto {
543		caseSensitive = containsUpper(query)
544	}
545
546	state := FindState{
547		Query:          query,
548		MatchCount:     uc.matchCount,
549		CurrentIndex:   uc.currentIndex,
550		NotFound:       uc.notFound,
551		CaseSensitive:  caseSensitive,
552		AtWordStarts:   uc.atWordStarts,
553		WrapAround:     uc.wrapAround,
554		HighlightOn:    uc.highlightOn,
555		HistoryEnabled: len(uc.history) > 0,
556	}
557	uc.onStateChange(state)
558}
559
560func (uc *FindInPageUseCase) disconnectSignalsLocked() {
561	if uc.controller == nil {
562		uc.signalIDs = uc.signalIDs[:0]
563		return
564	}
565
566	for _, id := range uc.signalIDs {
567		uc.controller.DisconnectSignal(id)
568	}
569	uc.signalIDs = uc.signalIDs[:0]
570}
571
572func containsUpper(text string) bool {
573	for _, r := range text {
574		if unicode.IsUpper(r) {
575			return true
576		}
577	}
578	return false
579}

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