From 1191b23cd4892246b69e53b735198b562ec309fc Mon Sep 17 00:00:00 2001 From: frathe Date: Sun, 16 Aug 2026 22:15:41 +0200 Subject: [PATCH] Fix crash deleting the last image in a multi-file set RemoveFile trimmed v.files but never adjusted v.index, so deleting while viewing the last image left v.index pointing past the shrunk slice - the next CurrentFile() call then indexed out of range and crashed the app. Clamp v.index in RemoveFile when it now points past the end. --- internal/ui/delete_test.go | 30 ++++++++++++++++++++++++++++++ internal/ui/viewer.go | 8 ++++++++ 2 files changed, 38 insertions(+) diff --git a/internal/ui/delete_test.go b/internal/ui/delete_test.go index f3667ac..00c0ce1 100644 --- a/internal/ui/delete_test.go +++ b/internal/ui/delete_test.go @@ -96,6 +96,36 @@ func TestPerformDelete_RemovesCurrentFileAndAdvancesToTheNextOne(t *testing.T) { settleToast(t, v) } +// TestPerformDelete_OnLastImageOfMultipleAdvancesWithoutPanicking is a +// regression test: deleting while positioned on the last image of a +// multi-file set left v.index equal to the new (shrunk) length, so the very +// next CurrentFile() call - performDelete's own "did that empty the set?" +// check - indexed v.files out of range and crashed the whole app. +func TestPerformDelete_OnLastImageOfMultipleAdvancesWithoutPanicking(t *testing.T) { + uitest.StubTrashMove(t, func(path string) error { return os.Remove(path) }) + v := newTestViewer(t) + a := uitest.TempJPEGURI(t, "a.jpg", 4, 4, color.White) + b := uitest.TempJPEGURI(t, "b.jpg", 4, 4, color.White) + dropAndWait(t, v, a, b) + + v.handleKeyEvent(&fyne.KeyEvent{Name: fyne.KeyRight}) + waitUntilLoaded(t, v) + if v.index != 1 { + t.Fatalf("setup: index = %d, want 1 (on b.jpg, the last image)", v.index) + } + + confirmDelete(t, v) + waitUntilLoaded(t, v) + + if len(v.files) != 1 || v.files[0].String() != a.String() { + t.Fatalf("files = %v, want just a.jpg left", v.files) + } + if v.index != 0 { + t.Errorf("index = %d, want 0 (a.jpg took b.jpg's slot)", v.index) + } + settleToast(t, v) +} + // TestPerformDelete_LastFileReturnsToEmptyDropzone covers deleting the only // remaining file: the app should fall back to the empty-state screen, the // same place a last decode failure already lands it. diff --git a/internal/ui/viewer.go b/internal/ui/viewer.go index add32ea..891e8c8 100644 --- a/internal/ui/viewer.go +++ b/internal/ui/viewer.go @@ -625,6 +625,14 @@ func (v *viewer) RemoveFile(i int) { v.files = append(v.files[:i], v.files[i+1:]...) v.imgCache.Remove(target.String()) + // Callers always remove the file currently at v.index, so once it's + // gone v.index may point past the new end (e.g. deleting the last + // image) - clamp it back onto the shrunk slice, same as attemptLoad's + // wraparound does for the retry path. + if v.index >= len(v.files) { + v.index = len(v.files) - 1 + } + for j, u := range v.unsortedFiles { if u.String() == target.String() { v.unsortedFiles = append(v.unsortedFiles[:j], v.unsortedFiles[j+1:]...)