download_handler.go RawHistoryFolder

1package webkit
2
3import (
4	"context"
5	"fmt"
6	"sync"
7
8	"github.com/bnema/dumber/internal/application/port"
9	downloadutil "github.com/bnema/dumber/internal/domain/download"
10	"github.com/bnema/dumber/internal/infrastructure/downloadruntime"
11	"github.com/bnema/dumber/internal/logging"
12	"github.com/bnema/puregotk/v4/glib"
13	"github.com/bnema/puregotk/v4/webkit"
14)
15
16// DownloadHandler manages WebKit downloads and notifies the UI layer.
17type DownloadHandler struct {
18	runtime *downloadruntime.Runtime
19	mu      sync.RWMutex
20}
21
22// NewDownloadHandler creates a new download handler.
23// The caller (ConfigureDownloads) must validate that preparer is non-nil.
24func NewDownloadHandler(
25	downloadPath string,
26	handler port.DownloadEventHandler,
27	preparer port.DownloadPreparer,
28) *DownloadHandler {
29	return &DownloadHandler{
30		runtime: downloadruntime.New(downloadPath, handler, preparer),
31	}
32}
33
34// downloadState tracks the state of a single download to prevent duplicate notifications.
35// This is allocated per-download to ensure proper isolation between concurrent downloads.
36type downloadState struct {
37	failed              bool
38	filename            string
39	destination         string
40	lastProgressPercent int
41	mu                  sync.Mutex
42}
43
44// HandleDownload sets up signal handlers for a new download.
45func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.Download) {
46	if download == nil {
47		return
48	}
49
50	h.mu.RLock()
51	runtime := h.runtime
52	h.mu.RUnlock()
53
54	// Track download state in a dedicated struct to ensure proper isolation.
55	// Each download gets its own state to prevent interference between concurrent downloads.
56	state := &downloadState{lastProgressPercent: -1}
57
58	decideDestCb := makeDecideDestinationCallback(ctx, runtime, state)
59	download.ConnectDecideDestination(&decideDestCb)
60
61	progressCb := makeProgressCallback(ctx, runtime, state)
62	download.ConnectReceivedData(&progressCb)
63
64	failedCb := makeFailedCallback(ctx, runtime, state)
65	download.ConnectFailed(&failedCb)
66
67	finishedCb := makeFinishedCallback(ctx, runtime, state)
68	download.ConnectFinished(&finishedCb)
69}
70
71func makeDecideDestinationCallback(
72	ctx context.Context,
73	runtime *downloadruntime.Runtime,
74	state *downloadState,
75) func(webkit.Download, string) bool {
76	log := logging.FromContext(ctx)
77
78	return func(d webkit.Download, suggestedFilename string) bool {
79		var response port.DownloadResponse
80		if resp := d.GetResponse(); resp != nil {
81			response = &uriResponseAdapter{resp: resp}
82		}
83
84		output, err := runtime.ResolveDestination(ctx, suggestedFilename, response)
85		if err != nil {
86			log.Error().Err(err).Msg("failed to prepare download destination")
87			d.Cancel()
88			return false
89		}
90
91		log.Debug().
92			Str("suggested", suggestedFilename).
93			Str("sanitized", output.Filename).
94			Str("destPath", output.DestinationPath).
95			Msg("setting download destination")
96
97		d.SetDestination(output.DestinationPath)
98
99		state.mu.Lock()
100		state.filename = output.Filename
101		state.destination = output.DestinationPath
102		state.lastProgressPercent = -1
103		state.mu.Unlock()
104
105		runtime.EmitStarted(ctx, output)
106		emitWebKitDownloadProgress(ctx, runtime, state, d)
107		return false
108	}
109}
110
111func makeProgressCallback(
112	ctx context.Context,
113	runtime *downloadruntime.Runtime,
114	state *downloadState,
115) func(webkit.Download, uint64) {
116	return func(d webkit.Download, _ uint64) {
117		emitWebKitDownloadProgress(ctx, runtime, state, d)
118	}
119}
120
121func makeFailedCallback(
122	ctx context.Context,
123	runtime *downloadruntime.Runtime,
124	state *downloadState,
125) func(webkit.Download, *glib.Error) {
126	return func(d webkit.Download, gerr *glib.Error) {
127		state.mu.Lock()
128		state.failed = true
129		state.mu.Unlock()
130
131		dest := d.GetDestination()
132		filename := downloadutil.ExtractFilenameFromDestination(dest)
133		downloadErr := fmt.Errorf("download failed: %s: %s", filename, gerr.MessageGo())
134		runtime.EmitFailed(ctx, filename, dest, downloadErr)
135	}
136}
137
138func makeFinishedCallback(
139	ctx context.Context,
140	runtime *downloadruntime.Runtime,
141	state *downloadState,
142) func(webkit.Download) {
143	return func(d webkit.Download) {
144		state.mu.Lock()
145		failed := state.failed
146		state.mu.Unlock()
147		if failed {
148			return
149		}
150
151		dest := d.GetDestination()
152		filename := downloadutil.ExtractFilenameFromDestination(dest)
153		runtime.EmitFinished(ctx, filename, dest)
154	}
155}
156
157func emitWebKitDownloadProgress(
158	ctx context.Context,
159	runtime *downloadruntime.Runtime,
160	state *downloadState,
161	d webkit.Download,
162) {
163	state.mu.Lock()
164	if state.failed {
165		state.mu.Unlock()
166		return
167	}
168	filename := state.filename
169	destination := state.destination
170	lastPercent := state.lastProgressPercent
171	state.mu.Unlock()
172	if filename == "" || destination == "" {
173		return
174	}
175
176	progress := d.GetEstimatedProgress()
177	if progress <= 0 || progress >= 1 {
178		return
179	}
180	percent := int(progress * 100)
181	if percent <= lastPercent {
182		return
183	}
184
185	state.mu.Lock()
186	if state.failed || percent <= state.lastProgressPercent {
187		state.mu.Unlock()
188		return
189	}
190	state.lastProgressPercent = percent
191	state.mu.Unlock()
192
193	runtime.EmitProgress(ctx, filename, destination, progress, int64(d.GetReceivedDataLength()), 0)
194}
195
196// SetDownloadPath updates the download directory.
197func (h *DownloadHandler) SetDownloadPath(path string) {
198	h.mu.Lock()
199	defer h.mu.Unlock()
200	h.runtime.SetDownloadPath(path)
201}
202
203// uriResponseAdapter wraps webkit.URIResponse to implement port.DownloadResponse.
204type uriResponseAdapter struct {
205	resp *webkit.URIResponse
206}
207
208func (a *uriResponseAdapter) GetMimeType() string {
209	if a.resp == nil {
210		return ""
211	}
212	return a.resp.GetMimeType()
213}
214
215func (a *uriResponseAdapter) GetSuggestedFilename() string {
216	if a.resp == nil {
217		return ""
218	}
219	return a.resp.GetSuggestedFilename()
220}
221
222func (a *uriResponseAdapter) GetUri() string {
223	if a.resp == nil {
224		return ""
225	}
226	return a.resp.GetUri()
227}

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