detached b4e5347

chore(finalizer): tighten clipboard cleanup and coverage

commit
b4e534751feb40db9d9a2fbc798821074c1e2398
author
brice · · 2026-04-18 14:00 UTC
parent
41800e7
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
10 files · +107 -28
M internal/application/usecase/clipboard_text_orchestrator.go +9 -10
@@ -81,11 +81,7 @@ func (uc *ClipboardTextOrchestratorUseCase) HandleSelectionUpdate(
8181 }
8282 textLen := utf8.RuneCountInString(input.Text)
8383 scope := selectionScope(input)
84- now := uc.now
85- if now == nil {
86- now = time.Now
87- }
88- currentTime := now()
84+ currentTime := uc.currentTime()
8985
9086 uc.mu.Lock()
9187 if input.Text == uc.lastSelection[scope] {
@@ -134,11 +130,7 @@ func (uc *ClipboardTextOrchestratorUseCase) HandleExplicitCopy(
134130 return nil
135131 }
136132
137- now := uc.now
138- if now == nil {
139- now = time.Now
140- }
141- currentTime := now()
133+ currentTime := uc.currentTime()
142134 textLen := utf8.RuneCountInString(input.Text)
143135 scope := explicitScope(input)
144136
@@ -189,3 +181,10 @@ func selectionScope(input dto.SelectionClipboardInput) clipboardScope {
189181 func explicitScope(input dto.ExplicitClipboardInput) clipboardScope {
190182 return clipboardScope{source: input.SourceEngine, viewID: input.ViewID}
191183 }
184+
185+func (uc *ClipboardTextOrchestratorUseCase) currentTime() time.Time {
186+ if uc == nil || uc.now == nil {
187+ return time.Now()
188+ }
189+ return uc.now()
190+}
@@ -81,11 +81,7 @@ func (uc *ClipboardTextOrchestratorUseCase) HandleSelectionUpdate(
81 }81 }
82 textLen := utf8.RuneCountInString(input.Text)82 textLen := utf8.RuneCountInString(input.Text)
83 scope := selectionScope(input)83 scope := selectionScope(input)
84- now := uc.now84+ currentTime := uc.currentTime()
85- if now == nil {
86- now = time.Now
87- }
88- currentTime := now()
89 85
90 uc.mu.Lock()86 uc.mu.Lock()
91 if input.Text == uc.lastSelection[scope] {87 if input.Text == uc.lastSelection[scope] {
@@ -134,11 +130,7 @@ func (uc *ClipboardTextOrchestratorUseCase) HandleExplicitCopy(
134 return nil130 return nil
135 }131 }
136 132
137- now := uc.now133+ currentTime := uc.currentTime()
138- if now == nil {
139- now = time.Now
140- }
141- currentTime := now()
142 textLen := utf8.RuneCountInString(input.Text)134 textLen := utf8.RuneCountInString(input.Text)
143 scope := explicitScope(input)135 scope := explicitScope(input)
144 136
@@ -189,3 +181,10 @@ func selectionScope(input dto.SelectionClipboardInput) clipboardScope {
189 func explicitScope(input dto.ExplicitClipboardInput) clipboardScope {181 func explicitScope(input dto.ExplicitClipboardInput) clipboardScope {
190 return clipboardScope{source: input.SourceEngine, viewID: input.ViewID}182 return clipboardScope{source: input.SourceEngine, viewID: input.ViewID}
191 }183 }
184+
185+func (uc *ClipboardTextOrchestratorUseCase) currentTime() time.Time {
186+ if uc == nil || uc.now == nil {
187+ return time.Now()
188+ }
189+ return uc.now()
190+}
M internal/application/usecase/clipboard_text_orchestrator_test.go +16 -2
@@ -375,8 +375,13 @@ func TestClipboardTextOrchestrator_HandleSelectionUpdate_DedupIsAtomic(t *testin
375375 ctx := context.Background()
376376 clipboard := newBlockingClipboard()
377377 autoCopyConfig := &staticAutoCopyConfig{enabled: true}
378- var toastCalls []int
378+ var (
379+ toastCalls []int
380+ toastMu sync.Mutex
381+ )
379382 uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, func(textLen int) {
383+ toastMu.Lock()
384+ defer toastMu.Unlock()
380385 toastCalls = append(toastCalls, textLen)
381386 })
382387
@@ -409,6 +414,8 @@ func TestClipboardTextOrchestrator_HandleSelectionUpdate_DedupIsAtomic(t *testin
409414 if got := clipboard.writeCount(); got != 1 {
410415 t.Fatalf("clipboard writes = %d, want 1", got)
411416 }
417+ toastMu.Lock()
418+ defer toastMu.Unlock()
412419 if len(toastCalls) != 1 || toastCalls[0] != 2 {
413420 t.Fatalf("toast calls = %v, want [2]", toastCalls)
414421 }
@@ -418,8 +425,13 @@ func TestClipboardTextOrchestrator_HandleExplicitCopy_DedupIsAtomic(t *testing.T
418425 ctx := context.Background()
419426 clipboard := newBlockingClipboard()
420427 autoCopyConfig := &staticAutoCopyConfig{enabled: true}
421- var toastCalls []int
428+ var (
429+ toastCalls []int
430+ toastMu sync.Mutex
431+ )
422432 uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, func(textLen int) {
433+ toastMu.Lock()
434+ defer toastMu.Unlock()
423435 toastCalls = append(toastCalls, textLen)
424436 })
425437 uc.now = func() time.Time { return time.Unix(0, 0) }
@@ -453,6 +465,8 @@ func TestClipboardTextOrchestrator_HandleExplicitCopy_DedupIsAtomic(t *testing.T
453465 if got := clipboard.writeCount(); got != 1 {
454466 t.Fatalf("clipboard writes = %d, want 1", got)
455467 }
468+ toastMu.Lock()
469+ defer toastMu.Unlock()
456470 if len(toastCalls) != 1 || toastCalls[0] != 2 {
457471 t.Fatalf("toast calls = %v, want [2]", toastCalls)
458472 }
@@ -375,8 +375,13 @@ func TestClipboardTextOrchestrator_HandleSelectionUpdate_DedupIsAtomic(t *testin
375 ctx := context.Background()375 ctx := context.Background()
376 clipboard := newBlockingClipboard()376 clipboard := newBlockingClipboard()
377 autoCopyConfig := &staticAutoCopyConfig{enabled: true}377 autoCopyConfig := &staticAutoCopyConfig{enabled: true}
378- var toastCalls []int378+ var (
379+ toastCalls []int
380+ toastMu sync.Mutex
381+ )
379 uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, func(textLen int) {382 uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, func(textLen int) {
383+ toastMu.Lock()
384+ defer toastMu.Unlock()
380 toastCalls = append(toastCalls, textLen)385 toastCalls = append(toastCalls, textLen)
381 })386 })
382 387
@@ -409,6 +414,8 @@ func TestClipboardTextOrchestrator_HandleSelectionUpdate_DedupIsAtomic(t *testin
409 if got := clipboard.writeCount(); got != 1 {414 if got := clipboard.writeCount(); got != 1 {
410 t.Fatalf("clipboard writes = %d, want 1", got)415 t.Fatalf("clipboard writes = %d, want 1", got)
411 }416 }
417+ toastMu.Lock()
418+ defer toastMu.Unlock()
412 if len(toastCalls) != 1 || toastCalls[0] != 2 {419 if len(toastCalls) != 1 || toastCalls[0] != 2 {
413 t.Fatalf("toast calls = %v, want [2]", toastCalls)420 t.Fatalf("toast calls = %v, want [2]", toastCalls)
414 }421 }
@@ -418,8 +425,13 @@ func TestClipboardTextOrchestrator_HandleExplicitCopy_DedupIsAtomic(t *testing.T
418 ctx := context.Background()425 ctx := context.Background()
419 clipboard := newBlockingClipboard()426 clipboard := newBlockingClipboard()
420 autoCopyConfig := &staticAutoCopyConfig{enabled: true}427 autoCopyConfig := &staticAutoCopyConfig{enabled: true}
421- var toastCalls []int428+ var (
429+ toastCalls []int
430+ toastMu sync.Mutex
431+ )
422 uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, func(textLen int) {432 uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, func(textLen int) {
433+ toastMu.Lock()
434+ defer toastMu.Unlock()
423 toastCalls = append(toastCalls, textLen)435 toastCalls = append(toastCalls, textLen)
424 })436 })
425 uc.now = func() time.Time { return time.Unix(0, 0) }437 uc.now = func() time.Time { return time.Unix(0, 0) }
@@ -453,6 +465,8 @@ func TestClipboardTextOrchestrator_HandleExplicitCopy_DedupIsAtomic(t *testing.T
453 if got := clipboard.writeCount(); got != 1 {465 if got := clipboard.writeCount(); got != 1 {
454 t.Fatalf("clipboard writes = %d, want 1", got)466 t.Fatalf("clipboard writes = %d, want 1", got)
455 }467 }
468+ toastMu.Lock()
469+ defer toastMu.Unlock()
456 if len(toastCalls) != 1 || toastCalls[0] != 2 {470 if len(toastCalls) != 1 || toastCalls[0] != 2 {
457 t.Fatalf("toast calls = %v, want [2]", toastCalls)471 t.Fatalf("toast calls = %v, want [2]", toastCalls)
458 }472 }
M internal/application/usecase/execute_context_menu_action.go +3 -0
@@ -106,6 +106,9 @@ func (uc *ExecuteContextMenuActionUseCase) executeMenuAction(
106106 }
107107 return nil
108108 case port.MenuActionCopySelection:
109+ // When selection text is present and clipboard wiring is available, copy it here.
110+ // Otherwise intentionally fall through to DelegateMenuAction so native browser copy
111+ // behavior can handle document selections that are only accessible inside the engine.
109112 if menuContext.SelectionText != "" && uc.clipboard != nil {
110113 if err := uc.clipboard.WriteText(ctx, menuContext.SelectionText); err != nil {
111114 return fmt.Errorf("copy selection: %w", err)
@@ -106,6 +106,9 @@ func (uc *ExecuteContextMenuActionUseCase) executeMenuAction(
106 }106 }
107 return nil107 return nil
108 case port.MenuActionCopySelection:108 case port.MenuActionCopySelection:
109+ // When selection text is present and clipboard wiring is available, copy it here.
110+ // Otherwise intentionally fall through to DelegateMenuAction so native browser copy
111+ // behavior can handle document selections that are only accessible inside the engine.
109 if menuContext.SelectionText != "" && uc.clipboard != nil {112 if menuContext.SelectionText != "" && uc.clipboard != nil {
110 if err := uc.clipboard.WriteText(ctx, menuContext.SelectionText); err != nil {113 if err := uc.clipboard.WriteText(ctx, menuContext.SelectionText); err != nil {
111 return fmt.Errorf("copy selection: %w", err)114 return fmt.Errorf("copy selection: %w", err)
M internal/infrastructure/cef/engine_runtime_test.go +1 -11
@@ -54,7 +54,7 @@ func TestEngineRegisterHandlers_PropagatesRegistrarError(t *testing.T) {
5454 }
5555
5656 func TestEngineRegisterHandlers_StoresClipboardWiring(t *testing.T) {
57- orchestrator := &stubClipboardTextOrchestrator{}
57+ orchestrator := &recordingClipboardTextOrchestrator{}
5858 onCopied := func(int) {}
5959 eng := &Engine{
6060 messageRouter: NewMessageRouter(context.Background()),
@@ -89,21 +89,11 @@ func TestEngineExplicitClipboardCopy_ForwardsViewID(t *testing.T) {
8989 require.Equal(t, dto.ExplicitClipboardInput{Text: "copied", SourceEngine: dto.ClipboardSourceCEF, ViewID: 77, Action: "copy", NativeHandled: false}, orchestrator.explicit)
9090 }
9191
92-type stubClipboardTextOrchestrator struct{}
93-
9492 type recordingClipboardTextOrchestrator struct {
9593 selection dto.SelectionClipboardInput
9694 explicit dto.ExplicitClipboardInput
9795 }
9896
99-func (stubClipboardTextOrchestrator) HandleSelectionUpdate(context.Context, dto.SelectionClipboardInput) error {
100- return nil
101-}
102-
103-func (stubClipboardTextOrchestrator) HandleExplicitCopy(context.Context, dto.ExplicitClipboardInput) error {
104- return nil
105-}
106-
10797 func (r *recordingClipboardTextOrchestrator) HandleSelectionUpdate(_ context.Context, input dto.SelectionClipboardInput) error {
10898 r.selection = input
10999 return nil
@@ -54,7 +54,7 @@ func TestEngineRegisterHandlers_PropagatesRegistrarError(t *testing.T) {
54 }54 }
55 55
56 func TestEngineRegisterHandlers_StoresClipboardWiring(t *testing.T) {56 func TestEngineRegisterHandlers_StoresClipboardWiring(t *testing.T) {
57- orchestrator := &stubClipboardTextOrchestrator{}57+ orchestrator := &recordingClipboardTextOrchestrator{}
58 onCopied := func(int) {}58 onCopied := func(int) {}
59 eng := &Engine{59 eng := &Engine{
60 messageRouter: NewMessageRouter(context.Background()),60 messageRouter: NewMessageRouter(context.Background()),
@@ -89,21 +89,11 @@ func TestEngineExplicitClipboardCopy_ForwardsViewID(t *testing.T) {
89 require.Equal(t, dto.ExplicitClipboardInput{Text: "copied", SourceEngine: dto.ClipboardSourceCEF, ViewID: 77, Action: "copy", NativeHandled: false}, orchestrator.explicit)89 require.Equal(t, dto.ExplicitClipboardInput{Text: "copied", SourceEngine: dto.ClipboardSourceCEF, ViewID: 77, Action: "copy", NativeHandled: false}, orchestrator.explicit)
90 }90 }
91 91
92-type stubClipboardTextOrchestrator struct{}
93-
94 type recordingClipboardTextOrchestrator struct {92 type recordingClipboardTextOrchestrator struct {
95 selection dto.SelectionClipboardInput93 selection dto.SelectionClipboardInput
96 explicit dto.ExplicitClipboardInput94 explicit dto.ExplicitClipboardInput
97 }95 }
98 96
99-func (stubClipboardTextOrchestrator) HandleSelectionUpdate(context.Context, dto.SelectionClipboardInput) error {
100- return nil
101-}
102-
103-func (stubClipboardTextOrchestrator) HandleExplicitCopy(context.Context, dto.ExplicitClipboardInput) error {
104- return nil
105-}
106-
107 func (r *recordingClipboardTextOrchestrator) HandleSelectionUpdate(_ context.Context, input dto.SelectionClipboardInput) error {97 func (r *recordingClipboardTextOrchestrator) HandleSelectionUpdate(_ context.Context, input dto.SelectionClipboardInput) error {
108 r.selection = input98 r.selection = input
109 return nil99 return nil
M internal/infrastructure/cef/internal_urls.go +2 -1
@@ -12,6 +12,7 @@ const (
1212 actualInternalHost = "dumber.invalid"
1313 actualInternalOrigin = actualInternalScheme + "://" + actualInternalHost
1414 internalAPIPathPrefix = "/api/"
15+ internalAPIPathPrefixTrimmed = "api/"
1516 )
1617
1718 func isConceptualInternalURL(raw string) bool {
@@ -50,7 +51,7 @@ func toActualInternalURL(raw string) string {
5051 case trimmedPath == "":
5152 // Keep page roots at /home instead of /home/ so relative assets resolve
5253 // from the origin root.
53- case strings.HasPrefix(trimmedPath, strings.TrimPrefix(internalAPIPathPrefix, "/")):
54+ case strings.HasPrefix(trimmedPath, internalAPIPathPrefixTrimmed):
5455 actualPath = "/" + trimmedPath
5556 case path.Ext(trimmedPath) != "":
5657 actualPath = "/" + trimmedPath
@@ -12,6 +12,7 @@ const (
12 actualInternalHost = "dumber.invalid"12 actualInternalHost = "dumber.invalid"
13 actualInternalOrigin = actualInternalScheme + "://" + actualInternalHost13 actualInternalOrigin = actualInternalScheme + "://" + actualInternalHost
14 internalAPIPathPrefix = "/api/"14 internalAPIPathPrefix = "/api/"
15+ internalAPIPathPrefixTrimmed = "api/"
15 )16 )
16 17
17 func isConceptualInternalURL(raw string) bool {18 func isConceptualInternalURL(raw string) bool {
@@ -50,7 +51,7 @@ func toActualInternalURL(raw string) string {
50 case trimmedPath == "":51 case trimmedPath == "":
51 // Keep page roots at /home instead of /home/ so relative assets resolve52 // Keep page roots at /home instead of /home/ so relative assets resolve
52 // from the origin root.53 // from the origin root.
53- case strings.HasPrefix(trimmedPath, strings.TrimPrefix(internalAPIPathPrefix, "/")):54+ case strings.HasPrefix(trimmedPath, internalAPIPathPrefixTrimmed):
54 actualPath = "/" + trimmedPath55 actualPath = "/" + trimmedPath
55 case path.Ext(trimmedPath) != "":56 case path.Ext(trimmedPath) != "":
56 actualPath = "/" + trimmedPath57 actualPath = "/" + trimmedPath
M internal/infrastructure/cef/internal_urls_test.go +13 -3
@@ -59,6 +59,12 @@ func TestResolveAPIPath(t *testing.T) {
5959 want string
6060 ok bool
6161 }{
62+ {
63+ name: "nil url returns false",
64+ in: "",
65+ want: "",
66+ ok: false,
67+ },
6268 {
6369 name: "conceptual api host",
6470 in: "dumb://api/clipboard-set",
@@ -88,9 +94,13 @@ func TestResolveAPIPath(t *testing.T) {
8894 for _, tt := range tests {
8995 t.Run(tt.name, func(t *testing.T) {
9096 t.Parallel()
91- parsed, err := url.Parse(tt.in)
92- if err != nil {
93- t.Fatalf("url.Parse(%q) error = %v", tt.in, err)
97+ var parsed *url.URL
98+ if tt.in != "" {
99+ var err error
100+ parsed, err = url.Parse(tt.in)
101+ if err != nil {
102+ t.Fatalf("url.Parse(%q) error = %v", tt.in, err)
103+ }
94104 }
95105 got, ok := resolveAPIPath(parsed)
96106 if got != tt.want || ok != tt.ok {
@@ -59,6 +59,12 @@ func TestResolveAPIPath(t *testing.T) {
59 want string59 want string
60 ok bool60 ok bool
61 }{61 }{
62+ {
63+ name: "nil url returns false",
64+ in: "",
65+ want: "",
66+ ok: false,
67+ },
62 {68 {
63 name: "conceptual api host",69 name: "conceptual api host",
64 in: "dumb://api/clipboard-set",70 in: "dumb://api/clipboard-set",
@@ -88,9 +94,13 @@ func TestResolveAPIPath(t *testing.T) {
88 for _, tt := range tests {94 for _, tt := range tests {
89 t.Run(tt.name, func(t *testing.T) {95 t.Run(tt.name, func(t *testing.T) {
90 t.Parallel()96 t.Parallel()
91- parsed, err := url.Parse(tt.in)97+ var parsed *url.URL
92- if err != nil {98+ if tt.in != "" {
93- t.Fatalf("url.Parse(%q) error = %v", tt.in, err)99+ var err error
100+ parsed, err = url.Parse(tt.in)
101+ if err != nil {
102+ t.Fatalf("url.Parse(%q) error = %v", tt.in, err)
103+ }
94 }104 }
95 got, ok := resolveAPIPath(parsed)105 got, ok := resolveAPIPath(parsed)
96 if got != tt.want || ok != tt.ok {106 if got != tt.want || ok != tt.ok {
M internal/infrastructure/cef/scheme_handler.go +1 -1
@@ -225,7 +225,7 @@ func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef.
225225 Int("forwarded_header_count", len(headers)).
226226 Msg("scheme handler returning transcoding stream")
227227
228- return purecef.NewResourceHandler(&transcodingResourceHandler{
228+ return cefNewResourceHandler(&transcodingResourceHandler{
229229 transcoder: h.transcoder,
230230 sourceURL: sourceURL,
231231 headers: headers,
@@ -225,7 +225,7 @@ func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef.
225 Int("forwarded_header_count", len(headers)).225 Int("forwarded_header_count", len(headers)).
226 Msg("scheme handler returning transcoding stream")226 Msg("scheme handler returning transcoding stream")
227 227
228- return purecef.NewResourceHandler(&transcodingResourceHandler{228+ return cefNewResourceHandler(&transcodingResourceHandler{
229 transcoder: h.transcoder,229 transcoder: h.transcoder,
230 sourceURL: sourceURL,230 sourceURL: sourceURL,
231 headers: headers,231 headers: headers,
M internal/infrastructure/webkit/injector.go +1 -0
@@ -125,6 +125,7 @@ const explicitCopyScript = `(function() {
125125 var originalWriteText = navigator.clipboard.writeText.bind(navigator.clipboard);
126126 navigator.clipboard.writeText = function(text) {
127127 return Promise.resolve(originalWriteText(text)).then(function(result) {
128+ // writeText only has copy semantics, so postExplicitCopy always uses "copy" here.
128129 postExplicitCopy('copy', typeof text === 'string' ? text : String(text || ''));
129130 return result;
130131 });
@@ -125,6 +125,7 @@ const explicitCopyScript = `(function() {
125 var originalWriteText = navigator.clipboard.writeText.bind(navigator.clipboard);125 var originalWriteText = navigator.clipboard.writeText.bind(navigator.clipboard);
126 navigator.clipboard.writeText = function(text) {126 navigator.clipboard.writeText = function(text) {
127 return Promise.resolve(originalWriteText(text)).then(function(result) {127 return Promise.resolve(originalWriteText(text)).then(function(result) {
128+ // writeText only has copy semantics, so postExplicitCopy always uses "copy" here.
128 postExplicitCopy('copy', typeof text === 'string' ? text : String(text || ''));129 postExplicitCopy('copy', typeof text === 'string' ? text : String(text || ''));
129 return result;130 return result;
130 });131 });
M internal/ui/app_clipboard_test.go +29 -0
@@ -39,3 +39,32 @@ func TestNew_DoesNotWireClipboardOrchestratorWhenClipboardMissing(t *testing.T)
3939 require.NotNil(t, app)
4040 require.Nil(t, registerDeps.ClipboardTextOrchestrator)
4141 }
42+
43+func TestNew_WiresClipboardOrchestratorWhenClipboardPresent(t *testing.T) {
44+ ctx := context.Background()
45+ engine := portmocks.NewMockEngine(t)
46+ clipboard := portmocks.NewMockClipboard(t)
47+ var registerDeps port.HandlerDependencies
48+ engine.EXPECT().RegisterHandlers(mock.Anything, mock.Anything).
49+ Run(func(_ context.Context, deps port.HandlerDependencies) {
50+ registerDeps = deps
51+ }).
52+ Return(nil).
53+ Once()
54+
55+ deps := &Dependencies{
56+ Ctx: ctx,
57+ Config: &config.Config{},
58+ Engine: engine,
59+ HandlerDeps: port.HandlerDeps{
60+ SaveConfig: func(context.Context, port.WebUIConfig) error { return nil },
61+ SaveOmniboxInitialBehavior: func(context.Context, entity.OmniboxInitialBehavior) error { return nil },
62+ },
63+ Clipboard: clipboard,
64+ }
65+
66+ app, err := New(deps)
67+ require.NoError(t, err)
68+ require.NotNil(t, app)
69+ require.NotNil(t, registerDeps.ClipboardTextOrchestrator)
70+}
@@ -39,3 +39,32 @@ func TestNew_DoesNotWireClipboardOrchestratorWhenClipboardMissing(t *testing.T)
39 require.NotNil(t, app)39 require.NotNil(t, app)
40 require.Nil(t, registerDeps.ClipboardTextOrchestrator)40 require.Nil(t, registerDeps.ClipboardTextOrchestrator)
41 }41 }
42+
43+func TestNew_WiresClipboardOrchestratorWhenClipboardPresent(t *testing.T) {
44+ ctx := context.Background()
45+ engine := portmocks.NewMockEngine(t)
46+ clipboard := portmocks.NewMockClipboard(t)
47+ var registerDeps port.HandlerDependencies
48+ engine.EXPECT().RegisterHandlers(mock.Anything, mock.Anything).
49+ Run(func(_ context.Context, deps port.HandlerDependencies) {
50+ registerDeps = deps
51+ }).
52+ Return(nil).
53+ Once()
54+
55+ deps := &Dependencies{
56+ Ctx: ctx,
57+ Config: &config.Config{},
58+ Engine: engine,
59+ HandlerDeps: port.HandlerDeps{
60+ SaveConfig: func(context.Context, port.WebUIConfig) error { return nil },
61+ SaveOmniboxInitialBehavior: func(context.Context, entity.OmniboxInitialBehavior) error { return nil },
62+ },
63+ Clipboard: clipboard,
64+ }
65+
66+ app, err := New(deps)
67+ require.NoError(t, err)
68+ require.NotNil(t, app)
69+ require.NotNil(t, registerDeps.ClipboardTextOrchestrator)
70+}
M internal/ui/component/progress_bar_test.go +32 -0
@@ -28,3 +28,35 @@ func TestProgressBarTimeoutRemainingLocked_UsesShowTimeBeforeFirstProgress(t *te
2828
2929 require.Zero(t, remaining)
3030 }
31+
32+func TestProgressBarTimeoutRemainingLocked_ZeroTimestamps(t *testing.T) {
33+ pb := &ProgressBar{}
34+
35+ remaining := pb.timeoutRemainingLocked(time.Unix(10, 0))
36+
37+ require.Zero(t, remaining)
38+}
39+
40+func TestProgressBarTimeoutRemainingLocked_EqualTimestamps(t *testing.T) {
41+ showAt := time.Unix(0, 0)
42+ pb := &ProgressBar{
43+ lastShowAt: showAt,
44+ lastProgressAt: showAt,
45+ }
46+
47+ remaining := pb.timeoutRemainingLocked(showAt.Add(10 * time.Second))
48+
49+ require.Equal(t, 20*time.Second, remaining)
50+}
51+
52+func TestProgressBarTimeoutRemainingLocked_NegativeClampsToZero(t *testing.T) {
53+ showAt := time.Unix(0, 0)
54+ pb := &ProgressBar{
55+ lastShowAt: showAt,
56+ lastProgressAt: showAt.Add(5 * time.Second),
57+ }
58+
59+ remaining := pb.timeoutRemainingLocked(showAt.Add(36 * time.Second))
60+
61+ require.Zero(t, remaining)
62+}
@@ -28,3 +28,35 @@ func TestProgressBarTimeoutRemainingLocked_UsesShowTimeBeforeFirstProgress(t *te
28 28
29 require.Zero(t, remaining)29 require.Zero(t, remaining)
30 }30 }
31+
32+func TestProgressBarTimeoutRemainingLocked_ZeroTimestamps(t *testing.T) {
33+ pb := &ProgressBar{}
34+
35+ remaining := pb.timeoutRemainingLocked(time.Unix(10, 0))
36+
37+ require.Zero(t, remaining)
38+}
39+
40+func TestProgressBarTimeoutRemainingLocked_EqualTimestamps(t *testing.T) {
41+ showAt := time.Unix(0, 0)
42+ pb := &ProgressBar{
43+ lastShowAt: showAt,
44+ lastProgressAt: showAt,
45+ }
46+
47+ remaining := pb.timeoutRemainingLocked(showAt.Add(10 * time.Second))
48+
49+ require.Equal(t, 20*time.Second, remaining)
50+}
51+
52+func TestProgressBarTimeoutRemainingLocked_NegativeClampsToZero(t *testing.T) {
53+ showAt := time.Unix(0, 0)
54+ pb := &ProgressBar{
55+ lastShowAt: showAt,
56+ lastProgressAt: showAt.Add(5 * time.Second),
57+ }
58+
59+ remaining := pb.timeoutRemainingLocked(showAt.Add(36 * time.Second))
60+
61+ require.Zero(t, remaining)
62+}

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