-
-
Notifications
You must be signed in to change notification settings - Fork 132
Expand file tree
/
Copy pathdiff.patch
More file actions
982 lines (943 loc) · 72.3 KB
/
diff.patch
File metadata and controls
982 lines (943 loc) · 72.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
diff --git a/.github/workflows/core-lint.yml b/.github/workflows/core-lint.yml
index d90b5b5..20bf402 100644
--- a/.github/workflows/core-lint.yml
+++ b/.github/workflows/core-lint.yml
@@ -28,3 +28,5 @@ jobs:
go-version: '1.25.0'
- name: golangci-lint
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest run
+ - name: unicode lint
+ run: go test ./internal/lint/...
diff --git a/cmd/connect_test.go b/cmd/connect_test.go
index 3a03762..a7fa98a 100644
--- a/cmd/connect_test.go
+++ b/cmd/connect_test.go
@@ -51,6 +51,8 @@ func (f *fakeRemoteDownloadService) UpdateURL(id string, newURL string) error {
func (f *fakeRemoteDownloadService) Delete(id string) error { return nil }
+func (f *fakeRemoteDownloadService) Purge(id string) error { return nil }
+
func (f *fakeRemoteDownloadService) StreamEvents(ctx context.Context) (<-chan interface{}, func(), error) {
ch := make(chan interface{})
return ch, func() { close(ch) }, nil
diff --git a/cmd/http_api.go b/cmd/http_api.go
index 0c3e004..b82a652 100644
--- a/cmd/http_api.go
+++ b/cmd/http_api.go
@@ -64,6 +64,14 @@ func registerHTTPRoutes(mux *http.ServeMux, port int, defaultOutputDir string, s
writeJSONResponse(w, http.StatusOK, map[string]string{"status": "deleted", "id": id})
}), http.MethodDelete, http.MethodPost))
+ mux.HandleFunc("/purge", requireMethods(withRequiredID(func(w http.ResponseWriter, _ *http.Request, id string) {
+ if err := service.Purge(id); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ writeJSONResponse(w, http.StatusOK, map[string]string{"status": "purged", "id": id})
+ }), http.MethodDelete, http.MethodPost))
+
mux.HandleFunc("/list", requireMethod(http.MethodGet, func(w http.ResponseWriter, _ *http.Request) {
statuses, err := service.List()
if err != nil {
diff --git a/cmd/http_api_test.go b/cmd/http_api_test.go
index d54f992..e46f713 100644
--- a/cmd/http_api_test.go
+++ b/cmd/http_api_test.go
@@ -64,6 +64,10 @@ func (s *httpAPITestService) Delete(string) error {
return nil
}
+func (s *httpAPITestService) Purge(string) error {
+ return nil
+}
+
func (s *httpAPITestService) StreamEvents(context.Context) (<-chan interface{}, func(), error) {
channel := make(chan interface{}, len(s.streamMsgs))
for _, msg := range s.streamMsgs {
@@ -513,6 +517,7 @@ type recordingActionService struct {
func (s *recordingActionService) Pause(id string) error { s.ids["pause"] = id; return nil }
func (s *recordingActionService) Resume(id string) error { s.ids["resume"] = id; return nil }
func (s *recordingActionService) Delete(id string) error { s.ids["delete"] = id; return nil }
+func (s *recordingActionService) Purge(id string) error { s.ids["purge"] = id; return nil }
// Regression for #456: ExecuteAPIAction sent the download id as a path segment
// (e.g. POST /pause/<id>), but the HTTP API registers exact routes and reads the
@@ -539,6 +544,7 @@ func TestExecuteAPIAction_SendsIDAsQueryParam(t *testing.T) {
{"pause", "/pause"},
{"resume", "/resume"},
{"delete", "/delete"},
+ {"purge", "/purge"},
} {
if err := ExecuteAPIAction(fullID, action.endpoint, http.MethodPost, action.name); err != nil {
t.Fatalf("ExecuteAPIAction(%s): id should reach %s via ?id=, got error: %v", action.name, action.endpoint, err)
diff --git a/cmd/rm.go b/cmd/rm.go
index 75a14e0..b5c8eec 100644
--- a/cmd/rm.go
+++ b/cmd/rm.go
@@ -12,7 +12,7 @@ var rmCmd = &cobra.Command{
Use: "rm <ID>",
Aliases: []string{"kill"},
Short: "Remove a download",
- Long: `Remove a download by its ID. Use --clean to remove all completed downloads.`,
+ Long: `Remove a download by its ID. Use --clean to remove all completed downloads. Use --purge to also delete the file(s) from disk.`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if err := initializeGlobalState(); err != nil {
@@ -20,6 +20,7 @@ var rmCmd = &cobra.Command{
}
clean, _ := cmd.Flags().GetBool("clean")
+ purge, _ := cmd.Flags().GetBool("purge")
if !clean && len(args) == 0 {
return fmt.Errorf("provide a download ID or use --clean")
@@ -35,6 +36,9 @@ var rmCmd = &cobra.Command{
return nil
}
+ if purge {
+ return ExecuteAPIAction(args[0], "/purge", http.MethodPost, "Purged download and deleted files")
+ }
return ExecuteAPIAction(args[0], "/delete", http.MethodPost, "Removed download")
},
}
@@ -42,4 +46,5 @@ var rmCmd = &cobra.Command{
func init() {
rootCmd.AddCommand(rmCmd)
rmCmd.Flags().Bool("clean", false, "Remove all completed downloads")
+ rmCmd.Flags().BoolP("purge", "p", false, "Also delete the downloaded file(s) from disk")
}
diff --git a/cmd/root_lifecycle_test.go b/cmd/root_lifecycle_test.go
index 6a75d36..d86f7b7 100644
--- a/cmd/root_lifecycle_test.go
+++ b/cmd/root_lifecycle_test.go
@@ -45,6 +45,7 @@ func (s *countingLifecycleService) Resume(string) error { return nil
func (s *countingLifecycleService) ResumeBatch([]string) []error { return nil }
func (s *countingLifecycleService) UpdateURL(string, string) error { return nil }
func (s *countingLifecycleService) Delete(string) error { return nil }
+func (s *countingLifecycleService) Purge(string) error { return nil }
func (s *countingLifecycleService) Publish(msg interface{}) error {
if log, ok := msg.(events.SystemLogMsg); ok {
s.cleanupMu.Lock()
diff --git a/docs/USAGE.md b/docs/USAGE.md
index 472586e..ad70167 100644
--- a/docs/USAGE.md
+++ b/docs/USAGE.md
@@ -14,7 +14,7 @@ Surge provides a robust Command Line Interface for automation and scripting. For
| `surge pause <id>` | Pauses a download by ID/prefix. | `--all` | |
| `surge resume <id>` | Resumes a paused download by ID/prefix. | `--all` | |
| `surge refresh <id> <url>` | Updates the source URL of a paused or errored download. | None | Reconnects using the new link. |
-| `surge rm <id>` | Removes a download by ID/prefix. | `--clean` | Alias: `kill`. |
+| `surge rm <id>` | Removes a download by ID/prefix. | `--clean`, `--purge` | Alias: `kill`. |
| `surge token` | Prints current API auth token. (Also visible in TUI > Settings > Extension) | None | Useful for remote clients. |
| `surge service <cmd>` | Manages Surge as a system service (daemon). | `install`, `uninstall`, `start`, `stop`, `status` | Cross-platform (Linux/Windows/macOS). See [Service Management](#service-management). |
| `surge bug-report` | Opens a pre-filled GitHub bug report. Prompts for target (Core/Extension) and optional system/log details. | None | Prints a manual URL fallback if browser open fails. |
diff --git a/internal/config/keymaps.go b/internal/config/keymaps.go
index 36fac55..bed7372 100644
--- a/internal/config/keymaps.go
+++ b/internal/config/keymaps.go
@@ -45,6 +45,7 @@ type DashboardKeyMap struct {
Pause key.Binding
Refresh key.Binding
Delete key.Binding
+ PurgeFile key.Binding
Settings key.Binding
Log key.Binding
ToggleHelp key.Binding
@@ -212,7 +213,7 @@ func LoadKeyMap() (*KeyMap, error) {
if err := json.Unmarshal(data, &cfg); err != nil {
utils.Debug("Warning: corrupt keymap file %s: %v \u2014 using defaults", path, err)
defaults.StartupWarnings = append(defaults.StartupWarnings,
- fmt.Sprintf("Config: keymap file is corrupt (%v) ΓÇö all keybindings reset to defaults & rewrite the file", err))
+ fmt.Sprintf("Config: keymap file is corrupt (%v) \u2014 all keybindings reset to defaults & rewrite the file", err))
err = SaveKeyMap(defaults)
return defaults, err
}
@@ -379,11 +380,11 @@ func DefaultKeyMap() *KeyMap {
),
NextTab: key.NewBinding(
key.WithKeys("tab", "right"),
- key.WithHelp("tab/→", "next tab"),
+ key.WithHelp("tab/\u2192", "next tab"),
),
PrevTab: key.NewBinding(
key.WithKeys("shift+tab", "left"),
- key.WithHelp("shift+tab/←", "prev tab"),
+ key.WithHelp("shift+tab/\u2190", "prev tab"),
),
Add: key.NewBinding(
key.WithKeys("a"),
@@ -409,6 +410,10 @@ func DefaultKeyMap() *KeyMap {
key.WithKeys("x"),
key.WithHelp("x", "delete"),
),
+ PurgeFile: key.NewBinding(
+ key.WithKeys("X"),
+ key.WithHelp("X", "delete+file"),
+ ),
Settings: key.NewBinding(
key.WithKeys("s"),
key.WithHelp("s", "settings"),
@@ -455,11 +460,11 @@ func DefaultKeyMap() *KeyMap {
),
LogUp: key.NewBinding(
key.WithKeys("up"),
- key.WithHelp("Γåæ", "scroll up"),
+ key.WithHelp("\u2191", "scroll up"),
),
LogDown: key.NewBinding(
key.WithKeys("down"),
- key.WithHelp("Γåô", "scroll down"),
+ key.WithHelp("\u2193", "scroll down"),
),
LogTop: key.NewBinding(
key.WithKeys("g"),
@@ -669,8 +674,8 @@ func DefaultKeyMap() *KeyMap {
),
},
CategoryMgr: CategoryManagerKeyMap{
- Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("Γåæ/k", "up")),
- Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("Γåô/j", "down")),
+ Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("\u2191/k", "up")),
+ Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("\u2193/j", "down")),
Edit: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "edit")),
Add: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "add")),
Delete: key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "delete")),
@@ -713,7 +718,7 @@ func (k DashboardKeyMap) ShortHelp() []key.Binding {
func (k DashboardKeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{
{k.TabQueued, k.TabActive, k.TabDone, k.NextTab, k.PrevTab},
- {k.Add, k.BatchImport, k.Search, k.CategoryFilter, k.Pause, k.Refresh, k.Delete, k.Settings, k.PinTab},
+ {k.Add, k.BatchImport, k.Search, k.CategoryFilter, k.Pause, k.Refresh, k.Delete, k.PurgeFile, k.Settings, k.PinTab},
{k.Log, k.OpenFile, k.ReportBug, k.Quit},
}
}
diff --git a/internal/core/interface.go b/internal/core/interface.go
index dc1883a..211a550 100644
--- a/internal/core/interface.go
+++ b/internal/core/interface.go
@@ -37,6 +37,9 @@ type DownloadService interface {
// Delete cancels and removes a download.
Delete(id string) error
+ // Purge cancels and removes a download, and deletes its files from disk.
+ Purge(id string) error
+
// StreamEvents returns a channel that receives real-time download events.
// For local mode, this is a direct channel.
// For remote mode, this is sourced from SSE.
diff --git a/internal/core/local_service.go b/internal/core/local_service.go
index d2f3fb9..52a0d25 100644
--- a/internal/core/local_service.go
+++ b/internal/core/local_service.go
@@ -3,6 +3,7 @@ package core
import (
"context"
"fmt"
+ "os"
"path/filepath"
"strings"
"sync"
@@ -589,6 +590,48 @@ func (s *LocalDownloadService) Delete(id string) error {
return nil
}
+// Purge cancels and removes a download, and deletes its files from disk.
+func (s *LocalDownloadService) Purge(id string) error {
+ destPath := ""
+
+ // Get status before deleting so we know where the file is
+ status, err := s.GetStatus(id)
+ if err == nil && status != nil {
+ destPath = filepath.Clean(status.DestPath)
+ } else {
+ // Fallback to history
+ history, err := s.History()
+ if err == nil {
+ for _, entry := range history {
+ if entry.ID == id {
+ destPath = filepath.Clean(entry.DestPath)
+ break
+ }
+ }
+ }
+ }
+
+ // Delete from engine/db
+ if err := s.Delete(id); err != nil {
+ return err
+ }
+
+ // Delete files if we found a path
+ if destPath != "" && destPath != "." {
+ var errs []string
+ if err := utils.RemoveFile(destPath); err != nil && !os.IsNotExist(err) {
+ errs = append(errs, err.Error())
+ }
+ if err := utils.RemoveFile(destPath + types.IncompleteSuffix); err != nil && !os.IsNotExist(err) {
+ errs = append(errs, err.Error())
+ }
+ if len(errs) > 0 {
+ return fmt.Errorf("failed to delete files: %s", strings.Join(errs, ", "))
+ }
+ }
+ return nil
+}
+
// GetStatus returns a status for a single download by id.
func (s *LocalDownloadService) GetStatus(id string) (*types.DownloadStatus, error) {
if id == "" {
diff --git a/internal/core/remote_service.go b/internal/core/remote_service.go
index 045e13f..11fec72 100644
--- a/internal/core/remote_service.go
+++ b/internal/core/remote_service.go
@@ -238,6 +238,16 @@ func (s *RemoteDownloadService) Delete(id string) error {
return nil
}
+// Purge cancels and removes a download, and deletes its files from disk.
+func (s *RemoteDownloadService) Purge(id string) error {
+ resp, err := s.doRequest("POST", "/purge?id="+url.QueryEscape(id), nil)
+ if err != nil {
+ return err
+ }
+ defer func() { _ = resp.Body.Close() }()
+ return nil
+}
+
// Shutdown stops the service.
func (s *RemoteDownloadService) Shutdown() error {
s.cancel()
diff --git a/internal/engine/state/file_other.go b/internal/engine/state/file_other.go
index 427e82a..c1d62cd 100644
--- a/internal/engine/state/file_other.go
+++ b/internal/engine/state/file_other.go
@@ -2,9 +2,10 @@
package state
-import "os"
+import "github.com/SurgeDM/Surge/internal/utils"
-// retryRemove is a no-op wrapper on non-Windows platforms.
+// retryRemove delegates to utils.RemoveFile. On non-Windows platforms this is
+// a direct os.Remove call; the wrapper exists only for API consistency.
func retryRemove(path string) error {
- return os.Remove(path)
+ return utils.RemoveFile(path)
}
diff --git a/internal/engine/state/file_windows.go b/internal/engine/state/file_windows.go
index 4805be3..f640297 100644
--- a/internal/engine/state/file_windows.go
+++ b/internal/engine/state/file_windows.go
@@ -2,31 +2,10 @@
package state
-import (
- "os"
- "time"
+import "github.com/SurgeDM/Surge/internal/utils"
- "github.com/SurgeDM/Surge/internal/utils"
-)
-
-const (
- retryAttempts = 5
- retryBaseInterval = 50 * time.Millisecond
-)
-
-// retryRemove wraps os.Remove with exponential backoff for transient Windows
-// file-locking errors (e.g. antivirus scanners, delayed handle release).
+// retryRemove delegates to utils.RemoveFile which implements exponential-backoff
+// retry for transient Windows file-locking errors.
func retryRemove(path string) error {
- var err error
- wait := retryBaseInterval
- for i := 0; i < retryAttempts; i++ {
- err = os.Remove(path)
- if err == nil || os.IsNotExist(err) {
- return nil
- }
- utils.Debug("retryRemove(%s): attempt %d failed: %v", path, i+1, err)
- time.Sleep(wait)
- wait *= 2
- }
- return err
+ return utils.RemoveFile(path)
}
diff --git a/internal/lint/unicode_lint_test.go b/internal/lint/unicode_lint_test.go
new file mode 100644
index 0000000..9abd12a
--- /dev/null
+++ b/internal/lint/unicode_lint_test.go
@@ -0,0 +1,149 @@
+// Package lint contains project-wide static-analysis tests that are safe to
+// run in CI without any external tooling. They have no dependencies on other
+// internal packages and require no TestMain setup.
+package lint_test
+
+import (
+ "fmt"
+ "go/scanner"
+ "go/token"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "unicode"
+)
+
+// TestNoRawUnicodeInStringLiterals walks every .go file in the repository and
+// fails if any string literal contains a raw non-ASCII character (e.g. '\u2716'
+// written literally as "Γ£û") instead of a \uXXXX escape sequence.
+//
+// Why: raw glyphs are invisible in diffs, harder to grep, and can silently
+// break on terminals that do not support the relevant Unicode block. The
+// project convention is to use \uXXXX escapes in all string literals.
+//
+// Run in CI with:
+//
+// go test ./internal/lint/...
+func TestNoRawUnicodeInStringLiterals(t *testing.T) {
+ root := projectRoot(t)
+
+ // Directories to skip entirely.
+ skipDirs := map[string]bool{
+ ".git": true,
+ "vendor": true,
+ "testdata": true,
+ }
+
+ type violation struct {
+ file string
+ line int
+ col int
+ raw rune
+ literal string
+ }
+ var violations []violation
+
+ err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if d.IsDir() {
+ if skipDirs[d.Name()] {
+ return filepath.SkipDir
+ }
+ return nil
+ }
+ if !strings.HasSuffix(d.Name(), ".go") {
+ return nil
+ }
+ // Test files intentionally use raw Unicode as test data
+ // (e.g. CJK filenames, box-drawing chars in render snapshots).
+ // Only production code must use \uXXXX escapes.
+ if strings.HasSuffix(d.Name(), "_test.go") {
+ return nil
+ }
+
+ src, err := os.ReadFile(path)
+ if err != nil {
+ return fmt.Errorf("read %s: %w", path, err)
+ }
+
+ fset := token.NewFileSet()
+ file := fset.AddFile(path, -1, len(src))
+
+ var s scanner.Scanner
+ // Suppress scanner errors ΓÇô malformed files are reported separately by
+ // the compiler; we do not want lint noise to abort the walk.
+ s.Init(file, src, func(_ token.Position, _ string) {}, 0)
+
+ for {
+ pos, tok, lit := s.Scan()
+ if tok == token.EOF {
+ break
+ }
+ if tok != token.STRING {
+ continue
+ }
+
+ // lit is the *raw source text* of the token, including surrounding
+ // quotes or back-ticks. If the programmer wrote "Γ£û" in source, lit
+ // will contain the actual UTF-8 bytes of that glyph. If they wrote
+ // "\u2716" in source, lit only contains ASCII bytes.
+ for _, r := range lit {
+ if r > 0x7F && unicode.IsPrint(r) {
+ position := fset.Position(pos)
+ rel, _ := filepath.Rel(root, path)
+ violations = append(violations, violation{
+ file: filepath.ToSlash(rel),
+ line: position.Line,
+ col: position.Column,
+ raw: r,
+ literal: lit,
+ })
+ // One report per literal keeps output manageable.
+ break
+ }
+ }
+ }
+ return nil
+ })
+
+ if err != nil {
+ t.Fatalf("walk error: %v", err)
+ }
+
+ for _, v := range violations {
+ t.Errorf(
+ "%s:%d:%d: raw Unicode glyph %q (\\u%04X) in string literal ΓÇö use \\u%04X escape instead",
+ v.file, v.line, v.col, string(v.raw), v.raw, v.raw,
+ )
+ }
+ if len(violations) > 0 {
+ t.Logf(
+ "%d string literal(s) contain raw Unicode glyphs. Replace each glyph with its \\uXXXX escape sequence.",
+ len(violations),
+ )
+ }
+}
+
+// projectRoot walks upward from the test binary's working directory until it
+// finds the directory that contains go.mod, which is the module root.
+func projectRoot(t *testing.T) string {
+ t.Helper()
+ dir, err := os.Getwd()
+ if err != nil {
+ t.Fatalf("getwd: %v", err)
+ }
+ for {
+ if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
+ return dir
+ }
+ parent := filepath.Dir(dir)
+ if parent == dir {
+ t.Fatal("could not locate go.mod: reached filesystem root")
+ }
+ dir = parent
+ }
+}
diff --git a/internal/tui/delete_resilience_test.go b/internal/tui/delete_resilience_test.go
index 055ccca..874ba08 100644
--- a/internal/tui/delete_resilience_test.go
+++ b/internal/tui/delete_resilience_test.go
@@ -19,6 +19,10 @@ func (m *mockService) Delete(id string) error {
return m.deleteErr
}
+func (m *mockService) Purge(id string) error {
+ return m.Delete(id)
+}
+
func (m *mockService) List() ([]types.DownloadStatus, error) { return nil, nil }
func (m *mockService) History() ([]types.DownloadEntry, error) { return nil, nil }
func (m *mockService) Add(url string, path string, filename string, mirrors []string, headers map[string]string, isExplicitCategory bool, totalSize int64, supportsRange bool) (string, error) {
diff --git a/internal/tui/model.go b/internal/tui/model.go
index e0f7763..e96d080 100644
--- a/internal/tui/model.go
+++ b/internal/tui/model.go
@@ -58,6 +58,7 @@ const (
BugReportSystemDetailsState
BugReportLogPathState
CategoryResetConfirmState
+ PurgeConfirmState
)
type FilePickerOrigin int
@@ -193,6 +194,9 @@ type RootModel struct {
// Quit confirm button focus (0 = Yep!, 1 = Nope)
quitConfirmFocused int
+ // Purge confirm: ID of download pending file deletion
+ purgeTargetID string
+
// Bug report flow context
bugReportIncludeSystemInfo bool
bugReportIncludeLatestLog bool
diff --git a/internal/tui/update.go b/internal/tui/update.go
index 825e862..c18f357 100644
--- a/internal/tui/update.go
+++ b/internal/tui/update.go
@@ -270,6 +270,9 @@ func (m RootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case CategoryResetConfirmState:
return m.updateCategoryResetConfirm(msg)
+ case PurgeConfirmState:
+ return m.updatePurgeConfirm(msg)
+
default:
return m, nil
}
diff --git a/internal/tui/update_dashboard.go b/internal/tui/update_dashboard.go
index c587859..b5c622b 100644
--- a/internal/tui/update_dashboard.go
+++ b/internal/tui/update_dashboard.go
@@ -146,7 +146,7 @@ func (m RootModel) updateDashboard(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
// Fall through
} else if d := m.GetSelectedDownload(); d != nil {
if m.Service == nil {
- m.addLogEntry(LogStyleError.Render("Γ£û Service unavailable"))
+ m.addLogEntry(LogStyleError.Render("\u2716 Service unavailable"))
return m, nil
}
targetID := d.ID
@@ -158,7 +158,7 @@ func (m RootModel) updateDashboard(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
if errors.Is(err, types.ErrNotFound) {
m.removeDownloadByID(targetID)
} else {
- m.addLogEntry(LogStyleError.Render("Γ£û Delete failed: " + err.Error()))
+ m.addLogEntry(LogStyleError.Render("\u2716 Delete failed: " + err.Error()))
}
} else {
m.removeDownloadByID(targetID)
@@ -168,11 +168,31 @@ func (m RootModel) updateDashboard(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
}
}
+ // Delete download + file from disk (purge)
+ if key.Matches(msg, m.keys.Dashboard.PurgeFile) {
+ if m.list.FilterState() == list.Filtering {
+ // Fall through
+ } else if d := m.GetSelectedDownload(); d != nil {
+ if !d.done || d.err != nil {
+ m.addLogEntry(LogStyleError.Render("\u2716 Purge is only for successfully completed downloads"))
+ return m, nil
+ }
+ if m.Service == nil {
+ m.addLogEntry(LogStyleError.Render("\u2716 Service unavailable"))
+ return m, nil
+ }
+ m.purgeTargetID = d.ID
+ m.quitConfirmFocused = 1 // default focus on "Cancel"
+ m.state = PurgeConfirmState
+ return m, nil
+ }
+ }
+
// Pause/Resume toggle
if key.Matches(msg, m.keys.Dashboard.Pause) {
if d := m.GetSelectedDownload(); d != nil {
if m.Service == nil {
- m.addLogEntry(LogStyleError.Render("Γ£û Service unavailable"))
+ m.addLogEntry(LogStyleError.Render("\u2716 Service unavailable"))
return m, nil
}
if !d.done {
@@ -181,14 +201,14 @@ func (m RootModel) updateDashboard(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
d.paused = false
d.resuming = true
if err := m.Service.Resume(d.ID); err != nil {
- m.addLogEntry(LogStyleError.Render("Γ£û Resume failed: " + err.Error()))
+ m.addLogEntry(LogStyleError.Render("\u2716 Resume failed: " + err.Error()))
d.paused = true // Revert
d.resuming = false
}
} else {
// Pause
if err := m.Service.Pause(d.ID); err != nil {
- m.addLogEntry(LogStyleError.Render("Γ£û Pause failed: " + err.Error()))
+ m.addLogEntry(LogStyleError.Render("\u2716 Pause failed: " + err.Error()))
} else {
d.resuming = false
d.pausing = true
@@ -219,7 +239,7 @@ func (m RootModel) updateDashboard(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
if key.Matches(msg, m.keys.Dashboard.Refresh) {
if d := m.GetSelectedDownload(); d != nil {
if m.Service == nil {
- m.addLogEntry(LogStyleError.Render("Γ£û Service unavailable"))
+ m.addLogEntry(LogStyleError.Render("\u2716 Service unavailable"))
return m, nil
}
// Only allow refresh if download is paused or errored
@@ -228,7 +248,7 @@ func (m RootModel) updateDashboard(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
m.urlUpdateInput.SetValue(d.URL)
m.urlUpdateInput.Focus()
} else {
- m.addLogEntry(LogStyleError.Render("Γ£û Pause download before refreshing URL"))
+ m.addLogEntry(LogStyleError.Render("\u2716 Pause download before refreshing URL"))
}
}
return m, nil
@@ -267,11 +287,11 @@ func (m RootModel) updateDashboard(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
if !config.Resolve[bool](m.Settings.Categories.CategoryEnabled) || len(m.Settings.Categories.Categories) == 0 {
if m.categoryFilter != "" {
m.categoryFilter = ""
- m.addLogEntry(LogStyleStarted.Render("📂 Filter: All"))
+ m.addLogEntry(LogStyleStarted.Render("\U0001F4C2 Filter: All"))
m.UpdateListItems()
return m, nil
}
- m.addLogEntry(LogStyleError.Render("Γ£û Enable categories in Settings first"))
+ m.addLogEntry(LogStyleError.Render("\u2716 Enable categories in Settings first"))
return m, nil
}
names := config.CategoryNames(m.Settings.Categories.Categories)
@@ -289,7 +309,7 @@ func (m RootModel) updateDashboard(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
if label == "" {
label = "All"
}
- m.addLogEntry(LogStyleStarted.Render("📂 Filter: " + label))
+ m.addLogEntry(LogStyleStarted.Render("\U0001F4C2 Filter: " + label))
m.UpdateListItems()
return m, nil
}
diff --git a/internal/tui/update_filepicker.go b/internal/tui/update_filepicker.go
index 916e30a..eeddff0 100644
--- a/internal/tui/update_filepicker.go
+++ b/internal/tui/update_filepicker.go
@@ -9,7 +9,7 @@ import (
func (m *RootModel) handleBatchFileSelection(path string) (tea.Model, tea.Cmd) {
urls, err := utils.ReadURLsFromFile(path)
if err != nil {
- m.addLogEntry(LogStyleError.Render("Γ£û Failed to read batch file: " + err.Error()))
+ m.addLogEntry(LogStyleError.Render("\u2716 Failed to read batch file: " + err.Error()))
m.resetFilepickerToDirMode()
m.state = DashboardState
return m, nil
diff --git a/internal/tui/update_modals.go b/internal/tui/update_modals.go
index 6d476c9..1ade05b 100644
--- a/internal/tui/update_modals.go
+++ b/internal/tui/update_modals.go
@@ -1,6 +1,7 @@
package tui
import (
+ "errors"
"fmt"
"strings"
@@ -9,6 +10,7 @@ import (
"github.com/SurgeDM/Surge/internal/bugreport"
"github.com/SurgeDM/Surge/internal/clipboard"
"github.com/SurgeDM/Surge/internal/config"
+ "github.com/SurgeDM/Surge/internal/engine/types"
"github.com/SurgeDM/Surge/internal/utils"
)
@@ -350,21 +352,21 @@ func (m RootModel) buildCoreBugReportURL() string {
func (m RootModel) tryOpenBugReportURL(reportURL string) RootModel {
if reportURL == "" {
- m.addLogEntry(LogStyleError.Render("Γ£û Could not open browser. Try running surge bug-report from your terminal instead."))
+ m.addLogEntry(LogStyleError.Render("\u2716 Could not open browser. Try running surge bug-report from your terminal instead."))
return m
}
if err := openBugReportBrowser(reportURL); err != nil {
if err := writeBugReportClipboard(reportURL); err == nil {
- m.addLogEntry(LogStyleError.Render("Γ£û Could not open browser. URL copied to clipboard."))
+ m.addLogEntry(LogStyleError.Render("\u2716 Could not open browser. URL copied to clipboard."))
return m
}
- m.addLogEntry(LogStyleError.Render("Γ£û Could not open browser. Try running surge bug-report from your terminal instead."))
+ m.addLogEntry(LogStyleError.Render("\u2716 Could not open browser. Try running surge bug-report from your terminal instead."))
return m
}
- m.addLogEntry(LogStyleStarted.Render("🐞 Opening browser to file bug report..."))
+ m.addLogEntry(LogStyleStarted.Render("\U0001F41E Opening browser to file bug report..."))
return m
}
@@ -437,3 +439,78 @@ func (m RootModel) updateCategoryResetConfirm(msg tea.KeyPressMsg) (tea.Model, t
return m, nil
}
+
+func (m RootModel) updatePurgeConfirm(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
+ confirmPurge := func() (tea.Model, tea.Cmd) {
+ targetID := m.purgeTargetID
+ m.purgeTargetID = ""
+ m.quitConfirmFocused = 0
+ m.state = DashboardState
+
+ if m.Service == nil {
+ m.addLogEntry(LogStyleError.Render("\u2716 Service unavailable"))
+ return m, nil
+ }
+
+ filename := ""
+ if d := m.FindDownloadByID(targetID); d != nil {
+ filename = d.Filename
+ }
+ if filename == "" {
+ if status, err := m.Service.GetStatus(targetID); err == nil && status != nil {
+ filename = status.Filename
+ }
+ if filename == "" {
+ if history, err := m.Service.History(); err == nil {
+ for _, entry := range history {
+ if entry.ID == targetID {
+ filename = entry.Filename
+ break
+ }
+ }
+ }
+ }
+ }
+
+ err := m.Service.Purge(targetID)
+
+ m.removeDownloadByID(targetID)
+ m.UpdateListItems()
+
+ if err != nil {
+ if !errors.Is(err, types.ErrNotFound) {
+ m.addLogEntry(LogStyleError.Render("\u2716 Purge failed: " + err.Error()))
+ return m, nil
+ }
+ }
+
+ if filename != "" {
+ m.addLogEntry(LogStyleStarted.Render("\u2714 Purged: " + filename))
+ } else {
+ m.addLogEntry(LogStyleStarted.Render("\u2714 Purged download"))
+ }
+
+ return m, nil
+ }
+
+ cancelPurge := func() (tea.Model, tea.Cmd) {
+ m.purgeTargetID = ""
+ m.quitConfirmFocused = 0
+ m.state = DashboardState
+ return m, nil
+ }
+
+ m, decision, handled := m.handleYesNoSelection(msg)
+ if !handled {
+ return m, nil
+ }
+
+ switch decision {
+ case yesNoYes:
+ return confirmPurge()
+ case yesNoNo, yesNoCancel:
+ return cancelPurge()
+ }
+
+ return m, nil
+}
diff --git a/internal/tui/view.go b/internal/tui/view.go
index 9dc59c2..5bb7727 100644
--- a/internal/tui/view.go
+++ b/internal/tui/view.go
@@ -71,7 +71,7 @@ func (m RootModel) View() tea.View {
// Terminal too small to render any meaningful layout
if m.width < MinTermWidth || m.height < MinTermHeight {
- msg := lipgloss.NewStyle().Foreground(colors.Cyan()).Render(fmt.Sprintf("Terminal too small (min: %d×%d)", MinTermWidth, MinTermHeight))
+ msg := lipgloss.NewStyle().Foreground(colors.Cyan()).Render(fmt.Sprintf("Terminal too small (min: %d\u00D7%d)", MinTermWidth, MinTermHeight))
return m.wrapView(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, msg))
}
@@ -307,6 +307,10 @@ func (m RootModel) View() tea.View {
return m.wrapView(m.renderModalWithOverlay(m.viewCategoryResetConfirm()))
}
+ if m.state == PurgeConfirmState {
+ return m.wrapView(m.renderModalWithOverlay(m.viewPurgeConfirm()))
+ }
+
if m.state == UpdateAvailableState && m.UpdateInfo != nil {
modal := components.ConfirmationModal{
Title: "\u2b06 Update Available",
@@ -938,6 +942,38 @@ func (m RootModel) viewRestartConfirm() string {
return renderBtopBox(PaneTitleStyle.Render(" Restart Required "), "", content, w, h, colors.Orange())
}
+func (m RootModel) viewPurgeConfirm() string {
+ filename := ""
+ if d := m.FindDownloadByID(m.purgeTargetID); d != nil {
+ filename = d.Filename
+ }
+
+ if filename == "" {
+ filename = "this download"
+ } else if len(filename) > 30 {
+ filename = filename[:27] + "..."
+ }
+
+ modal := components.ConfirmationModal{
+ Title: "Purge Download",
+ Message: "Permanently delete this download?",
+ Detail: fmt.Sprintf("File: %s\nThis will also remove the downloaded file(s) from disk.", filename),
+ Keys: m.keys.QuitConfirm, // QuitConfirm works as a general yes/no
+ Help: m.help,
+ BorderColor: colors.Red(),
+ ShowYesNoButtons: true,
+ YesNoFocused: m.quitConfirmFocused,
+ YesLabel: "Yes",
+ NoLabel: "No",
+ }
+
+ w, h := GetDynamicModalDimensions(m.width, m.height, 46, 8, 60, 12)
+ modal.Width = w
+ modal.Height = h
+
+ return modal.RenderWithBtopBox(renderBtopBox, PaneTitleStyle)
+}
+
func (m RootModel) viewCategoryResetConfirm() string {
w, h := GetDynamicModalDimensions(m.width, m.height, 40, 8, 60, 10)
innerWidth := w - (components.BorderFrameWidth * 2)
diff --git a/internal/utils/remove_other.go b/internal/utils/remove_other.go
new file mode 100644
index 0000000..65576b7
--- /dev/null
+++ b/internal/utils/remove_other.go
@@ -0,0 +1,13 @@
+//go:build !windows
+
+package utils
+
+import "os"
+
+// RemoveFile removes a file from disk. On non-Windows platforms this is a
+// direct call to os.Remove; no retry is needed because POSIX unlink semantics
+// allow removing an open file (the directory entry is removed immediately and
+// the data persists until the last file descriptor is closed).
+func RemoveFile(path string) error {
+ return os.Remove(path)
+}
diff --git a/internal/utils/remove_windows.go b/internal/utils/remove_windows.go
new file mode 100644
index 0000000..e3b4125
--- /dev/null
+++ b/internal/utils/remove_windows.go
@@ -0,0 +1,37 @@
+//go:build windows
+
+package utils
+
+import (
+ "os"
+ "time"
+)
+
+const (
+ removeRetryAttempts = 5
+ removeRetryBaseInterval = 50 * time.Millisecond
+)
+
+// RemoveFile removes a file from disk. On Windows it retries with exponential
+// backoff to handle transient file-locking errors (antivirus scanners, delayed
+// handle release from the download engine, etc.).
+//
+// Callers should prefer this over os.Remove for any downloaded or in-progress
+// file so that Windows users do not see spurious "access denied" errors.
+func RemoveFile(path string) error {
+ wait := removeRetryBaseInterval
+ for i := 0; i <= removeRetryAttempts; i++ {
+ err := os.Remove(path)
+ if err == nil || os.IsNotExist(err) {
+ return nil
+ }
+ if i == removeRetryAttempts {
+ Debug("RemoveFile(%s): final attempt %d failed: %v", path, i+1, err)
+ return err
+ }
+ Debug("RemoveFile(%s): attempt %d failed: %v", path, i+1, err)
+ time.Sleep(wait)
+ wait *= 2
+ }
+ return nil
+}
diff --git a/internal/utils/text.go b/internal/utils/text.go
index 8141528..c4777bf 100644
--- a/internal/utils/text.go
+++ b/internal/utils/text.go
@@ -164,11 +164,11 @@ func Truncate(s string, limit int) string {
return s
}
if limit <= 1 {
- return "…"
+ return "\u2026"
}
sub := truncateToWidth(s, limit-1)
- return sub + "…"
+ return sub + "\u2026"
}
// TruncateMiddle truncates a string in the middle to a maximum visual width.
@@ -225,10 +225,10 @@ func TruncateMiddle(s string, limit int) string {
if !strings.HasSuffix(lStr, "\x1b[0m") {
lStr += "\x1b[0m"
}
- return lStr + "…" + state + right.String()
+ return lStr + "\u2026" + state + right.String()
}
- return lStr + "…" + right.String()
+ return lStr + "\u2026" + right.String()
}
// TruncateTwoLines middle-truncates a string to fit in at most 2 lines of a given width.