detached 401758e

feat(download): keep toast alive and show progress

commit
401758e5122193bf4f81f5f248bdb8e420e24b6d
author
brice · · 2026-04-19 20:09 UTC
parent
db3c5c7
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +470 -61
M internal/application/port/download.go +9 -4
@@ -8,6 +8,8 @@ type DownloadEventType int
88 const (
99 // DownloadEventStarted indicates a download has begun.
1010 DownloadEventStarted DownloadEventType = iota
11+ // DownloadEventProgress indicates download progress has advanced.
12+ DownloadEventProgress
1113 // DownloadEventFinished indicates a download completed successfully.
1214 DownloadEventFinished
1315 // DownloadEventFailed indicates a download failed.
@@ -16,10 +18,13 @@ const (
1618
1719 // DownloadEvent contains information about a download event.
1820 type DownloadEvent struct {
19- Type DownloadEventType
20- Filename string
21- Destination string
22- Error error // Set when Type is DownloadEventFailed
21+ Type DownloadEventType
22+ Filename string
23+ Destination string
24+ Progress float64 // Set when Type is DownloadEventProgress, normalized to 0.0-1.0.
25+ BytesReceived int64 // Best-effort received byte count for progress updates.
26+ BytesTotal int64 // Best-effort total byte count for progress updates when known.
27+ Error error // Set when Type is DownloadEventFailed
2328 }
2429
2530 // DownloadEventHandler receives download event notifications.
@@ -8,6 +8,8 @@ type DownloadEventType int
8 const (8 const (
9 // DownloadEventStarted indicates a download has begun.9 // DownloadEventStarted indicates a download has begun.
10 DownloadEventStarted DownloadEventType = iota10 DownloadEventStarted DownloadEventType = iota
11+ // DownloadEventProgress indicates download progress has advanced.
12+ DownloadEventProgress
11 // DownloadEventFinished indicates a download completed successfully.13 // DownloadEventFinished indicates a download completed successfully.
12 DownloadEventFinished14 DownloadEventFinished
13 // DownloadEventFailed indicates a download failed.15 // DownloadEventFailed indicates a download failed.
@@ -16,10 +18,13 @@ const (
16 18
17 // DownloadEvent contains information about a download event.19 // DownloadEvent contains information about a download event.
18 type DownloadEvent struct {20 type DownloadEvent struct {
19- Type DownloadEventType21+ Type DownloadEventType
20- Filename string22+ Filename string
21- Destination string23+ Destination string
22- Error error // Set when Type is DownloadEventFailed24+ Progress float64 // Set when Type is DownloadEventProgress, normalized to 0.0-1.0.
25+ BytesReceived int64 // Best-effort received byte count for progress updates.
26+ BytesTotal int64 // Best-effort total byte count for progress updates when known.
27+ Error error // Set when Type is DownloadEventFailed
23 }28 }
24 29
25 // DownloadEventHandler receives download event notifications.30 // DownloadEventHandler receives download event notifications.
M internal/infrastructure/cef/download_handler.go +61 -4
@@ -27,8 +27,9 @@ type downloadHandler struct {
2727 }
2828
2929 type cefDownloadState struct {
30- filename string
31- destination string
30+ filename string
31+ destination string
32+ lastProgressPercent int32
3233 }
3334
3435 type cefDownloadInterruptError struct {
@@ -134,8 +135,9 @@ func (h *downloadHandler) onBeforeDownload(
134135
135136 h.mu.Lock()
136137 h.active[downloadItem.GetID()] = cefDownloadState{
137- filename: output.Filename,
138- destination: output.DestinationPath,
138+ filename: output.Filename,
139+ destination: output.DestinationPath,
140+ lastProgressPercent: -1,
139141 }
140142 h.mu.Unlock()
141143
@@ -166,6 +168,7 @@ func (h *downloadHandler) onDownloadUpdated(
166168 }
167169
168170 state := h.currentState(id, downloadItem)
171+ h.emitProgressIfNeeded(ctx, id, downloadItem, state)
169172
170173 switch {
171174 case downloadItem.IsComplete():
@@ -208,6 +211,60 @@ func (h *downloadHandler) currentState(id uint32, item purecef.DownloadItem) cef
208211 return state
209212 }
210213
214+func (h *downloadHandler) emitProgressIfNeeded(
215+ ctx context.Context,
216+ id uint32,
217+ item purecef.DownloadItem,
218+ state cefDownloadState,
219+) {
220+ percent, ok := downloadProgressPercentFromItem(item)
221+ if !ok {
222+ return
223+ }
224+
225+ h.mu.Lock()
226+ current, exists := h.active[id]
227+ if !exists {
228+ current = state
229+ }
230+ if current.lastProgressPercent == percent {
231+ h.mu.Unlock()
232+ return
233+ }
234+ current.lastProgressPercent = percent
235+ h.active[id] = current
236+ h.mu.Unlock()
237+
238+ h.runtime.EmitProgress(
239+ ctx,
240+ current.filename,
241+ current.destination,
242+ float64(percent)/100,
243+ item.GetReceivedBytes(),
244+ item.GetTotalBytes(),
245+ )
246+}
247+
248+func downloadProgressPercentFromItem(item purecef.DownloadItem) (int32, bool) {
249+ if item == nil || item.IsComplete() || item.IsCanceled() || item.IsInterrupted() {
250+ return 0, false
251+ }
252+
253+ percent := item.GetPercentComplete()
254+ if percent < 0 {
255+ total := item.GetTotalBytes()
256+ received := item.GetReceivedBytes()
257+ if total <= 0 || received <= 0 {
258+ return 0, false
259+ }
260+ percent = int32((received * 100) / total)
261+ }
262+ if percent <= 0 || percent >= 100 {
263+ return 0, false
264+ }
265+ return percent, true
266+}
267+
211268 func (h *downloadHandler) markFinished(id uint32) bool {
212269 h.mu.Lock()
213270 defer h.mu.Unlock()
@@ -27,8 +27,9 @@ type downloadHandler struct {
27 }27 }
28 28
29 type cefDownloadState struct {29 type cefDownloadState struct {
30- filename string30+ filename string
31- destination string31+ destination string
32+ lastProgressPercent int32
32 }33 }
33 34
34 type cefDownloadInterruptError struct {35 type cefDownloadInterruptError struct {
@@ -134,8 +135,9 @@ func (h *downloadHandler) onBeforeDownload(
134 135
135 h.mu.Lock()136 h.mu.Lock()
136 h.active[downloadItem.GetID()] = cefDownloadState{137 h.active[downloadItem.GetID()] = cefDownloadState{
137- filename: output.Filename,138+ filename: output.Filename,
138- destination: output.DestinationPath,139+ destination: output.DestinationPath,
140+ lastProgressPercent: -1,
139 }141 }
140 h.mu.Unlock()142 h.mu.Unlock()
141 143
@@ -166,6 +168,7 @@ func (h *downloadHandler) onDownloadUpdated(
166 }168 }
167 169
168 state := h.currentState(id, downloadItem)170 state := h.currentState(id, downloadItem)
171+ h.emitProgressIfNeeded(ctx, id, downloadItem, state)
169 172
170 switch {173 switch {
171 case downloadItem.IsComplete():174 case downloadItem.IsComplete():
@@ -208,6 +211,60 @@ func (h *downloadHandler) currentState(id uint32, item purecef.DownloadItem) cef
208 return state211 return state
209 }212 }
210 213
214+func (h *downloadHandler) emitProgressIfNeeded(
215+ ctx context.Context,
216+ id uint32,
217+ item purecef.DownloadItem,
218+ state cefDownloadState,
219+) {
220+ percent, ok := downloadProgressPercentFromItem(item)
221+ if !ok {
222+ return
223+ }
224+
225+ h.mu.Lock()
226+ current, exists := h.active[id]
227+ if !exists {
228+ current = state
229+ }
230+ if current.lastProgressPercent == percent {
231+ h.mu.Unlock()
232+ return
233+ }
234+ current.lastProgressPercent = percent
235+ h.active[id] = current
236+ h.mu.Unlock()
237+
238+ h.runtime.EmitProgress(
239+ ctx,
240+ current.filename,
241+ current.destination,
242+ float64(percent)/100,
243+ item.GetReceivedBytes(),
244+ item.GetTotalBytes(),
245+ )
246+}
247+
248+func downloadProgressPercentFromItem(item purecef.DownloadItem) (int32, bool) {
249+ if item == nil || item.IsComplete() || item.IsCanceled() || item.IsInterrupted() {
250+ return 0, false
251+ }
252+
253+ percent := item.GetPercentComplete()
254+ if percent < 0 {
255+ total := item.GetTotalBytes()
256+ received := item.GetReceivedBytes()
257+ if total <= 0 || received <= 0 {
258+ return 0, false
259+ }
260+ percent = int32((received * 100) / total)
261+ }
262+ if percent <= 0 || percent >= 100 {
263+ return 0, false
264+ }
265+ return percent, true
266+}
267+
211 func (h *downloadHandler) markFinished(id uint32) bool {268 func (h *downloadHandler) markFinished(id uint32) bool {
212 h.mu.Lock()269 h.mu.Lock()
213 defer h.mu.Unlock()270 defer h.mu.Unlock()
M internal/infrastructure/cef/download_handler_test.go +51 -13
@@ -12,15 +12,18 @@ import (
1212 )
1313
1414 type stubDownloadItem struct {
15- id uint32
16- url string
17- suggested string
18- mimeType string
19- fullPath string
20- complete bool
21- canceled bool
22- interrupted bool
23- reason purecef.DownloadInterruptReason
15+ id uint32
16+ url string
17+ suggested string
18+ mimeType string
19+ fullPath string
20+ complete bool
21+ canceled bool
22+ interrupted bool
23+ reason purecef.DownloadInterruptReason
24+ percentComplete int32
25+ totalBytes int64
26+ receivedBytes int64
2427 }
2528
2629 func (s stubDownloadItem) IsValid() bool { return true }
@@ -32,9 +35,9 @@ func (s stubDownloadItem) GetInterruptReason() purecef.DownloadInterruptReason {
3235 return s.reason
3336 }
3437 func (s stubDownloadItem) GetCurrentSpeed() int64 { return 0 }
35-func (s stubDownloadItem) GetPercentComplete() int32 { return 0 }
36-func (s stubDownloadItem) GetTotalBytes() int64 { return 0 }
37-func (s stubDownloadItem) GetReceivedBytes() int64 { return 0 }
38+func (s stubDownloadItem) GetPercentComplete() int32 { return s.percentComplete }
39+func (s stubDownloadItem) GetTotalBytes() int64 { return s.totalBytes }
40+func (s stubDownloadItem) GetReceivedBytes() int64 { return s.receivedBytes }
3841 func (s stubDownloadItem) GetStartTime() uintptr { return 0 }
3942 func (s stubDownloadItem) GetEndTime() uintptr { return 0 }
4043 func (s stubDownloadItem) GetFullPath() string { return s.fullPath }
@@ -120,7 +123,7 @@ func TestMarkFinished_SuppressesDuplicatesAfterCleanup(t *testing.T) {
120123
121124 // Simulate a download that was tracked through onBeforeDownload.
122125 handler.mu.Lock()
123- handler.active[42] = cefDownloadState{filename: "test.bin", destination: "/tmp/test.bin"}
126+ handler.active[42] = cefDownloadState{filename: "test.bin", destination: "/tmp/test.bin", lastProgressPercent: -1}
124127 handler.mu.Unlock()
125128
126129 require.True(t, handler.markFinished(42), "first terminal event should succeed")
@@ -133,6 +136,41 @@ func TestMarkFinished_SuppressesDuplicatesAfterCleanup(t *testing.T) {
133136 require.False(t, inActive)
134137 }
135138
139+func TestCEFDownloadHandlerEmitsProgressOncePerPercent(t *testing.T) {
140+ ctx := context.Background()
141+ preparer := usecase.NewPrepareDownloadUseCase(nil)
142+ events := &captureDownloadEvents{}
143+ handler := newDownloadHandler("/tmp/downloads", events, preparer)
144+ callback := &stubBeforeDownloadCallback{}
145+
146+ item := stubDownloadItem{
147+ id: 11,
148+ url: "https://example.com/archive.zip",
149+ suggested: "archive.zip",
150+ mimeType: "application/zip",
151+ }
152+
153+ require.True(t, handler.onBeforeDownload(ctx, nil, item, item.suggested, callback))
154+
155+ item.fullPath = callback.path
156+ item.percentComplete = 12
157+ item.receivedBytes = 12
158+ item.totalBytes = 100
159+ handler.onDownloadUpdated(ctx, item, nil)
160+ handler.onDownloadUpdated(ctx, item, nil)
161+
162+ item.percentComplete = 13
163+ item.receivedBytes = 13
164+ handler.onDownloadUpdated(ctx, item, nil)
165+
166+ require.Len(t, events.events, 3)
167+ require.Equal(t, port.DownloadEventStarted, events.events[0].Type)
168+ require.Equal(t, port.DownloadEventProgress, events.events[1].Type)
169+ require.InEpsilon(t, 0.12, events.events[1].Progress, 0.0001)
170+ require.Equal(t, port.DownloadEventProgress, events.events[2].Type)
171+ require.InEpsilon(t, 0.13, events.events[2].Progress, 0.0001)
172+}
173+
136174 func TestCEFDownloadInterruptedErrorIncludesCodeAndReason(t *testing.T) {
137175 ctx := context.Background()
138176 preparer := usecase.NewPrepareDownloadUseCase(nil)
@@ -12,15 +12,18 @@ import (
12 )12 )
13 13
14 type stubDownloadItem struct {14 type stubDownloadItem struct {
15- id uint3215+ id uint32
16- url string16+ url string
17- suggested string17+ suggested string
18- mimeType string18+ mimeType string
19- fullPath string19+ fullPath string
20- complete bool20+ complete bool
21- canceled bool21+ canceled bool
22- interrupted bool22+ interrupted bool
23- reason purecef.DownloadInterruptReason23+ reason purecef.DownloadInterruptReason
24+ percentComplete int32
25+ totalBytes int64
26+ receivedBytes int64
24 }27 }
25 28
26 func (s stubDownloadItem) IsValid() bool { return true }29 func (s stubDownloadItem) IsValid() bool { return true }
@@ -32,9 +35,9 @@ func (s stubDownloadItem) GetInterruptReason() purecef.DownloadInterruptReason {
32 return s.reason35 return s.reason
33 }36 }
34 func (s stubDownloadItem) GetCurrentSpeed() int64 { return 0 }37 func (s stubDownloadItem) GetCurrentSpeed() int64 { return 0 }
35-func (s stubDownloadItem) GetPercentComplete() int32 { return 0 }38+func (s stubDownloadItem) GetPercentComplete() int32 { return s.percentComplete }
36-func (s stubDownloadItem) GetTotalBytes() int64 { return 0 }39+func (s stubDownloadItem) GetTotalBytes() int64 { return s.totalBytes }
37-func (s stubDownloadItem) GetReceivedBytes() int64 { return 0 }40+func (s stubDownloadItem) GetReceivedBytes() int64 { return s.receivedBytes }
38 func (s stubDownloadItem) GetStartTime() uintptr { return 0 }41 func (s stubDownloadItem) GetStartTime() uintptr { return 0 }
39 func (s stubDownloadItem) GetEndTime() uintptr { return 0 }42 func (s stubDownloadItem) GetEndTime() uintptr { return 0 }
40 func (s stubDownloadItem) GetFullPath() string { return s.fullPath }43 func (s stubDownloadItem) GetFullPath() string { return s.fullPath }
@@ -120,7 +123,7 @@ func TestMarkFinished_SuppressesDuplicatesAfterCleanup(t *testing.T) {
120 123
121 // Simulate a download that was tracked through onBeforeDownload.124 // Simulate a download that was tracked through onBeforeDownload.
122 handler.mu.Lock()125 handler.mu.Lock()
123- handler.active[42] = cefDownloadState{filename: "test.bin", destination: "/tmp/test.bin"}126+ handler.active[42] = cefDownloadState{filename: "test.bin", destination: "/tmp/test.bin", lastProgressPercent: -1}
124 handler.mu.Unlock()127 handler.mu.Unlock()
125 128
126 require.True(t, handler.markFinished(42), "first terminal event should succeed")129 require.True(t, handler.markFinished(42), "first terminal event should succeed")
@@ -133,6 +136,41 @@ func TestMarkFinished_SuppressesDuplicatesAfterCleanup(t *testing.T) {
133 require.False(t, inActive)136 require.False(t, inActive)
134 }137 }
135 138
139+func TestCEFDownloadHandlerEmitsProgressOncePerPercent(t *testing.T) {
140+ ctx := context.Background()
141+ preparer := usecase.NewPrepareDownloadUseCase(nil)
142+ events := &captureDownloadEvents{}
143+ handler := newDownloadHandler("/tmp/downloads", events, preparer)
144+ callback := &stubBeforeDownloadCallback{}
145+
146+ item := stubDownloadItem{
147+ id: 11,
148+ url: "https://example.com/archive.zip",
149+ suggested: "archive.zip",
150+ mimeType: "application/zip",
151+ }
152+
153+ require.True(t, handler.onBeforeDownload(ctx, nil, item, item.suggested, callback))
154+
155+ item.fullPath = callback.path
156+ item.percentComplete = 12
157+ item.receivedBytes = 12
158+ item.totalBytes = 100
159+ handler.onDownloadUpdated(ctx, item, nil)
160+ handler.onDownloadUpdated(ctx, item, nil)
161+
162+ item.percentComplete = 13
163+ item.receivedBytes = 13
164+ handler.onDownloadUpdated(ctx, item, nil)
165+
166+ require.Len(t, events.events, 3)
167+ require.Equal(t, port.DownloadEventStarted, events.events[0].Type)
168+ require.Equal(t, port.DownloadEventProgress, events.events[1].Type)
169+ require.InEpsilon(t, 0.12, events.events[1].Progress, 0.0001)
170+ require.Equal(t, port.DownloadEventProgress, events.events[2].Type)
171+ require.InEpsilon(t, 0.13, events.events[2].Progress, 0.0001)
172+}
173+
136 func TestCEFDownloadInterruptedErrorIncludesCodeAndReason(t *testing.T) {174 func TestCEFDownloadInterruptedErrorIncludesCodeAndReason(t *testing.T) {
137 ctx := context.Background()175 ctx := context.Background()
138 preparer := usecase.NewPrepareDownloadUseCase(nil)176 preparer := usecase.NewPrepareDownloadUseCase(nil)
M internal/infrastructure/downloadruntime/runtime.go +22 -0
@@ -90,6 +90,28 @@ func (r *Runtime) EmitStarted(ctx context.Context, output *port.DownloadPrepareO
9090 Msg("download started")
9191 }
9292
93+func (r *Runtime) EmitProgress(
94+ ctx context.Context,
95+ filename, destination string,
96+ progress float64,
97+ bytesReceived, bytesTotal int64,
98+) {
99+ r.mu.RLock()
100+ eventHandler := r.eventHandler
101+ r.mu.RUnlock()
102+
103+ if eventHandler != nil {
104+ eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
105+ Type: port.DownloadEventProgress,
106+ Filename: filename,
107+ Destination: destination,
108+ Progress: progress,
109+ BytesReceived: bytesReceived,
110+ BytesTotal: bytesTotal,
111+ })
112+ }
113+}
114+
93115 func (r *Runtime) EmitFinished(ctx context.Context, filename, destination string) {
94116 r.mu.RLock()
95117 eventHandler := r.eventHandler
@@ -90,6 +90,28 @@ func (r *Runtime) EmitStarted(ctx context.Context, output *port.DownloadPrepareO
90 Msg("download started")90 Msg("download started")
91 }91 }
92 92
93+func (r *Runtime) EmitProgress(
94+ ctx context.Context,
95+ filename, destination string,
96+ progress float64,
97+ bytesReceived, bytesTotal int64,
98+) {
99+ r.mu.RLock()
100+ eventHandler := r.eventHandler
101+ r.mu.RUnlock()
102+
103+ if eventHandler != nil {
104+ eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
105+ Type: port.DownloadEventProgress,
106+ Filename: filename,
107+ Destination: destination,
108+ Progress: progress,
109+ BytesReceived: bytesReceived,
110+ BytesTotal: bytesTotal,
111+ })
112+ }
113+}
114+
93 func (r *Runtime) EmitFinished(ctx context.Context, filename, destination string) {115 func (r *Runtime) EmitFinished(ctx context.Context, filename, destination string) {
94 r.mu.RLock()116 r.mu.RLock()
95 eventHandler := r.eventHandler117 eventHandler := r.eventHandler
M internal/infrastructure/downloadruntime/runtime_test.go +7 -2
@@ -37,11 +37,16 @@ func TestRuntimeResolveDestinationAndEvents(t *testing.T) {
3737 require.NoError(t, err)
3838 require.Equal(t, "artifact.pdf", output.Filename)
3939 runtime.EmitStarted(ctx, output)
40+ runtime.EmitProgress(ctx, output.Filename, output.DestinationPath, 0.42, 42, 100)
4041 runtime.EmitFinished(ctx, output.Filename, output.DestinationPath)
4142
42- require.Len(t, events.events, 2)
43+ require.Len(t, events.events, 3)
4344 require.Equal(t, port.DownloadEventStarted, events.events[0].Type)
44- require.Equal(t, port.DownloadEventFinished, events.events[1].Type)
45+ require.Equal(t, port.DownloadEventProgress, events.events[1].Type)
46+ require.InEpsilon(t, 0.42, events.events[1].Progress, 0.0001)
47+ require.EqualValues(t, 42, events.events[1].BytesReceived)
48+ require.EqualValues(t, 100, events.events[1].BytesTotal)
49+ require.Equal(t, port.DownloadEventFinished, events.events[2].Type)
4550 }
4651
4752 func TestNewRuntime_NilPreparer_NoPanic(t *testing.T) {
@@ -37,11 +37,16 @@ func TestRuntimeResolveDestinationAndEvents(t *testing.T) {
37 require.NoError(t, err)37 require.NoError(t, err)
38 require.Equal(t, "artifact.pdf", output.Filename)38 require.Equal(t, "artifact.pdf", output.Filename)
39 runtime.EmitStarted(ctx, output)39 runtime.EmitStarted(ctx, output)
40+ runtime.EmitProgress(ctx, output.Filename, output.DestinationPath, 0.42, 42, 100)
40 runtime.EmitFinished(ctx, output.Filename, output.DestinationPath)41 runtime.EmitFinished(ctx, output.Filename, output.DestinationPath)
41 42
42- require.Len(t, events.events, 2)43+ require.Len(t, events.events, 3)
43 require.Equal(t, port.DownloadEventStarted, events.events[0].Type)44 require.Equal(t, port.DownloadEventStarted, events.events[0].Type)
44- require.Equal(t, port.DownloadEventFinished, events.events[1].Type)45+ require.Equal(t, port.DownloadEventProgress, events.events[1].Type)
46+ require.InEpsilon(t, 0.42, events.events[1].Progress, 0.0001)
47+ require.EqualValues(t, 42, events.events[1].BytesReceived)
48+ require.EqualValues(t, 100, events.events[1].BytesTotal)
49+ require.Equal(t, port.DownloadEventFinished, events.events[2].Type)
45 }50 }
46 51
47 func TestNewRuntime_NilPreparer_NoPanic(t *testing.T) {52 func TestNewRuntime_NilPreparer_NoPanic(t *testing.T) {
M internal/infrastructure/webkit/download_handler.go +100 -22
@@ -34,13 +34,18 @@ func NewDownloadHandler(
3434 // downloadState tracks the state of a single download to prevent duplicate notifications.
3535 // This is allocated per-download to ensure proper isolation between concurrent downloads.
3636 type downloadState struct {
37- failed bool
38- mu sync.Mutex
37+ failed bool
38+ filename string
39+ destination string
40+ lastProgressPercent int
41+ mu sync.Mutex
3942 }
4043
4144 // HandleDownload sets up signal handlers for a new download.
4245 func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.Download) {
43- log := logging.FromContext(ctx)
46+ if download == nil {
47+ return
48+ }
4449
4550 h.mu.RLock()
4651 runtime := h.runtime
@@ -48,11 +53,29 @@ func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.D
4853
4954 // Track download state in a dedicated struct to ensure proper isolation.
5055 // Each download gets its own state to prevent interference between concurrent downloads.
51- state := &downloadState{}
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+}
5270
53- // Handle decide-destination signal to set download path.
54- //nolint:unparam // bool return is required by WebKit's signal signature (false = handled synchronously)
55- decideDestCb := func(d webkit.Download, suggestedFilename string) bool {
71+func 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 {
5679 var response port.DownloadResponse
5780 if resp := d.GetResponse(); resp != nil {
5881 response = &uriResponseAdapter{resp: resp}
@@ -71,48 +94,103 @@ func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.D
7194 Str("destPath", output.DestinationPath).
7295 Msg("setting download destination")
7396
74- // WebKit expects an absolute file path for the destination.
7597 d.SetDestination(output.DestinationPath)
7698
99+ state.mu.Lock()
100+ state.filename = output.Filename
101+ state.destination = output.DestinationPath
102+ state.lastProgressPercent = -1
103+ state.mu.Unlock()
104+
77105 runtime.EmitStarted(ctx, output)
106+ emitWebKitDownloadProgress(ctx, runtime, state, d)
107+ return false
108+ }
109+}
78110
79- return false // false = we handled it synchronously
111+func 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)
80118 }
81- download.ConnectDecideDestination(&decideDestCb)
119+}
82120
83- // Handle failed signal.
84- failedCb := func(d webkit.Download, gerr *glib.Error) {
121+func 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) {
85127 state.mu.Lock()
86128 state.failed = true
87129 state.mu.Unlock()
88130
89131 dest := d.GetDestination()
90132 filename := downloadutil.ExtractFilenameFromDestination(dest)
91-
92- // Create error for the failed download.
93133 downloadErr := fmt.Errorf("download failed: %s: %s", filename, gerr.MessageGo())
94-
95134 runtime.EmitFailed(ctx, filename, dest, downloadErr)
96135 }
97- download.ConnectFailed(&failedCb)
136+}
98137
99- // Handle finished signal.
100- finishedCb := func(d webkit.Download) {
138+func makeFinishedCallback(
139+ ctx context.Context,
140+ runtime *downloadruntime.Runtime,
141+ state *downloadState,
142+) func(webkit.Download) {
143+ return func(d webkit.Download) {
101144 state.mu.Lock()
102145 failed := state.failed
103146 state.mu.Unlock()
104-
105- // Don't send finished event if we already sent a failed event.
106147 if failed {
107148 return
108149 }
109150
110151 dest := d.GetDestination()
111152 filename := downloadutil.ExtractFilenameFromDestination(dest)
112-
113153 runtime.EmitFinished(ctx, filename, dest)
114154 }
115- download.ConnectFinished(&finishedCb)
155+}
156+
157+func 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)
116194 }
117195
118196 // SetDownloadPath updates the download directory.
@@ -34,13 +34,18 @@ func NewDownloadHandler(
34 // downloadState tracks the state of a single download to prevent duplicate notifications.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.35 // This is allocated per-download to ensure proper isolation between concurrent downloads.
36 type downloadState struct {36 type downloadState struct {
37- failed bool37+ failed bool
38- mu sync.Mutex38+ filename string
39+ destination string
40+ lastProgressPercent int
41+ mu sync.Mutex
39 }42 }
40 43
41 // HandleDownload sets up signal handlers for a new download.44 // HandleDownload sets up signal handlers for a new download.
42 func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.Download) {45 func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.Download) {
43- log := logging.FromContext(ctx)46+ if download == nil {
47+ return
48+ }
44 49
45 h.mu.RLock()50 h.mu.RLock()
46 runtime := h.runtime51 runtime := h.runtime
@@ -48,11 +53,29 @@ func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.D
48 53
49 // Track download state in a dedicated struct to ensure proper isolation.54 // Track download state in a dedicated struct to ensure proper isolation.
50 // Each download gets its own state to prevent interference between concurrent downloads.55 // Each download gets its own state to prevent interference between concurrent downloads.
51- state := &downloadState{}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+}
52 70
53- // Handle decide-destination signal to set download path.71+func makeDecideDestinationCallback(
54- //nolint:unparam // bool return is required by WebKit's signal signature (false = handled synchronously)72+ ctx context.Context,
55- decideDestCb := func(d webkit.Download, suggestedFilename string) bool {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 {
56 var response port.DownloadResponse79 var response port.DownloadResponse
57 if resp := d.GetResponse(); resp != nil {80 if resp := d.GetResponse(); resp != nil {
58 response = &uriResponseAdapter{resp: resp}81 response = &uriResponseAdapter{resp: resp}
@@ -71,48 +94,103 @@ func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.D
71 Str("destPath", output.DestinationPath).94 Str("destPath", output.DestinationPath).
72 Msg("setting download destination")95 Msg("setting download destination")
73 96
74- // WebKit expects an absolute file path for the destination.
75 d.SetDestination(output.DestinationPath)97 d.SetDestination(output.DestinationPath)
76 98
99+ state.mu.Lock()
100+ state.filename = output.Filename
101+ state.destination = output.DestinationPath
102+ state.lastProgressPercent = -1
103+ state.mu.Unlock()
104+
77 runtime.EmitStarted(ctx, output)105 runtime.EmitStarted(ctx, output)
106+ emitWebKitDownloadProgress(ctx, runtime, state, d)
107+ return false
108+ }
109+}
78 110
79- return false // false = we handled it synchronously111+func 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)
80 }118 }
81- download.ConnectDecideDestination(&decideDestCb)119+}
82 120
83- // Handle failed signal.121+func makeFailedCallback(
84- failedCb := func(d webkit.Download, gerr *glib.Error) {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) {
85 state.mu.Lock()127 state.mu.Lock()
86 state.failed = true128 state.failed = true
87 state.mu.Unlock()129 state.mu.Unlock()
88 130
89 dest := d.GetDestination()131 dest := d.GetDestination()
90 filename := downloadutil.ExtractFilenameFromDestination(dest)132 filename := downloadutil.ExtractFilenameFromDestination(dest)
91-
92- // Create error for the failed download.
93 downloadErr := fmt.Errorf("download failed: %s: %s", filename, gerr.MessageGo())133 downloadErr := fmt.Errorf("download failed: %s: %s", filename, gerr.MessageGo())
94-
95 runtime.EmitFailed(ctx, filename, dest, downloadErr)134 runtime.EmitFailed(ctx, filename, dest, downloadErr)
96 }135 }
97- download.ConnectFailed(&failedCb)136+}
98 137
99- // Handle finished signal.138+func makeFinishedCallback(
100- finishedCb := func(d webkit.Download) {139+ ctx context.Context,
140+ runtime *downloadruntime.Runtime,
141+ state *downloadState,
142+) func(webkit.Download) {
143+ return func(d webkit.Download) {
101 state.mu.Lock()144 state.mu.Lock()
102 failed := state.failed145 failed := state.failed
103 state.mu.Unlock()146 state.mu.Unlock()
104-
105- // Don't send finished event if we already sent a failed event.
106 if failed {147 if failed {
107 return148 return
108 }149 }
109 150
110 dest := d.GetDestination()151 dest := d.GetDestination()
111 filename := downloadutil.ExtractFilenameFromDestination(dest)152 filename := downloadutil.ExtractFilenameFromDestination(dest)
112-
113 runtime.EmitFinished(ctx, filename, dest)153 runtime.EmitFinished(ctx, filename, dest)
114 }154 }
115- download.ConnectFinished(&finishedCb)155+}
156+
157+func 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)
116 }194 }
117 195
118 // SetDownloadPath updates the download directory.196 // SetDownloadPath updates the download directory.
M internal/ui/app.go +134 -16
@@ -40,12 +40,14 @@ const (
4040 AppID = "com.github.bnema.dumber"
4141 appTitle = "Dumber"
4242 // crashReportToastDurationMs keeps startup crash-report toast visible longer.
43- crashReportToastDurationMs = 5000
44- floatingPaneFallbackWidth = 1200
45- floatingPaneFallbackHeight = 800
46- floatingPaneIDPrefix = "floating-pane:"
47- floatingSessionIDDefault = "default"
48- floatingPaneVisibleClass = "floating-pane-visible"
43+ crashReportToastDurationMs = 5000
44+ downloadToastTerminalDuration = 2500
45+ downloadProgressRoundBias = 0.5
46+ floatingPaneFallbackWidth = 1200
47+ floatingPaneFallbackHeight = 800
48+ floatingPaneIDPrefix = "floating-pane:"
49+ floatingSessionIDDefault = "default"
50+ floatingPaneVisibleClass = "floating-pane-visible"
4951 )
5052
5153 func gtkApplicationFlags() gio.ApplicationFlags {
@@ -889,7 +891,7 @@ func (a *App) initDownloadHandler(ctx context.Context) {
889891 }
890892
891893 // Create download event adapter to show toasts.
892- eventAdapter := &downloadEventAdapter{app: a}
894+ eventAdapter := newDownloadEventAdapter(a)
893895
894896 // Create use case for preparing download destinations with file deduplication.
895897 prepareDownloadUC := usecase.NewPrepareDownloadUseCase(a.deps.FileSystem)
@@ -904,25 +906,141 @@ func (a *App) initDownloadHandler(ctx context.Context) {
904906
905907 // downloadEventAdapter implements port.DownloadEventHandler and shows toasts.
906908 type downloadEventAdapter struct {
907- app *App
909+ app *App
910+ mu sync.Mutex
911+ active map[string]downloadToastState
912+}
913+
914+type downloadToastState struct {
915+ filename string
916+ percent int
917+}
918+
919+type downloadToastSpec struct {
920+ message string
921+ level component.ToastLevel
922+ duration int
923+ show bool
924+}
925+
926+func newDownloadEventAdapter(app *App) *downloadEventAdapter {
927+ return &downloadEventAdapter{
928+ app: app,
929+ active: make(map[string]downloadToastState),
930+ }
908931 }
909932
910933 func (d *downloadEventAdapter) OnDownloadEvent(ctx context.Context, event port.DownloadEvent) {
934+ spec := d.toastSpecForEvent(event)
935+ if !spec.show {
936+ return
937+ }
938+
911939 // Must schedule on GTK main thread.
912940 cb := glib.SourceFunc(func(_ uintptr) bool {
913- switch event.Type {
914- case port.DownloadEventStarted:
915- d.app.showToastOnLastFocusedBrowserWindow(ctx, "Download started: "+event.Filename, component.ToastInfo)
916- case port.DownloadEventFinished:
917- d.app.showToastOnLastFocusedBrowserWindow(ctx, "Download complete: "+event.Filename, component.ToastSuccess)
918- case port.DownloadEventFailed:
919- d.app.showToastOnLastFocusedBrowserWindow(ctx, "Download failed: "+event.Filename, component.ToastError)
920- }
941+ d.app.showToastOnLastFocusedBrowserWindow(
942+ ctx,
943+ spec.message,
944+ spec.level,
945+ component.WithDuration(spec.duration),
946+ )
921947 return false
922948 })
923949 glib.IdleAdd(&cb, 0)
924950 }
925951
952+func (d *downloadEventAdapter) toastSpecForEvent(event port.DownloadEvent) downloadToastSpec {
953+ key := downloadToastKey(event)
954+ filename := event.Filename
955+ if filename == "" {
956+ filename = filepath.Base(event.Destination)
957+ }
958+ if filename == "" {
959+ filename = "download"
960+ }
961+
962+ d.mu.Lock()
963+ defer d.mu.Unlock()
964+
965+ state := d.active[key]
966+ if state.filename == "" {
967+ state.filename = filename
968+ }
969+
970+ switch event.Type {
971+ case port.DownloadEventStarted:
972+ state.percent = 0
973+ d.active[key] = state
974+ return downloadToastSpec{
975+ message: fmt.Sprintf("Download started: %s (0%%)", state.filename),
976+ level: component.ToastInfo,
977+ duration: 0,
978+ show: true,
979+ }
980+ case port.DownloadEventProgress:
981+ percent := downloadProgressPercent(event.Progress)
982+ if percent <= state.percent {
983+ if _, ok := d.active[key]; !ok {
984+ d.active[key] = state
985+ }
986+ return downloadToastSpec{}
987+ }
988+ state.percent = percent
989+ d.active[key] = state
990+ return downloadToastSpec{
991+ message: fmt.Sprintf("Downloading: %s (%d%%)", state.filename, percent),
992+ level: component.ToastInfo,
993+ duration: 0,
994+ show: true,
995+ }
996+ case port.DownloadEventFinished:
997+ delete(d.active, key)
998+ return downloadToastSpec{
999+ message: "Download complete: " + state.filename,
1000+ level: component.ToastSuccess,
1001+ duration: downloadToastTerminalDuration,
1002+ show: true,
1003+ }
1004+ case port.DownloadEventFailed:
1005+ delete(d.active, key)
1006+ return downloadToastSpec{
1007+ message: "Download failed: " + state.filename,
1008+ level: component.ToastError,
1009+ duration: downloadToastTerminalDuration,
1010+ show: true,
1011+ }
1012+ default:
1013+ return downloadToastSpec{}
1014+ }
1015+}
1016+
1017+func downloadToastKey(event port.DownloadEvent) string {
1018+ if event.Destination != "" {
1019+ return event.Destination
1020+ }
1021+ if event.Filename != "" {
1022+ return event.Filename
1023+ }
1024+ return "download"
1025+}
1026+
1027+func downloadProgressPercent(progress float64) int {
1028+ if progress <= 0 {
1029+ return 0
1030+ }
1031+ if progress >= 1 {
1032+ return 100
1033+ }
1034+ percent := int(progress*100 + downloadProgressRoundBias)
1035+ if percent < 0 {
1036+ return 0
1037+ }
1038+ if percent > 100 {
1039+ return 100
1040+ }
1041+ return percent
1042+}
1043+
9261044 // autoCopyConfigFn implements port.AutoCopyConfig using a function closure.
9271045 type autoCopyConfigFn struct {
9281046 fn func() bool
@@ -40,12 +40,14 @@ const (
40 AppID = "com.github.bnema.dumber"40 AppID = "com.github.bnema.dumber"
41 appTitle = "Dumber"41 appTitle = "Dumber"
42 // crashReportToastDurationMs keeps startup crash-report toast visible longer.42 // crashReportToastDurationMs keeps startup crash-report toast visible longer.
43- crashReportToastDurationMs = 500043+ crashReportToastDurationMs = 5000
44- floatingPaneFallbackWidth = 120044+ downloadToastTerminalDuration = 2500
45- floatingPaneFallbackHeight = 80045+ downloadProgressRoundBias = 0.5
46- floatingPaneIDPrefix = "floating-pane:"46+ floatingPaneFallbackWidth = 1200
47- floatingSessionIDDefault = "default"47+ floatingPaneFallbackHeight = 800
48- floatingPaneVisibleClass = "floating-pane-visible"48+ floatingPaneIDPrefix = "floating-pane:"
49+ floatingSessionIDDefault = "default"
50+ floatingPaneVisibleClass = "floating-pane-visible"
49 )51 )
50 52
51 func gtkApplicationFlags() gio.ApplicationFlags {53 func gtkApplicationFlags() gio.ApplicationFlags {
@@ -889,7 +891,7 @@ func (a *App) initDownloadHandler(ctx context.Context) {
889 }891 }
890 892
891 // Create download event adapter to show toasts.893 // Create download event adapter to show toasts.
892- eventAdapter := &downloadEventAdapter{app: a}894+ eventAdapter := newDownloadEventAdapter(a)
893 895
894 // Create use case for preparing download destinations with file deduplication.896 // Create use case for preparing download destinations with file deduplication.
895 prepareDownloadUC := usecase.NewPrepareDownloadUseCase(a.deps.FileSystem)897 prepareDownloadUC := usecase.NewPrepareDownloadUseCase(a.deps.FileSystem)
@@ -904,25 +906,141 @@ func (a *App) initDownloadHandler(ctx context.Context) {
904 906
905 // downloadEventAdapter implements port.DownloadEventHandler and shows toasts.907 // downloadEventAdapter implements port.DownloadEventHandler and shows toasts.
906 type downloadEventAdapter struct {908 type downloadEventAdapter struct {
907- app *App909+ app *App
910+ mu sync.Mutex
911+ active map[string]downloadToastState
912+}
913+
914+type downloadToastState struct {
915+ filename string
916+ percent int
917+}
918+
919+type downloadToastSpec struct {
920+ message string
921+ level component.ToastLevel
922+ duration int
923+ show bool
924+}
925+
926+func newDownloadEventAdapter(app *App) *downloadEventAdapter {
927+ return &downloadEventAdapter{
928+ app: app,
929+ active: make(map[string]downloadToastState),
930+ }
908 }931 }
909 932
910 func (d *downloadEventAdapter) OnDownloadEvent(ctx context.Context, event port.DownloadEvent) {933 func (d *downloadEventAdapter) OnDownloadEvent(ctx context.Context, event port.DownloadEvent) {
934+ spec := d.toastSpecForEvent(event)
935+ if !spec.show {
936+ return
937+ }
938+
911 // Must schedule on GTK main thread.939 // Must schedule on GTK main thread.
912 cb := glib.SourceFunc(func(_ uintptr) bool {940 cb := glib.SourceFunc(func(_ uintptr) bool {
913- switch event.Type {941+ d.app.showToastOnLastFocusedBrowserWindow(
914- case port.DownloadEventStarted:942+ ctx,
915- d.app.showToastOnLastFocusedBrowserWindow(ctx, "Download started: "+event.Filename, component.ToastInfo)943+ spec.message,
916- case port.DownloadEventFinished:944+ spec.level,
917- d.app.showToastOnLastFocusedBrowserWindow(ctx, "Download complete: "+event.Filename, component.ToastSuccess)945+ component.WithDuration(spec.duration),
918- case port.DownloadEventFailed:946+ )
919- d.app.showToastOnLastFocusedBrowserWindow(ctx, "Download failed: "+event.Filename, component.ToastError)
920- }
921 return false947 return false
922 })948 })
923 glib.IdleAdd(&cb, 0)949 glib.IdleAdd(&cb, 0)
924 }950 }
925 951
952+func (d *downloadEventAdapter) toastSpecForEvent(event port.DownloadEvent) downloadToastSpec {
953+ key := downloadToastKey(event)
954+ filename := event.Filename
955+ if filename == "" {
956+ filename = filepath.Base(event.Destination)
957+ }
958+ if filename == "" {
959+ filename = "download"
960+ }
961+
962+ d.mu.Lock()
963+ defer d.mu.Unlock()
964+
965+ state := d.active[key]
966+ if state.filename == "" {
967+ state.filename = filename
968+ }
969+
970+ switch event.Type {
971+ case port.DownloadEventStarted:
972+ state.percent = 0
973+ d.active[key] = state
974+ return downloadToastSpec{
975+ message: fmt.Sprintf("Download started: %s (0%%)", state.filename),
976+ level: component.ToastInfo,
977+ duration: 0,
978+ show: true,
979+ }
980+ case port.DownloadEventProgress:
981+ percent := downloadProgressPercent(event.Progress)
982+ if percent <= state.percent {
983+ if _, ok := d.active[key]; !ok {
984+ d.active[key] = state
985+ }
986+ return downloadToastSpec{}
987+ }
988+ state.percent = percent
989+ d.active[key] = state
990+ return downloadToastSpec{
991+ message: fmt.Sprintf("Downloading: %s (%d%%)", state.filename, percent),
992+ level: component.ToastInfo,
993+ duration: 0,
994+ show: true,
995+ }
996+ case port.DownloadEventFinished:
997+ delete(d.active, key)
998+ return downloadToastSpec{
999+ message: "Download complete: " + state.filename,
1000+ level: component.ToastSuccess,
1001+ duration: downloadToastTerminalDuration,
1002+ show: true,
1003+ }
1004+ case port.DownloadEventFailed:
1005+ delete(d.active, key)
1006+ return downloadToastSpec{
1007+ message: "Download failed: " + state.filename,
1008+ level: component.ToastError,
1009+ duration: downloadToastTerminalDuration,
1010+ show: true,
1011+ }
1012+ default:
1013+ return downloadToastSpec{}
1014+ }
1015+}
1016+
1017+func downloadToastKey(event port.DownloadEvent) string {
1018+ if event.Destination != "" {
1019+ return event.Destination
1020+ }
1021+ if event.Filename != "" {
1022+ return event.Filename
1023+ }
1024+ return "download"
1025+}
1026+
1027+func downloadProgressPercent(progress float64) int {
1028+ if progress <= 0 {
1029+ return 0
1030+ }
1031+ if progress >= 1 {
1032+ return 100
1033+ }
1034+ percent := int(progress*100 + downloadProgressRoundBias)
1035+ if percent < 0 {
1036+ return 0
1037+ }
1038+ if percent > 100 {
1039+ return 100
1040+ }
1041+ return percent
1042+}
1043+
926 // autoCopyConfigFn implements port.AutoCopyConfig using a function closure.1044 // autoCopyConfigFn implements port.AutoCopyConfig using a function closure.
927 type autoCopyConfigFn struct {1045 type autoCopyConfigFn struct {
928 fn func() bool1046 fn func() bool
A internal/ui/app_download_test.go +86 -0
@@ -0,0 +1,86 @@
1+package ui
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+ "github.com/bnema/dumber/internal/ui/component"
10+)
11+
12+func TestDownloadEventAdapterToastSpecLifecycle(t *testing.T) {
13+ adapter := newDownloadEventAdapter(nil)
14+ event := port.DownloadEvent{
15+ Filename: "archlinux.iso",
16+ Destination: "/tmp/archlinux.iso",
17+ }
18+
19+ started := adapter.toastSpecForEvent(port.DownloadEvent{
20+ Type: port.DownloadEventStarted,
21+ Filename: event.Filename,
22+ Destination: event.Destination,
23+ })
24+ require.True(t, started.show)
25+ require.Equal(t, component.ToastInfo, started.level)
26+ require.Equal(t, 0, started.duration)
27+ require.Equal(t, "Download started: archlinux.iso (0%)", started.message)
28+
29+ progress := adapter.toastSpecForEvent(port.DownloadEvent{
30+ Type: port.DownloadEventProgress,
31+ Filename: event.Filename,
32+ Destination: event.Destination,
33+ Progress: 0.37,
34+ })
35+ require.True(t, progress.show)
36+ require.Equal(t, component.ToastInfo, progress.level)
37+ require.Equal(t, 0, progress.duration)
38+ require.Equal(t, "Downloading: archlinux.iso (37%)", progress.message)
39+
40+ finished := adapter.toastSpecForEvent(port.DownloadEvent{
41+ Type: port.DownloadEventFinished,
42+ Filename: event.Filename,
43+ Destination: event.Destination,
44+ })
45+ require.True(t, finished.show)
46+ require.Equal(t, component.ToastSuccess, finished.level)
47+ require.Equal(t, downloadToastTerminalDuration, finished.duration)
48+ require.Equal(t, "Download complete: archlinux.iso", finished.message)
49+}
50+
51+func TestDownloadEventAdapterToastSpecSuppressesDuplicateProgress(t *testing.T) {
52+ adapter := newDownloadEventAdapter(nil)
53+ event := port.DownloadEvent{
54+ Filename: "ubuntu.iso",
55+ Destination: "/tmp/ubuntu.iso",
56+ }
57+
58+ adapter.toastSpecForEvent(port.DownloadEvent{
59+ Type: port.DownloadEventStarted,
60+ Filename: event.Filename,
61+ Destination: event.Destination,
62+ })
63+
64+ first := adapter.toastSpecForEvent(port.DownloadEvent{
65+ Type: port.DownloadEventProgress,
66+ Filename: event.Filename,
67+ Destination: event.Destination,
68+ Progress: 0.12,
69+ })
70+ require.True(t, first.show)
71+
72+ duplicate := adapter.toastSpecForEvent(port.DownloadEvent{
73+ Type: port.DownloadEventProgress,
74+ Filename: event.Filename,
75+ Destination: event.Destination,
76+ Progress: 0.12,
77+ })
78+ require.False(t, duplicate.show)
79+}
80+
81+func TestDownloadProgressPercentRoundsReasonably(t *testing.T) {
82+ require.Equal(t, 0, downloadProgressPercent(0))
83+ require.Equal(t, 1, downloadProgressPercent(0.0051))
84+ require.Equal(t, 37, downloadProgressPercent(0.37))
85+ require.Equal(t, 100, downloadProgressPercent(1.2))
86+}
@@ -0,0 +1,86 @@
1+package ui
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+ "github.com/bnema/dumber/internal/ui/component"
10+)
11+
12+func TestDownloadEventAdapterToastSpecLifecycle(t *testing.T) {
13+ adapter := newDownloadEventAdapter(nil)
14+ event := port.DownloadEvent{
15+ Filename: "archlinux.iso",
16+ Destination: "/tmp/archlinux.iso",
17+ }
18+
19+ started := adapter.toastSpecForEvent(port.DownloadEvent{
20+ Type: port.DownloadEventStarted,
21+ Filename: event.Filename,
22+ Destination: event.Destination,
23+ })
24+ require.True(t, started.show)
25+ require.Equal(t, component.ToastInfo, started.level)
26+ require.Equal(t, 0, started.duration)
27+ require.Equal(t, "Download started: archlinux.iso (0%)", started.message)
28+
29+ progress := adapter.toastSpecForEvent(port.DownloadEvent{
30+ Type: port.DownloadEventProgress,
31+ Filename: event.Filename,
32+ Destination: event.Destination,
33+ Progress: 0.37,
34+ })
35+ require.True(t, progress.show)
36+ require.Equal(t, component.ToastInfo, progress.level)
37+ require.Equal(t, 0, progress.duration)
38+ require.Equal(t, "Downloading: archlinux.iso (37%)", progress.message)
39+
40+ finished := adapter.toastSpecForEvent(port.DownloadEvent{
41+ Type: port.DownloadEventFinished,
42+ Filename: event.Filename,
43+ Destination: event.Destination,
44+ })
45+ require.True(t, finished.show)
46+ require.Equal(t, component.ToastSuccess, finished.level)
47+ require.Equal(t, downloadToastTerminalDuration, finished.duration)
48+ require.Equal(t, "Download complete: archlinux.iso", finished.message)
49+}
50+
51+func TestDownloadEventAdapterToastSpecSuppressesDuplicateProgress(t *testing.T) {
52+ adapter := newDownloadEventAdapter(nil)
53+ event := port.DownloadEvent{
54+ Filename: "ubuntu.iso",
55+ Destination: "/tmp/ubuntu.iso",
56+ }
57+
58+ adapter.toastSpecForEvent(port.DownloadEvent{
59+ Type: port.DownloadEventStarted,
60+ Filename: event.Filename,
61+ Destination: event.Destination,
62+ })
63+
64+ first := adapter.toastSpecForEvent(port.DownloadEvent{
65+ Type: port.DownloadEventProgress,
66+ Filename: event.Filename,
67+ Destination: event.Destination,
68+ Progress: 0.12,
69+ })
70+ require.True(t, first.show)
71+
72+ duplicate := adapter.toastSpecForEvent(port.DownloadEvent{
73+ Type: port.DownloadEventProgress,
74+ Filename: event.Filename,
75+ Destination: event.Destination,
76+ Progress: 0.12,
77+ })
78+ require.False(t, duplicate.show)
79+}
80+
81+func TestDownloadProgressPercentRoundsReasonably(t *testing.T) {
82+ require.Equal(t, 0, downloadProgressPercent(0))
83+ require.Equal(t, 1, downloadProgressPercent(0.0051))
84+ require.Equal(t, 37, downloadProgressPercent(0.37))
85+ require.Equal(t, 100, downloadProgressPercent(1.2))
86+}

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