Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .claude/memory/go-expert.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ Persistent notes for the go-expert agent on this repo. Local-only (gitignored)

## Architecture decisions

- (2026-08-19) Favorites are split into pure `internal/favstore` persistence and a Host-based `internal/ui/favorites` feature. Production storage is initialized explicitly from `ui.Run`, not during viewer construction, so tests never scan the user's real config directory. The first implementation stores only named file lists; disk-backed thumbnail caching remains a separate follow-up.
- (2026-08-19) Favorite accelerators are positional over the current case-insensitively sorted list: Cmd/Ctrl+1–9 open entries 1–9 and Cmd/Ctrl+0 opens entry 10. Register all ten handlers once in `buildViewer`; `favorites.Feature.Open` resolves a slot against names captured by the latest menu refresh, so add/remove never requires shortcut re-registration.
- (2026-08-15) A feature that reorders/replaces `v.files`/`v.unsortedFiles` off the UI goroutine gets its *own* staleness generation counter (e.g. `sortGen`), not `v.gen`. `v.gen` is specifically the load/decode/animation generation - every bump site pairs it with `stopAnimation()`, and reusing it for an unrelated async op would spuriously kill a playing GIF or in-flight preload. Precedent: `toast.gen`. When adding a new async op, grep for every existing writer of the state it will eventually overwrite (`grep -rn "v\.files\s*=\|v\.unsortedFiles\s*="` etc.) and bump the new op's own gen at each of those sites too, so a stale background result can't clobber state a *different* feature changed in the meantime - not just a newer instance of the same op.
- (2026-08-15) Async UI operations in this codebase (scan, sort, ...) all follow one shape: synchronous snapshot of inputs on the UI goroutine -> bump a dedicated gen counter -> show spinner/label + `ForceRepaint()` -> `go func() { compute; fyne.Do(applyResult) }()` -> `applyResult` does `defer close(doneChan)` first, unconditionally resets the spinner/loading flag, *then* checks the gen for staleness before touching shared state. Follow this shape for any future long-running operation instead of inventing a new one. Reference implementations: `sort.go`'s `startSort`/`finishSort` (the generic form, with an `onDone` callback - used by both `SetSortMode` and `drop.go`'s `applyScannedFiles`) and `drop.go`'s `handleDrop`/`applyScanResult`.
- (2026-08-15) A snapshot handed to one of those background goroutines must be a *defensive copy* when it comes from `v.files`/`v.unsortedFiles`, never a bare slice-header assignment: `RemoveFile` shifts those slices in place (`append(s[:j], s[j+1:]...)`), so an aliased snapshot is a genuine data race with whatever the goroutine reads. Same for merges - copy first, then append, so a spare-capacity append doesn't write into the shared array either. Confirmed reachable under `-race`, not theoretical; guarded by `TestSetSortMode_SnapshotDoesNotAliasUnsortedFiles`.
Expand Down
15 changes: 13 additions & 2 deletions ARCHITECTURE.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion FyneApp.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
Name = "PicFetch"
ID = "io.github.frathe.picfetch"
Version = "0.1.7"
Build = 299
Build = 310

[Migrations]
fyneDo = true
7 changes: 7 additions & 0 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,13 @@ <h3>Folders and merging</h3>
trees. <kbd>M</kbd> makes further drops add to the set instead of
replacing it.</p>
</li>
<li class="card">
<h3>Named favorites</h3>
<p>Save the current file list as a collection and reopen it from the
Favorites menu after a restart. The first ten are one shortcut away with
<kbd>Cmd/Ctrl</kbd>+<kbd>1</kbd>–<kbd>9</kbd> and
<kbd>Cmd/Ctrl</kbd>+<kbd>0</kbd>.</p>
</li>
</ul>

<h2 id="downloads">Download</h2>
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ require (
require (
fyne.io/systray v1.12.2 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/FyshOS/fancyfs v0.0.1 // indirect
github.com/anthonynsimon/bild v0.14.0 // indirect
github.com/clipperhouse/uax29/v2 v2.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
Expand All @@ -32,7 +33,6 @@ require (
github.com/hack-pad/safejs v0.1.0 // indirect
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/mattn/go-runewidth v0.0.24 // indirect
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect
Expand Down
3 changes: 2 additions & 1 deletion go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA=
fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/FyshOS/fancyfs v0.0.1 h1:kgvm7VvwOMLkYTqSflplp62SlMVWQ2uAoHw9CXwXHYg=
github.com/FyshOS/fancyfs v0.0.1/go.mod h1:S5SHVz/5R72iCXOxCqdcyTPSlg3JxNd0gaHyGBSrY8A=
github.com/anthonynsimon/bild v0.14.0 h1:IFRkmKdNdqmexXHfEU7rPlAmdUZ8BDZEGtGHDnGWync=
github.com/anthonynsimon/bild v0.14.0/go.mod h1:hcvEAyBjTW69qkKJTfpcDQ83sSZHxwOunsseDfeQhUs=
github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
Expand Down
170 changes: 170 additions & 0 deletions internal/favstore/favstore.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
// Package favstore persists named lists of image files.
package favstore

import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"

"fyne.io/fyne/v2"
"fyne.io/fyne/v2/storage"

"github.com/frathe/picfetch/internal/trash"
)

const fileListName = "file-list.json"

// DefaultDir returns the directory used for favorites in production.
func DefaultDir() string {
base, err := os.UserConfigDir()
if err != nil || base == "" {
base = os.TempDir()
}
return filepath.Join(base, "picfetch", "favorites")
}

// ValidName reports whether name is safe to use as one directory component.
func ValidName(name string) bool {
return name != "" &&
name != "." &&
name != ".." &&
!strings.ContainsAny(name, `/\:*?"<>|`)
}

// Exists reports whether a favorite with name exists.
func Exists(dir, name string) bool {
if !ValidName(name) {
return false
}
info, err := os.Stat(filepath.Join(dir, name, fileListName))
return err == nil && !info.IsDir()
}

// List returns favorite names sorted case-insensitively.
func List(dir string) ([]string, error) {
entries, err := os.ReadDir(dir)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, err
}

names := make([]string, 0, len(entries))
for _, entry := range entries {
if !entry.IsDir() || !ValidName(entry.Name()) {
continue
}
info, err := os.Stat(filepath.Join(dir, entry.Name(), fileListName))
if err == nil && !info.IsDir() {
names = append(names, entry.Name())
continue
}
if err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, err
}
}

sort.Slice(names, func(i, j int) bool {
left, right := strings.ToLower(names[i]), strings.ToLower(names[j])
if left == right {
return names[i] < names[j]
}
return left < right
})
return names, nil
}

// Save atomically writes files as the favorite named name.
func Save(dir, name string, files []fyne.URI) error {
if !ValidName(name) {
return fmt.Errorf("invalid favorite name %q", name)
}

list := make(map[string]string, len(files))
for i, file := range files {
if file == nil {
return fmt.Errorf("favorite file %d is nil", i)
}
list[strconv.Itoa(i)] = file.Path()
}

favoriteDir := filepath.Join(dir, name)
if err := os.MkdirAll(favoriteDir, 0o755); err != nil {
return err
}

tmp, err := os.CreateTemp(favoriteDir, ".file-list-*.json")
if err != nil {
return err
}
tmpPath := tmp.Name()
defer func() { _ = os.Remove(tmpPath) }()

if err := tmp.Chmod(0o644); err != nil {
_ = tmp.Close()
return err
}
if err := json.NewEncoder(tmp).Encode(list); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmpPath, filepath.Join(favoriteDir, fileListName))
}

// Load returns the files stored in the favorite named name.
func Load(dir, name string) ([]fyne.URI, error) {
if !ValidName(name) {
return nil, fmt.Errorf("invalid favorite name %q", name)
}

data, err := os.ReadFile(filepath.Join(dir, name, fileListName))
if err != nil {
return nil, err
}

var list map[string]string
if err := json.Unmarshal(data, &list); err != nil {
return nil, err
}

type indexedPath struct {
index int
path string
}
paths := make([]indexedPath, 0, len(list))
for key, path := range list {
index, err := strconv.Atoi(key)
if err != nil || index < 0 {
return nil, fmt.Errorf("invalid file index %q", key)
}
paths = append(paths, indexedPath{index: index, path: path})
}
sort.Slice(paths, func(i, j int) bool { return paths[i].index < paths[j].index })

files := make([]fyne.URI, len(paths))
for i, item := range paths {
files[i] = storage.NewFileURI(item.path)
}
return files, nil
}

// Remove moves the favorite named name to the operating system's trash.
func Remove(dir, name string) error {
if !ValidName(name) {
return fmt.Errorf("invalid favorite name %q", name)
}
return trash.Move(filepath.Join(dir, name))
}
Loading
Loading