detached 1c4bed6

Merge branch 'main' into refactor/reactive-history-sidebar

commit
1c4bed672bfb802457e31053fb89d71181e84df2
author
bnema · · 2026-06-25 05:06 UTC
committer
GitHub · · 2026-06-25 05:06 UTC
merge of
5f2a4b4 + 961fbde · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
7 files · +214 -14
M .github/workflows/aur.yml +2 -2
@@ -23,7 +23,7 @@ jobs:
2323 cancel-in-progress: true
2424
2525 steps:
26- - uses: actions/checkout@v6
26+ - uses: actions/checkout@v7
2727 with:
2828 ref: ${{ github.event.workflow_run.head_sha }}
2929 fetch-depth: 0
@@ -123,7 +123,7 @@ jobs:
123123 cancel-in-progress: true
124124
125125 steps:
126- - uses: actions/checkout@v6
126+ - uses: actions/checkout@v7
127127 with:
128128 fetch-depth: 0
129129
@@ -23,7 +23,7 @@ jobs:
23 cancel-in-progress: true23 cancel-in-progress: true
24 24
25 steps:25 steps:
26- - uses: actions/checkout@v626+ - uses: actions/checkout@v7
27 with:27 with:
28 ref: ${{ github.event.workflow_run.head_sha }}28 ref: ${{ github.event.workflow_run.head_sha }}
29 fetch-depth: 029 fetch-depth: 0
@@ -123,7 +123,7 @@ jobs:
123 cancel-in-progress: true123 cancel-in-progress: true
124 124
125 steps:125 steps:
126- - uses: actions/checkout@v6126+ - uses: actions/checkout@v7
127 with:127 with:
128 fetch-depth: 0128 fetch-depth: 0
129 129
M .github/workflows/ci.yml +13 -8
@@ -5,7 +5,7 @@ permissions:
55
66 on:
77 push:
8- branches: [main]
8+ branches: [main, next]
99 paths:
1010 - '**.go'
1111 - 'go.mod'
@@ -20,15 +20,20 @@ on:
2020 jobs:
2121 changes:
2222 runs-on: ubuntu-latest
23+ permissions:
24+ contents: read
25+ # dorny/paths-filter uses PR metadata to compute changed files.
26+ pull-requests: read
2327 outputs:
2428 ci: ${{ steps.filter.outputs.ci }}
2529 steps:
26- - uses: actions/checkout@v6
30+ - uses: actions/checkout@v7
2731 with:
2832 fetch-depth: 0
2933 - uses: dorny/paths-filter@v4
3034 id: filter
3135 with:
36+ base: ${{ github.event_name == 'push' && github.ref_name || '' }}
3237 filters: |
3338 ci:
3439 - '**.go'
@@ -51,7 +56,7 @@ jobs:
5156 run: |
5257 pacman -Syu --noconfirm
5358 pacman -S --noconfirm git go webkitgtk-6.0 gtk4 gobject-introspection base-devel brotli
54- - uses: actions/checkout@v6
59+ - uses: actions/checkout@v7
5560 - name: Allow git commands in container checkout
5661 run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
5762 - name: Set up pinned Go toolchain
@@ -60,14 +65,14 @@ jobs:
6065 go-version-file: go.mod
6166 cache: false
6267 - name: Cache Go modules
63- uses: actions/cache@v5
68+ uses: actions/cache@v6
6469 with:
6570 path: ~/go/pkg/mod
6671 key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }}
6772 restore-keys: |
6873 ${{ runner.os }}-go-mod-
6974 - name: Cache Go build cache
70- uses: actions/cache@v5
75+ uses: actions/cache@v6
7176 with:
7277 path: ~/.cache/go-build
7378 key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }}
@@ -95,21 +100,21 @@ jobs:
95100 run: |
96101 pacman -Syu --noconfirm
97102 pacman -S --noconfirm git go webkitgtk-6.0 gtk4 libadwaita gobject-introspection base-devel brotli dbus
98- - uses: actions/checkout@v6
103+ - uses: actions/checkout@v7
99104 - name: Set up pinned Go toolchain
100105 uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c
101106 with:
102107 go-version-file: go.mod
103108 cache: false
104109 - name: Cache Go modules
105- uses: actions/cache@v5
110+ uses: actions/cache@v6
106111 with:
107112 path: ~/go/pkg/mod
108113 key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }}
109114 restore-keys: |
110115 ${{ runner.os }}-go-mod-
111116 - name: Cache Go build cache
112- uses: actions/cache@v5
117+ uses: actions/cache@v6
113118 with:
114119 path: ~/.cache/go-build
115120 key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }}
@@ -5,7 +5,7 @@ permissions:
5 5
6 on:6 on:
7 push:7 push:
8- branches: [main]8+ branches: [main, next]
9 paths:9 paths:
10 - '**.go'10 - '**.go'
11 - 'go.mod'11 - 'go.mod'
@@ -20,15 +20,20 @@ on:
20 jobs:20 jobs:
21 changes:21 changes:
22 runs-on: ubuntu-latest22 runs-on: ubuntu-latest
23+ permissions:
24+ contents: read
25+ # dorny/paths-filter uses PR metadata to compute changed files.
26+ pull-requests: read
23 outputs:27 outputs:
24 ci: ${{ steps.filter.outputs.ci }}28 ci: ${{ steps.filter.outputs.ci }}
25 steps:29 steps:
26- - uses: actions/checkout@v630+ - uses: actions/checkout@v7
27 with:31 with:
28 fetch-depth: 032 fetch-depth: 0
29 - uses: dorny/paths-filter@v433 - uses: dorny/paths-filter@v4
30 id: filter34 id: filter
31 with:35 with:
36+ base: ${{ github.event_name == 'push' && github.ref_name || '' }}
32 filters: |37 filters: |
33 ci:38 ci:
34 - '**.go'39 - '**.go'
@@ -51,7 +56,7 @@ jobs:
51 run: |56 run: |
52 pacman -Syu --noconfirm57 pacman -Syu --noconfirm
53 pacman -S --noconfirm git go webkitgtk-6.0 gtk4 gobject-introspection base-devel brotli58 pacman -S --noconfirm git go webkitgtk-6.0 gtk4 gobject-introspection base-devel brotli
54- - uses: actions/checkout@v659+ - uses: actions/checkout@v7
55 - name: Allow git commands in container checkout60 - name: Allow git commands in container checkout
56 run: git config --global --add safe.directory "$GITHUB_WORKSPACE"61 run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
57 - name: Set up pinned Go toolchain62 - name: Set up pinned Go toolchain
@@ -60,14 +65,14 @@ jobs:
60 go-version-file: go.mod65 go-version-file: go.mod
61 cache: false66 cache: false
62 - name: Cache Go modules67 - name: Cache Go modules
63- uses: actions/cache@v568+ uses: actions/cache@v6
64 with:69 with:
65 path: ~/go/pkg/mod70 path: ~/go/pkg/mod
66 key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }}71 key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }}
67 restore-keys: |72 restore-keys: |
68 ${{ runner.os }}-go-mod-73 ${{ runner.os }}-go-mod-
69 - name: Cache Go build cache74 - name: Cache Go build cache
70- uses: actions/cache@v575+ uses: actions/cache@v6
71 with:76 with:
72 path: ~/.cache/go-build77 path: ~/.cache/go-build
73 key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }}78 key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }}
@@ -95,21 +100,21 @@ jobs:
95 run: |100 run: |
96 pacman -Syu --noconfirm101 pacman -Syu --noconfirm
97 pacman -S --noconfirm git go webkitgtk-6.0 gtk4 libadwaita gobject-introspection base-devel brotli dbus102 pacman -S --noconfirm git go webkitgtk-6.0 gtk4 libadwaita gobject-introspection base-devel brotli dbus
98- - uses: actions/checkout@v6103+ - uses: actions/checkout@v7
99 - name: Set up pinned Go toolchain104 - name: Set up pinned Go toolchain
100 uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c105 uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c
101 with:106 with:
102 go-version-file: go.mod107 go-version-file: go.mod
103 cache: false108 cache: false
104 - name: Cache Go modules109 - name: Cache Go modules
105- uses: actions/cache@v5110+ uses: actions/cache@v6
106 with:111 with:
107 path: ~/go/pkg/mod112 path: ~/go/pkg/mod
108 key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }}113 key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }}
109 restore-keys: |114 restore-keys: |
110 ${{ runner.os }}-go-mod-115 ${{ runner.os }}-go-mod-
111 - name: Cache Go build cache116 - name: Cache Go build cache
112- uses: actions/cache@v5117+ uses: actions/cache@v6
113 with:118 with:
114 path: ~/.cache/go-build119 path: ~/.cache/go-build
115 key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }}120 key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }}
M .github/workflows/flatpak.yml +1 -1
@@ -21,7 +21,7 @@ jobs:
2121 image: ghcr.io/flathub-infra/flatpak-github-actions:gnome-48
2222 options: --privileged
2323 steps:
24- - uses: actions/checkout@v6
24+ - uses: actions/checkout@v7
2525
2626 - name: Get version
2727 id: version
@@ -21,7 +21,7 @@ jobs:
21 image: ghcr.io/flathub-infra/flatpak-github-actions:gnome-4821 image: ghcr.io/flathub-infra/flatpak-github-actions:gnome-48
22 options: --privileged22 options: --privileged
23 steps:23 steps:
24- - uses: actions/checkout@v624+ - uses: actions/checkout@v7
25 25
26 - name: Get version26 - name: Get version
27 id: version27 id: version
A .github/workflows/release-paths.yml +69 -0
@@ -0,0 +1,69 @@
1+name: Release Paths
2+
3+permissions:
4+ contents: read
5+
6+on:
7+ push:
8+ branches: [main, next]
9+ paths:
10+ - 'docs/**'
11+ - '*.md'
12+ - 'install.sh'
13+ - 'scripts/**'
14+ - 'flatpak/**'
15+ - 'aur/**'
16+ - '.goreleaser.yaml'
17+ - '.github/workflows/aur.yml'
18+ - '.github/workflows/release-paths.yml'
19+ pull_request:
20+ branches: [main, next]
21+ paths:
22+ - 'docs/**'
23+ - '*.md'
24+ - 'install.sh'
25+ - 'scripts/**'
26+ - 'flatpak/**'
27+ - 'aur/**'
28+ - '.goreleaser.yaml'
29+ - '.github/workflows/aur.yml'
30+ - '.github/workflows/release-paths.yml'
31+
32+jobs:
33+ validate:
34+ runs-on: ubuntu-latest
35+ concurrency:
36+ group: release-paths-${{ github.event.pull_request.number || github.ref }}
37+ cancel-in-progress: true
38+ steps:
39+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
40+ with:
41+ persist-credentials: false
42+ - name: Validate shell entrypoints
43+ run: |
44+ shopt -s nullglob globstar
45+ files=(install.sh scripts/**/*.sh aur/*/PKGBUILD)
46+ for f in "${files[@]}"; do
47+ bash -n "$f"
48+ done
49+ - name: Validate release YAML
50+ run: |
51+ ruby -e 'require "psych"; Psych.safe_load_file(".goreleaser.yaml", permitted_classes: [], aliases: true); puts "validated .goreleaser.yaml"'
52+ ruby -e 'require "psych"; Psych.safe_load_file(".github/workflows/aur.yml", permitted_classes: [], aliases: true); puts "validated .github/workflows/aur.yml"'
53+ ruby -e 'require "psych"; Psych.safe_load_file(".github/workflows/release-paths.yml", permitted_classes: [], aliases: true); puts "validated .github/workflows/release-paths.yml"'
54+ - name: Validate Flatpak metadata XML
55+ run: |
56+ python3 - <<'PY'
57+ from pathlib import Path
58+ import xml.etree.ElementTree as ET
59+
60+ for path in Path('flatpak').glob('*.xml'):
61+ ET.parse(path)
62+ print(f'validated {path}')
63+ PY
64+ - name: Check docs and packaging conflict markers
65+ run: |
66+ if grep -RInE '^(<<<<<<<|>>>>>>>)([[:space:]].*)?$' *.md .goreleaser.yaml docs install.sh scripts flatpak aur; then
67+ echo 'Found conflict marker in docs or release-adjacent files' >&2
68+ exit 1
69+ fi
@@ -0,0 +1,69 @@
1+name: Release Paths
2+
3+permissions:
4+ contents: read
5+
6+on:
7+ push:
8+ branches: [main, next]
9+ paths:
10+ - 'docs/**'
11+ - '*.md'
12+ - 'install.sh'
13+ - 'scripts/**'
14+ - 'flatpak/**'
15+ - 'aur/**'
16+ - '.goreleaser.yaml'
17+ - '.github/workflows/aur.yml'
18+ - '.github/workflows/release-paths.yml'
19+ pull_request:
20+ branches: [main, next]
21+ paths:
22+ - 'docs/**'
23+ - '*.md'
24+ - 'install.sh'
25+ - 'scripts/**'
26+ - 'flatpak/**'
27+ - 'aur/**'
28+ - '.goreleaser.yaml'
29+ - '.github/workflows/aur.yml'
30+ - '.github/workflows/release-paths.yml'
31+
32+jobs:
33+ validate:
34+ runs-on: ubuntu-latest
35+ concurrency:
36+ group: release-paths-${{ github.event.pull_request.number || github.ref }}
37+ cancel-in-progress: true
38+ steps:
39+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
40+ with:
41+ persist-credentials: false
42+ - name: Validate shell entrypoints
43+ run: |
44+ shopt -s nullglob globstar
45+ files=(install.sh scripts/**/*.sh aur/*/PKGBUILD)
46+ for f in "${files[@]}"; do
47+ bash -n "$f"
48+ done
49+ - name: Validate release YAML
50+ run: |
51+ ruby -e 'require "psych"; Psych.safe_load_file(".goreleaser.yaml", permitted_classes: [], aliases: true); puts "validated .goreleaser.yaml"'
52+ ruby -e 'require "psych"; Psych.safe_load_file(".github/workflows/aur.yml", permitted_classes: [], aliases: true); puts "validated .github/workflows/aur.yml"'
53+ ruby -e 'require "psych"; Psych.safe_load_file(".github/workflows/release-paths.yml", permitted_classes: [], aliases: true); puts "validated .github/workflows/release-paths.yml"'
54+ - name: Validate Flatpak metadata XML
55+ run: |
56+ python3 - <<'PY'
57+ from pathlib import Path
58+ import xml.etree.ElementTree as ET
59+
60+ for path in Path('flatpak').glob('*.xml'):
61+ ET.parse(path)
62+ print(f'validated {path}')
63+ PY
64+ - name: Check docs and packaging conflict markers
65+ run: |
66+ if grep -RInE '^(<<<<<<<|>>>>>>>)([[:space:]].*)?$' *.md .goreleaser.yaml docs install.sh scripts flatpak aur; then
67+ echo 'Found conflict marker in docs or release-adjacent files' >&2
68+ exit 1
69+ fi
M .github/workflows/release.yml +1 -1
@@ -13,7 +13,7 @@ jobs:
1313 release:
1414 runs-on: ubuntu-latest
1515 steps:
16- - uses: actions/checkout@v6
16+ - uses: actions/checkout@v7
1717 with:
1818 fetch-depth: 0
1919
@@ -13,7 +13,7 @@ jobs:
13 release:13 release:
14 runs-on: ubuntu-latest14 runs-on: ubuntu-latest
15 steps:15 steps:
16- - uses: actions/checkout@v616+ - uses: actions/checkout@v7
17 with:17 with:
18 fetch-depth: 018 fetch-depth: 0
19 19
M internal/infrastructure/cef/webview_handlers.go +43 -2
@@ -542,8 +542,49 @@ func (h *handlerSet) OnLoadEnd(_ purecef.Browser, frame purecef.Frame, httpStatu
542542 // CEF's OnLoadingProgressChange also provides progress=1.0 at completion.
543543 }
544544
545-// OnLoadError is a no-op.
546-func (h *handlerSet) OnLoadError(_ purecef.Browser, _ purecef.Frame, _ purecef.Errorcode, _, _ string) {
545+const cefErrAborted purecef.Errorcode = -3
546+
547+// OnLoadError handles failed main-frame navigations that never reach
548+// OnLoadStart. CEF still sends OnLoadingStateChange(false) after load errors,
549+// so this callback only synthesizes the missing committed event needed to reveal
550+// the pane; the normal browser-level loading-state callback remains responsible
551+// for the terminal LoadFinished state.
552+func (h *handlerSet) OnLoadError(_ purecef.Browser, frame purecef.Frame, errorCode purecef.Errorcode, errorText, failedURL string) {
553+ if h == nil || h.wv == nil || frame == nil || !frame.IsMain() {
554+ return
555+ }
556+ if errorCode == cefErrAborted {
557+ return
558+ }
559+ if failedURL == "" {
560+ failedURL = frame.GetURL()
561+ }
562+
563+ h.wv.mu.RLock()
564+ currentURI := h.wv.uri
565+ cb := h.wv.callbacks
566+ h.wv.mu.RUnlock()
567+
568+ if h.wv.ctx != nil {
569+ logging.FromContext(h.wv.ctx).Debug().
570+ Int32("error_code", errorCode).
571+ Str("error_text", errorText).
572+ Str("failed_url", logging.TruncateURL(failedURL, logging.PermissionLogURLMaxLen)).
573+ Msg("cef: OnLoadError")
574+ }
575+
576+ if failedURL != "" {
577+ h.wv.updateURI(failedURL)
578+ }
579+
580+ if cb == nil || cb.OnLoadChanged == nil {
581+ return
582+ }
583+ if failedURL != "" && !pendingURIEquivalent(currentURI, failedURL) {
584+ h.wv.runOnGTK(func() {
585+ cb.OnLoadChanged(port.LoadCommitted)
586+ })
587+ }
547588 }
548589
549590 func mapCEFWindowDisposition(disposition purecef.WindowOpenDisposition) dto.WindowDisposition {
@@ -542,8 +542,49 @@ func (h *handlerSet) OnLoadEnd(_ purecef.Browser, frame purecef.Frame, httpStatu
542 // CEF's OnLoadingProgressChange also provides progress=1.0 at completion.542 // CEF's OnLoadingProgressChange also provides progress=1.0 at completion.
543 }543 }
544 544
545-// OnLoadError is a no-op.545+const cefErrAborted purecef.Errorcode = -3
546-func (h *handlerSet) OnLoadError(_ purecef.Browser, _ purecef.Frame, _ purecef.Errorcode, _, _ string) {546+
547+// OnLoadError handles failed main-frame navigations that never reach
548+// OnLoadStart. CEF still sends OnLoadingStateChange(false) after load errors,
549+// so this callback only synthesizes the missing committed event needed to reveal
550+// the pane; the normal browser-level loading-state callback remains responsible
551+// for the terminal LoadFinished state.
552+func (h *handlerSet) OnLoadError(_ purecef.Browser, frame purecef.Frame, errorCode purecef.Errorcode, errorText, failedURL string) {
553+ if h == nil || h.wv == nil || frame == nil || !frame.IsMain() {
554+ return
555+ }
556+ if errorCode == cefErrAborted {
557+ return
558+ }
559+ if failedURL == "" {
560+ failedURL = frame.GetURL()
561+ }
562+
563+ h.wv.mu.RLock()
564+ currentURI := h.wv.uri
565+ cb := h.wv.callbacks
566+ h.wv.mu.RUnlock()
567+
568+ if h.wv.ctx != nil {
569+ logging.FromContext(h.wv.ctx).Debug().
570+ Int32("error_code", errorCode).
571+ Str("error_text", errorText).
572+ Str("failed_url", logging.TruncateURL(failedURL, logging.PermissionLogURLMaxLen)).
573+ Msg("cef: OnLoadError")
574+ }
575+
576+ if failedURL != "" {
577+ h.wv.updateURI(failedURL)
578+ }
579+
580+ if cb == nil || cb.OnLoadChanged == nil {
581+ return
582+ }
583+ if failedURL != "" && !pendingURIEquivalent(currentURI, failedURL) {
584+ h.wv.runOnGTK(func() {
585+ cb.OnLoadChanged(port.LoadCommitted)
586+ })
587+ }
547 }588 }
548 589
549 func mapCEFWindowDisposition(disposition purecef.WindowOpenDisposition) dto.WindowDisposition {590 func mapCEFWindowDisposition(disposition purecef.WindowOpenDisposition) dto.WindowDisposition {
M internal/infrastructure/cef/webview_handlers_test.go +85 -0
@@ -423,6 +423,91 @@ func TestOnLoadEndDoesNotDispatchBrowserLevelCompletion(t *testing.T) {
423423 assert.Empty(t, gotProgress)
424424 }
425425
426+func TestOnLoadErrorMainFrameCommitsFailedNavigation(t *testing.T) {
427+ wv := &WebView{ctx: context.Background()}
428+ wv.updateLoadState(true, true, false)
429+ var gotEvents []port.LoadEvent
430+ wv.SetCallbacks(&port.WebViewCallbacks{
431+ OnLoadChanged: func(event port.LoadEvent) {
432+ gotEvents = append(gotEvents, event)
433+ },
434+ })
435+
436+ h := &handlerSet{wv: wv}
437+ h.OnLoadError(nil, stubFrame{main: true, url: "http://localhost:9"}, -102, "CONNECTION_REFUSED", "http://localhost:9")
438+
439+ require.Equal(t, []port.LoadEvent{port.LoadCommitted}, gotEvents)
440+ require.Equal(t, "http://localhost:9", wv.URI())
441+ require.True(t, wv.IsLoading())
442+ require.True(t, wv.CanGoBack())
443+}
444+
445+func TestOnLoadErrorFollowedByLoadingStateFinishTerminatesNavigation(t *testing.T) {
446+ wv := &WebView{ctx: context.Background()}
447+ wv.updateLoadState(true, false, false)
448+ var gotEvents []port.LoadEvent
449+ wv.SetCallbacks(&port.WebViewCallbacks{
450+ OnLoadChanged: func(event port.LoadEvent) {
451+ gotEvents = append(gotEvents, event)
452+ },
453+ })
454+
455+ h := &handlerSet{wv: wv}
456+ h.OnLoadError(nil, stubFrame{main: true, url: "http://localhost:9"}, -102, "CONNECTION_REFUSED", "http://localhost:9")
457+ h.OnLoadingStateChange(nil, 0, 0, 0)
458+
459+ require.Equal(t, []port.LoadEvent{port.LoadCommitted, port.LoadFinished}, gotEvents)
460+ require.False(t, wv.IsLoading())
461+}
462+
463+func TestOnLoadErrorAfterLoadStartDoesNotDispatchDuplicateEvents(t *testing.T) {
464+ wv := &WebView{ctx: context.Background()}
465+ var gotEvents []port.LoadEvent
466+ wv.SetCallbacks(&port.WebViewCallbacks{
467+ OnLoadChanged: func(event port.LoadEvent) {
468+ gotEvents = append(gotEvents, event)
469+ },
470+ })
471+
472+ h := &handlerSet{wv: wv}
473+ h.OnLoadStart(nil, stubFrame{main: true, url: "http://localhost:9"}, 0)
474+ gotEvents = nil
475+ h.OnLoadError(nil, stubFrame{main: true, url: "http://localhost:9"}, -102, "CONNECTION_REFUSED", "http://localhost:9")
476+
477+ require.Empty(t, gotEvents)
478+}
479+
480+func TestOnLoadErrorAbortedNavigationDoesNotCommit(t *testing.T) {
481+ wv := &WebView{ctx: context.Background()}
482+ var gotEvents []port.LoadEvent
483+ wv.SetCallbacks(&port.WebViewCallbacks{
484+ OnLoadChanged: func(event port.LoadEvent) {
485+ gotEvents = append(gotEvents, event)
486+ },
487+ })
488+
489+ h := &handlerSet{wv: wv}
490+ h.OnLoadError(nil, stubFrame{main: true, url: "https://example.com/file.zip"}, cefErrAborted, "ERR_ABORTED", "https://example.com/file.zip")
491+
492+ require.Empty(t, gotEvents)
493+ require.Empty(t, wv.URI())
494+}
495+
496+func TestOnLoadErrorSubFrameDoesNotTerminateNavigation(t *testing.T) {
497+ wv := &WebView{ctx: context.Background()}
498+ var gotEvents []port.LoadEvent
499+ wv.SetCallbacks(&port.WebViewCallbacks{
500+ OnLoadChanged: func(event port.LoadEvent) {
501+ gotEvents = append(gotEvents, event)
502+ },
503+ })
504+
505+ h := &handlerSet{wv: wv}
506+ h.OnLoadError(nil, stubFrame{main: false, url: "http://localhost:9/frame"}, -102, "CONNECTION_REFUSED", "http://localhost:9/frame")
507+
508+ assert.Empty(t, gotEvents)
509+}
510+
426511 func TestOnTextSelectionChanged_ForwardsSelectionToClipboardOrchestrator(t *testing.T) {
427512 recorder := &clipboardOrchestratorRecorder{}
428513 orchestrator := portmocks.NewMockClipboardTextOrchestrator(t)
@@ -423,6 +423,91 @@ func TestOnLoadEndDoesNotDispatchBrowserLevelCompletion(t *testing.T) {
423 assert.Empty(t, gotProgress)423 assert.Empty(t, gotProgress)
424 }424 }
425 425
426+func TestOnLoadErrorMainFrameCommitsFailedNavigation(t *testing.T) {
427+ wv := &WebView{ctx: context.Background()}
428+ wv.updateLoadState(true, true, false)
429+ var gotEvents []port.LoadEvent
430+ wv.SetCallbacks(&port.WebViewCallbacks{
431+ OnLoadChanged: func(event port.LoadEvent) {
432+ gotEvents = append(gotEvents, event)
433+ },
434+ })
435+
436+ h := &handlerSet{wv: wv}
437+ h.OnLoadError(nil, stubFrame{main: true, url: "http://localhost:9"}, -102, "CONNECTION_REFUSED", "http://localhost:9")
438+
439+ require.Equal(t, []port.LoadEvent{port.LoadCommitted}, gotEvents)
440+ require.Equal(t, "http://localhost:9", wv.URI())
441+ require.True(t, wv.IsLoading())
442+ require.True(t, wv.CanGoBack())
443+}
444+
445+func TestOnLoadErrorFollowedByLoadingStateFinishTerminatesNavigation(t *testing.T) {
446+ wv := &WebView{ctx: context.Background()}
447+ wv.updateLoadState(true, false, false)
448+ var gotEvents []port.LoadEvent
449+ wv.SetCallbacks(&port.WebViewCallbacks{
450+ OnLoadChanged: func(event port.LoadEvent) {
451+ gotEvents = append(gotEvents, event)
452+ },
453+ })
454+
455+ h := &handlerSet{wv: wv}
456+ h.OnLoadError(nil, stubFrame{main: true, url: "http://localhost:9"}, -102, "CONNECTION_REFUSED", "http://localhost:9")
457+ h.OnLoadingStateChange(nil, 0, 0, 0)
458+
459+ require.Equal(t, []port.LoadEvent{port.LoadCommitted, port.LoadFinished}, gotEvents)
460+ require.False(t, wv.IsLoading())
461+}
462+
463+func TestOnLoadErrorAfterLoadStartDoesNotDispatchDuplicateEvents(t *testing.T) {
464+ wv := &WebView{ctx: context.Background()}
465+ var gotEvents []port.LoadEvent
466+ wv.SetCallbacks(&port.WebViewCallbacks{
467+ OnLoadChanged: func(event port.LoadEvent) {
468+ gotEvents = append(gotEvents, event)
469+ },
470+ })
471+
472+ h := &handlerSet{wv: wv}
473+ h.OnLoadStart(nil, stubFrame{main: true, url: "http://localhost:9"}, 0)
474+ gotEvents = nil
475+ h.OnLoadError(nil, stubFrame{main: true, url: "http://localhost:9"}, -102, "CONNECTION_REFUSED", "http://localhost:9")
476+
477+ require.Empty(t, gotEvents)
478+}
479+
480+func TestOnLoadErrorAbortedNavigationDoesNotCommit(t *testing.T) {
481+ wv := &WebView{ctx: context.Background()}
482+ var gotEvents []port.LoadEvent
483+ wv.SetCallbacks(&port.WebViewCallbacks{
484+ OnLoadChanged: func(event port.LoadEvent) {
485+ gotEvents = append(gotEvents, event)
486+ },
487+ })
488+
489+ h := &handlerSet{wv: wv}
490+ h.OnLoadError(nil, stubFrame{main: true, url: "https://example.com/file.zip"}, cefErrAborted, "ERR_ABORTED", "https://example.com/file.zip")
491+
492+ require.Empty(t, gotEvents)
493+ require.Empty(t, wv.URI())
494+}
495+
496+func TestOnLoadErrorSubFrameDoesNotTerminateNavigation(t *testing.T) {
497+ wv := &WebView{ctx: context.Background()}
498+ var gotEvents []port.LoadEvent
499+ wv.SetCallbacks(&port.WebViewCallbacks{
500+ OnLoadChanged: func(event port.LoadEvent) {
501+ gotEvents = append(gotEvents, event)
502+ },
503+ })
504+
505+ h := &handlerSet{wv: wv}
506+ h.OnLoadError(nil, stubFrame{main: false, url: "http://localhost:9/frame"}, -102, "CONNECTION_REFUSED", "http://localhost:9/frame")
507+
508+ assert.Empty(t, gotEvents)
509+}
510+
426 func TestOnTextSelectionChanged_ForwardsSelectionToClipboardOrchestrator(t *testing.T) {511 func TestOnTextSelectionChanged_ForwardsSelectionToClipboardOrchestrator(t *testing.T) {
427 recorder := &clipboardOrchestratorRecorder{}512 recorder := &clipboardOrchestratorRecorder{}
428 orchestrator := portmocks.NewMockClipboardTextOrchestrator(t)513 orchestrator := portmocks.NewMockClipboardTextOrchestrator(t)

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