diff options
| author | Paul Buetow <paul@buetow.org> | 2026-01-28 19:04:38 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-01-28 19:04:38 +0200 |
| commit | 1720b2ebb12d8fdbdd372665e9d5fd394d8bc8dc (patch) | |
| tree | efd40ab63a3e305dcf712e4f753d3f7a57435b48 | |
| parent | d52cb9a8c3dedc2a5395f181981805f582d5a772 (diff) | |
Add random video selection hotkey (x)
- Add selectRandomVideo() method to randomly select from filtered videos
- Bind to 'x' hotkey in table key handler
- Respects current filters when selecting
- Add comprehensive tests covering edge cases and integration scenarios
- Update README with new keyboard shortcut documentation
Amp-Thread-ID: https://ampcode.com/threads/T-019c058b-410d-7229-9a95-afa8eb13efce
Co-authored-by: Amp <amp@ampcode.com>
| -rw-r--r-- | README.md | 3 | ||||
| -rw-r--r-- | coverage.html | 2009 | ||||
| -rw-r--r-- | coverage.out | 582 | ||||
| -rw-r--r-- | internal/app/model_keys.go | 15 | ||||
| -rw-r--r-- | internal/app/model_test.go | 489 | ||||
| -rwxr-xr-x | yoga | bin | 0 -> 5758165 bytes |
6 files changed, 3097 insertions, 1 deletions
@@ -1,6 +1,6 @@ # Yoga -Yoga is a TUI for browsing local yoga videos with quick filtering, duration probing, and one-key playback via VLC. It has ben vibe-coded. +Yoga is a TUI for browsing local yoga videos with quick filtering, duration probing, and one-key playback via VLC. It has been vibe coded.  @@ -26,6 +26,7 @@ Yoga recognises common video extensions (`.mp4`, `.mkv`, `.mov`, `.avi`, `.wmv`, - `n`, `l`, `a` – Sort by name, length, or age - `c` – Toggle VLC crop - `t` – Edit tags for the selected video +- `x` – Select a random video from filtered results - `H` / `h` – Hide or re-show the help footer - `q` – Quit diff --git a/coverage.html b/coverage.html new file mode 100644 index 0000000..a129207 --- /dev/null +++ b/coverage.html @@ -0,0 +1,2009 @@ + +<!DOCTYPE html> +<html> + <head> + <meta http-equiv="Content-Type" content="text/html; charset=utf-8"> + <title>yoga: Go Coverage Report</title> + <style> + body { + background: black; + color: rgb(80, 80, 80); + } + body, pre, #legend span { + font-family: Menlo, monospace; + font-weight: bold; + } + #topbar { + background: black; + position: fixed; + top: 0; left: 0; right: 0; + height: 42px; + border-bottom: 1px solid rgb(80, 80, 80); + } + #content { + margin-top: 50px; + } + #nav, #legend { + float: left; + margin-left: 10px; + } + #legend { + margin-top: 12px; + } + #nav { + margin-top: 10px; + } + #legend span { + margin: 0 5px; + } + .cov0 { color: rgb(192, 0, 0) } +.cov1 { color: rgb(128, 128, 128) } +.cov2 { color: rgb(116, 140, 131) } +.cov3 { color: rgb(104, 152, 134) } +.cov4 { color: rgb(92, 164, 137) } +.cov5 { color: rgb(80, 176, 140) } +.cov6 { color: rgb(68, 188, 143) } +.cov7 { color: rgb(56, 200, 146) } +.cov8 { color: rgb(44, 212, 149) } +.cov9 { color: rgb(32, 224, 152) } +.cov10 { color: rgb(20, 236, 155) } + + </style> + </head> + <body> + <div id="topbar"> + <div id="nav"> + <select id="files"> + + <option value="file0">codeberg.org/snonux/yoga/cmd/yoga/main.go (85.0%)</option> + + <option value="file1">codeberg.org/snonux/yoga/internal/app/app.go (75.0%)</option> + + <option value="file2">codeberg.org/snonux/yoga/internal/app/duration_cache.go (85.7%)</option> + + <option value="file3">codeberg.org/snonux/yoga/internal/app/filters.go (92.0%)</option> + + <option value="file4">codeberg.org/snonux/yoga/internal/app/load_progress.go (81.5%)</option> + + <option value="file5">codeberg.org/snonux/yoga/internal/app/loader.go (81.2%)</option> + + <option value="file6">codeberg.org/snonux/yoga/internal/app/model.go (83.3%)</option> + + <option value="file7">codeberg.org/snonux/yoga/internal/app/model_durations.go (87.5%)</option> + + <option value="file8">codeberg.org/snonux/yoga/internal/app/model_keys.go (78.2%)</option> + + <option value="file9">codeberg.org/snonux/yoga/internal/app/model_sort.go (92.6%)</option> + + <option value="file10">codeberg.org/snonux/yoga/internal/app/model_tags.go (64.4%)</option> + + <option value="file11">codeberg.org/snonux/yoga/internal/app/tag_commands.go (75.0%)</option> + + <option value="file12">codeberg.org/snonux/yoga/internal/app/view_helpers.go (84.4%)</option> + + <option value="file13">codeberg.org/snonux/yoga/internal/fsutil/path.go (83.0%)</option> + + <option value="file14">codeberg.org/snonux/yoga/internal/tags/tags.go (82.9%)</option> + + </select> + </div> + <div id="legend"> + <span>not tracked</span> + + <span class="cov0">not covered</span> + <span class="cov8">covered</span> + + </div> + </div> + <div id="content"> + + <pre class="file" id="file0" style="display: none">package main + +import ( + "flag" + "fmt" + "io" + "os" + "strings" + + "codeberg.org/snonux/yoga/internal/app" + "codeberg.org/snonux/yoga/internal/fsutil" + "codeberg.org/snonux/yoga/internal/meta" +) + +const defaultRoot = "~/Yoga" + +var ( + runApp = app.Run + exit = os.Exit +) + +func main() <span class="cov8" title="1">{ + exit(run(os.Args[1:], os.Stdout, os.Stderr)) +}</span> + +func run(args []string, stdout, stderr io.Writer) int <span class="cov8" title="1">{ + fs := flag.NewFlagSet("yoga", flag.ContinueOnError) + fs.SetOutput(stderr) + rootFlag := fs.String("root", "", "Directory containing yoga videos (default ~/Yoga)") + cropFlag := fs.String("crop", "", "Optional crop aspect for VLC (e.g. 5:4)") + versionFlag := fs.Bool("version", false, "Print version and exit") + if err := fs.Parse(args); err != nil </span><span class="cov0" title="0">{ + return 2 + }</span> + <span class="cov8" title="1">if *versionFlag </span><span class="cov8" title="1">{ + fmt.Fprintf(stdout, "Yoga version %s\n", meta.Version) + return 0 + }</span> + <span class="cov8" title="1">root, err := fsutil.ResolveRootPath(*rootFlag, defaultRoot) + if err != nil </span><span class="cov0" title="0">{ + fmt.Fprintf(stderr, "%v\n", err) + return 1 + }</span> + <span class="cov8" title="1">opts := app.Options{Root: root, Crop: strings.TrimSpace(*cropFlag)} + if err := runApp(opts); err != nil </span><span class="cov8" title="1">{ + fmt.Fprintf(stderr, "error: %v\n", err) + return 1 + }</span> + <span class="cov8" title="1">return 0</span> +} +</pre> + + <pre class="file" id="file1" style="display: none">package app + +import ( + "fmt" + + tea "github.com/charmbracelet/bubbletea" +) + +type teaProgram interface { + Run() (tea.Model, error) +} + +var programFactory = func(m tea.Model) teaProgram <span class="cov0" title="0">{ + return tea.NewProgram(m, tea.WithAltScreen()) +}</span> + +// Run bootstraps the Bubble Tea program with the provided options. +func Run(opts Options) error <span class="cov8" title="1">{ + model, err := newModel(opts) + if err != nil </span><span class="cov0" title="0">{ + return fmt.Errorf("create model: %w", err) + }</span> + <span class="cov8" title="1">program := programFactory(model) + if _, err := program.Run(); err != nil </span><span class="cov8" title="1">{ + return fmt.Errorf("run program: %w", err) + }</span> + <span class="cov8" title="1">return nil</span> +} +</pre> + + <pre class="file" id="file2" style="display: none">package app + +import ( + "encoding/json" + "errors" + "io/fs" + "os" + "sync" + "time" +) + +type cacheEntry struct { + DurationSeconds float64 `json:"duration_seconds"` + ModTimeUnix int64 `json:"mod_time_unix"` + Size int64 `json:"size"` +} + +type durationCache struct { + path string + entries map[string]cacheEntry + mu sync.Mutex + dirty bool +} + +func newDurationCache(path string) *durationCache <span class="cov8" title="1">{ + return &durationCache{path: path, entries: make(map[string]cacheEntry)} +}</span> + +func loadDurationCache(path string) (*durationCache, error) <span class="cov8" title="1">{ + cache := newDurationCache(path) + data, err := os.ReadFile(path) + if err != nil </span><span class="cov8" title="1">{ + if errors.Is(err, fs.ErrNotExist) </span><span class="cov8" title="1">{ + return cache, nil + }</span> + <span class="cov0" title="0">return cache, err</span> + } + <span class="cov8" title="1">if len(data) == 0 </span><span class="cov0" title="0">{ + return cache, nil + }</span> + <span class="cov8" title="1">if err := json.Unmarshal(data, &cache.entries); err != nil </span><span class="cov8" title="1">{ + cache.entries = make(map[string]cacheEntry) + return cache, err + }</span> + <span class="cov8" title="1">return cache, nil</span> +} + +func (c *durationCache) Lookup(path string, info os.FileInfo) (time.Duration, bool) <span class="cov8" title="1">{ + c.mu.Lock() + defer c.mu.Unlock() + entry, ok := c.entries[path] + if !ok </span><span class="cov8" title="1">{ + return 0, false + }</span> + <span class="cov8" title="1">if entry.ModTimeUnix != info.ModTime().Unix() || entry.Size != info.Size() </span><span class="cov8" title="1">{ + delete(c.entries, path) + c.dirty = true + return 0, false + }</span> + <span class="cov8" title="1">if entry.DurationSeconds <= 0 </span><span class="cov0" title="0">{ + return 0, false + }</span> + <span class="cov8" title="1">return time.Duration(entry.DurationSeconds * float64(time.Second)), true</span> +} + +func (c *durationCache) Record(path string, info os.FileInfo, dur time.Duration) error <span class="cov8" title="1">{ + if c == nil || dur <= 0 </span><span class="cov0" title="0">{ + return nil + }</span> + <span class="cov8" title="1">c.mu.Lock() + defer c.mu.Unlock() + if c.entries == nil </span><span class="cov0" title="0">{ + c.entries = make(map[string]cacheEntry) + }</span> + <span class="cov8" title="1">c.entries[path] = cacheEntry{ + DurationSeconds: dur.Seconds(), + ModTimeUnix: info.ModTime().Unix(), + Size: info.Size(), + } + c.dirty = true + return nil</span> +} + +func (c *durationCache) Flush() error <span class="cov8" title="1">{ + if c == nil </span><span class="cov0" title="0">{ + return nil + }</span> + <span class="cov8" title="1">c.mu.Lock() + if !c.dirty </span><span class="cov8" title="1">{ + c.mu.Unlock() + return nil + }</span> + <span class="cov8" title="1">snapshot := make(map[string]cacheEntry, len(c.entries)) + for k, v := range c.entries </span><span class="cov8" title="1">{ + snapshot[k] = v + }</span> + <span class="cov8" title="1">c.dirty = false + c.mu.Unlock() + data, err := json.MarshalIndent(snapshot, "", " ") + if err != nil </span><span class="cov0" title="0">{ + return err + }</span> + <span class="cov8" title="1">return os.WriteFile(c.path, data, 0o644)</span> +} +</pre> + + <pre class="file" id="file3" style="display: none">package app + +import ( + "errors" + "fmt" + "strconv" + "strings" + "time" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" +) + +type filterState struct { + name string + minEnabled bool + minMinutes int + maxEnabled bool + maxMinutes int + tags string +} + +type filterInputs struct { + fields []textinput.Model + focus int +} + +func (m *model) applyFilterInputs() error <span class="cov8" title="1">{ + name := strings.TrimSpace(m.inputs.fields[0].Value()) + minText := strings.TrimSpace(m.inputs.fields[1].Value()) + maxText := strings.TrimSpace(m.inputs.fields[2].Value()) + tags := strings.TrimSpace(m.inputs.fields[3].Value()) + + filters := filterState{name: name, tags: tags} + if err := populateMinFilter(&filters, minText); err != nil </span><span class="cov0" title="0">{ + return err + }</span> + <span class="cov8" title="1">if err := populateMaxFilter(&filters, maxText); err != nil </span><span class="cov0" title="0">{ + return err + }</span> + <span class="cov8" title="1">if filters.minEnabled && filters.maxEnabled && filters.minMinutes > filters.maxMinutes </span><span class="cov0" title="0">{ + return errors.New("min minutes cannot exceed max minutes") + }</span> + <span class="cov8" title="1">m.filters = filters + return nil</span> +} + +func populateMinFilter(dst *filterState, value string) error <span class="cov8" title="1">{ + if value == "" </span><span class="cov8" title="1">{ + return nil + }</span> + <span class="cov8" title="1">minutes, err := strconv.Atoi(value) + if err != nil </span><span class="cov8" title="1">{ + return fmt.Errorf("invalid min minutes: %q", value) + }</span> + <span class="cov8" title="1">if minutes < 0 </span><span class="cov8" title="1">{ + return errors.New("min minutes must be positive") + }</span> + <span class="cov8" title="1">dst.minEnabled = true + dst.minMinutes = minutes + return nil</span> +} + +func populateMaxFilter(dst *filterState, value string) error <span class="cov8" title="1">{ + if value == "" </span><span class="cov8" title="1">{ + return nil + }</span> + <span class="cov8" title="1">minutes, err := strconv.Atoi(value) + if err != nil </span><span class="cov8" title="1">{ + return fmt.Errorf("invalid max minutes: %q", value) + }</span> + <span class="cov8" title="1">if minutes < 0 </span><span class="cov8" title="1">{ + return errors.New("max minutes must be positive") + }</span> + <span class="cov8" title="1">dst.maxEnabled = true + dst.maxMinutes = minutes + return nil</span> +} + +func (m *model) resetFilters() <span class="cov8" title="1">{ + m.filters = filterState{} + for i := range m.inputs.fields </span><span class="cov8" title="1">{ + m.inputs.fields[i].SetValue("") + }</span> +} + +func (m *model) updateFilterInputs(msg tea.Msg) (filterInputs, tea.Cmd) <span class="cov8" title="1">{ + inputs := m.inputs + var cmds []tea.Cmd + for i := range inputs.fields </span><span class="cov8" title="1">{ + var cmd tea.Cmd + inputs.fields[i], cmd = inputs.fields[i].Update(msg) + cmds = append(cmds, cmd) + }</span> + <span class="cov8" title="1">return inputs, tea.Batch(cmds...)</span> +} + +func (m model) describeFilters() string <span class="cov8" title="1">{ + parts := []string{} + if m.filters.name != "" </span><span class="cov8" title="1">{ + parts = append(parts, fmt.Sprintf("name contains %q", m.filters.name)) + }</span> + <span class="cov8" title="1">if m.filters.tags != "" </span><span class="cov8" title="1">{ + parts = append(parts, fmt.Sprintf("tags contain %q", m.filters.tags)) + }</span> + <span class="cov8" title="1">if m.filters.minEnabled </span><span class="cov8" title="1">{ + parts = append(parts, fmt.Sprintf(">=%d min", m.filters.minMinutes)) + }</span> + <span class="cov8" title="1">if m.filters.maxEnabled </span><span class="cov8" title="1">{ + parts = append(parts, fmt.Sprintf("<=%d min", m.filters.maxMinutes)) + }</span> + <span class="cov8" title="1">if len(parts) == 0 </span><span class="cov0" title="0">{ + return "(none)" + }</span> + <span class="cov8" title="1">return strings.Join(parts, ", ")</span> +} + +func (m *model) passesFilters(v video) bool <span class="cov8" title="1">{ + if m.filters.name != "" && !strings.Contains(strings.ToLower(v.Name), strings.ToLower(m.filters.name)) </span><span class="cov8" title="1">{ + return false + }</span> + <span class="cov8" title="1">durMinutes := int(v.Duration.Round(time.Minute) / time.Minute) + if m.filters.minEnabled && (v.Duration == 0 || durMinutes < m.filters.minMinutes) </span><span class="cov8" title="1">{ + return false + }</span> + <span class="cov8" title="1">if m.filters.maxEnabled && (v.Duration == 0 || durMinutes > m.filters.maxMinutes) </span><span class="cov8" title="1">{ + return false + }</span> + <span class="cov8" title="1">if m.filters.tags != "" </span><span class="cov8" title="1">{ + query := strings.ToLower(m.filters.tags) + matched := false + for _, tag := range v.Tags </span><span class="cov8" title="1">{ + if strings.Contains(strings.ToLower(tag), query) </span><span class="cov8" title="1">{ + matched = true + break</span> + } + } + <span class="cov8" title="1">if !matched </span><span class="cov8" title="1">{ + return false + }</span> + } + <span class="cov8" title="1">return true</span> +} + +func (m *model) renderFilterModal() string <span class="cov8" title="1">{ + var b strings.Builder + b.WriteString("Filter videos\n") + b.WriteString("(Enter to apply, Esc to cancel)\n\n") + labels := []string{"Name contains:", "Min length (minutes):", "Max length (minutes):", "Tags contain:"} + for i, field := range m.inputs.fields </span><span class="cov8" title="1">{ + line := fmt.Sprintf("%s %s", labels[i], field.View()) + if i == m.inputs.focus </span><span class="cov8" title="1">{ + line = highlightStyle.Render(line) + }</span> + <span class="cov8" title="1">b.WriteString(line) + b.WriteString("\n")</span> + } + <span class="cov8" title="1">if m.filters.minEnabled || m.filters.maxEnabled || m.filters.name != "" </span><span class="cov0" title="0">{ + b.WriteString("\nCurrent filter: ") + b.WriteString(m.describeFilters()) + b.WriteString("\n") + }</span> + <span class="cov8" title="1">return filterStyle.Render(b.String())</span> +} +</pre> + + <pre class="file" id="file4" style="display: none">package app + +import "sync" + +type loadProgress struct { + mu sync.Mutex + total int + processed int + done bool +} + +func (p *loadProgress) Reset() <span class="cov8" title="1">{ + if p == nil </span><span class="cov0" title="0">{ + return + }</span> + <span class="cov8" title="1">p.mu.Lock() + p.total = 0 + p.processed = 0 + p.done = false + p.mu.Unlock()</span> +} + +func (p *loadProgress) SetTotal(total int) <span class="cov8" title="1">{ + if p == nil </span><span class="cov0" title="0">{ + return + }</span> + <span class="cov8" title="1">p.mu.Lock() + p.total = total + p.mu.Unlock()</span> +} + +func (p *loadProgress) Increment() <span class="cov8" title="1">{ + if p == nil </span><span class="cov0" title="0">{ + return + }</span> + <span class="cov8" title="1">p.mu.Lock() + p.processed++ + p.mu.Unlock()</span> +} + +func (p *loadProgress) MarkDone() <span class="cov8" title="1">{ + if p == nil </span><span class="cov0" title="0">{ + return + }</span> + <span class="cov8" title="1">p.mu.Lock() + p.done = true + p.mu.Unlock()</span> +} + +func (p *loadProgress) Snapshot() (processed, total int, done bool) <span class="cov8" title="1">{ + if p == nil </span><span class="cov0" title="0">{ + return 0, 0, true + }</span> + <span class="cov8" title="1">p.mu.Lock() + defer p.mu.Unlock() + return p.processed, p.total, p.done</span> +} +</pre> + + <pre class="file" id="file5" style="display: none">package app + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + + "codeberg.org/snonux/yoga/internal/tags" +) + +func loadVideosCmd(root, cachePath string, progress *loadProgress) tea.Cmd <span class="cov8" title="1">{ + return func() tea.Msg </span><span class="cov8" title="1">{ + cache, cacheErr := loadDurationCache(cachePath) + videos, pending, tagErr, err := loadVideos(root, cache, progress) + if progress != nil </span><span class="cov8" title="1">{ + progress.MarkDone() + }</span> + <span class="cov8" title="1">return videosLoadedMsg{videos: videos, err: err, cacheErr: cacheErr, pending: pending, cache: cache, tagErr: tagErr}</span> + } +} + +func progressTickerCmd(progress *loadProgress) tea.Cmd <span class="cov8" title="1">{ + if progress == nil </span><span class="cov8" title="1">{ + return nil + }</span> + <span class="cov8" title="1">return tea.Tick(200*time.Millisecond, func(time.Time) tea.Msg </span><span class="cov8" title="1">{ + processed, total, done := progress.Snapshot() + return progressUpdateMsg{processed: processed, total: total, done: done} + }</span>) +} + +func loadVideos(root string, cache *durationCache, progress *loadProgress) ([]video, []string, error, error) <span class="cov8" title="1">{ + paths, err := collectVideoPaths(root) + if err != nil </span><span class="cov0" title="0">{ + return nil, nil, nil, err + }</span> + <span class="cov8" title="1">if progress != nil </span><span class="cov8" title="1">{ + progress.SetTotal(len(paths)) + }</span> + <span class="cov8" title="1">videos := make([]video, 0, len(paths)) + pending := make([]string, 0) + var tagErrors []string + for _, path := range paths </span><span class="cov8" title="1">{ + info, statErr := os.Stat(path) + if statErr != nil </span><span class="cov8" title="1">{ + videos = append(videos, video{Name: filepath.Base(path), Path: path, Err: statErr}) + increment(progress) + continue</span> + } + <span class="cov8" title="1">dur := cachedDuration(cache, path, info) + if dur == 0 </span><span class="cov8" title="1">{ + pending = append(pending, path) + }</span> + <span class="cov8" title="1">tagList, tagErr := tags.Load(path) + if tagErr != nil </span><span class="cov0" title="0">{ + tagErrors = append(tagErrors, fmt.Sprintf("%s: %v", filepath.Base(path), tagErr)) + }</span> + <span class="cov8" title="1">videos = append(videos, video{ + Name: filepath.Base(path), + Path: path, + Duration: dur, + ModTime: info.ModTime(), + Size: info.Size(), + Tags: tagList, + }) + increment(progress)</span> + } + <span class="cov8" title="1">return videos, pending, joinErrors(tagErrors), nil</span> +} + +func joinErrors(messages []string) error <span class="cov8" title="1">{ + if len(messages) == 0 </span><span class="cov8" title="1">{ + return nil + }</span> + <span class="cov0" title="0">return errors.New(strings.Join(messages, "; "))</span> +} + +func increment(progress *loadProgress) <span class="cov8" title="1">{ + if progress != nil </span><span class="cov8" title="1">{ + progress.Increment() + }</span> +} + +func cachedDuration(cache *durationCache, path string, info os.FileInfo) time.Duration <span class="cov8" title="1">{ + if cache == nil </span><span class="cov8" title="1">{ + return 0 + }</span> + <span class="cov8" title="1">dur, ok := cache.Lookup(path, info) + if !ok </span><span class="cov8" title="1">{ + return 0 + }</span> + <span class="cov8" title="1">return dur</span> +} + +func collectVideoPaths(root string) ([]string, error) <span class="cov8" title="1">{ + info, err := os.Stat(root) + if err != nil </span><span class="cov0" title="0">{ + return nil, err + }</span> + <span class="cov8" title="1">if !info.IsDir() </span><span class="cov0" title="0">{ + if isVideo(root) </span><span class="cov0" title="0">{ + return []string{root}, nil + }</span> + <span class="cov0" title="0">return nil, nil</span> + } + <span class="cov8" title="1">visited := make(map[string]struct{}) + var paths []string + if err := traverseVideoPaths(root, root, visited, &paths); err != nil </span><span class="cov0" title="0">{ + return nil, err + }</span> + <span class="cov8" title="1">sort.Strings(paths) + return paths, nil</span> +} + +func traverseVideoPaths(displayPath, realPath string, visited map[string]struct{}, acc *[]string) error <span class="cov8" title="1">{ + resolved, err := filepath.EvalSymlinks(realPath) + if err != nil </span><span class="cov0" title="0">{ + resolved = realPath + }</span> + <span class="cov8" title="1">resolved = filepath.Clean(resolved) + if _, seen := visited[resolved]; seen </span><span class="cov0" title="0">{ + return nil + }</span> + <span class="cov8" title="1">visited[resolved] = struct{}{} + + entries, err := os.ReadDir(resolved) + if err != nil </span><span class="cov0" title="0">{ + return err + }</span> + <span class="cov8" title="1">for _, entry := range entries </span><span class="cov8" title="1">{ + displayChild := filepath.Join(displayPath, entry.Name()) + realChild := filepath.Join(resolved, entry.Name()) + mode := entry.Type() + var info os.FileInfo + if mode == fs.FileMode(0) </span><span class="cov8" title="1">{ + info, err = entry.Info() + if err != nil </span><span class="cov0" title="0">{ + return err + }</span> + <span class="cov8" title="1">mode = info.Mode()</span> + } + <span class="cov8" title="1">if mode&os.ModeSymlink != 0 </span><span class="cov8" title="1">{ + if err := handleSymlink(displayChild, realChild, visited, acc); err != nil </span><span class="cov0" title="0">{ + return err + }</span> + <span class="cov8" title="1">continue</span> + } + <span class="cov8" title="1">if mode.IsDir() </span><span class="cov0" title="0">{ + if err := traverseVideoPaths(displayChild, realChild, visited, acc); err != nil </span><span class="cov0" title="0">{ + return err + }</span> + <span class="cov0" title="0">continue</span> + } + <span class="cov8" title="1">if isVideo(displayChild) </span><span class="cov8" title="1">{ + *acc = append(*acc, displayChild) + }</span> + } + <span class="cov8" title="1">return nil</span> +} + +func handleSymlink(displayChild, realChild string, visited map[string]struct{}, acc *[]string) error <span class="cov8" title="1">{ + targetPath, err := filepath.EvalSymlinks(realChild) + if err != nil </span><span class="cov8" title="1">{ + return recordIfVideo(displayChild, acc) + }</span> + <span class="cov8" title="1">targetInfo, err := os.Stat(targetPath) + if err != nil </span><span class="cov0" title="0">{ + return recordIfVideo(displayChild, acc) + }</span> + <span class="cov8" title="1">if targetInfo.IsDir() </span><span class="cov8" title="1">{ + return traverseVideoPaths(displayChild, targetPath, visited, acc) + }</span> + <span class="cov0" title="0">if isVideo(displayChild) || isVideo(targetPath) </span><span class="cov0" title="0">{ + *acc = append(*acc, displayChild) + }</span> + <span class="cov0" title="0">return nil</span> +} + +func recordIfVideo(path string, acc *[]string) error <span class="cov8" title="1">{ + if isVideo(path) </span><span class="cov8" title="1">{ + *acc = append(*acc, path) + }</span> + <span class="cov8" title="1">return nil</span> +} + +func probeDurationsCmd(path string, cache *durationCache) tea.Cmd <span class="cov8" title="1">{ + return func() tea.Msg </span><span class="cov8" title="1">{ + dur, err := probeDuration(path) + if err == nil && cache != nil </span><span class="cov0" title="0">{ + if info, statErr := os.Stat(path); statErr == nil </span><span class="cov0" title="0">{ + _ = cache.Record(path, info, dur) + }</span> + } + <span class="cov8" title="1">return durationUpdateMsg{path: path, duration: dur, err: err}</span> + } +} + +func probeDuration(path string) (time.Duration, error) <span class="cov8" title="1">{ + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", path) + out, err := cmd.Output() + if err != nil </span><span class="cov8" title="1">{ + return 0, err + }</span> + <span class="cov8" title="1">raw := strings.TrimSpace(string(out)) + if raw == "" </span><span class="cov0" title="0">{ + return 0, errors.New("empty duration") + }</span> + <span class="cov8" title="1">seconds, err := strconv.ParseFloat(raw, 64) + if err != nil </span><span class="cov0" title="0">{ + return 0, err + }</span> + <span class="cov8" title="1">return time.Duration(seconds * float64(time.Second)), nil</span> +} + +func playVideoCmd(path, crop string) tea.Cmd <span class="cov8" title="1">{ + return func() tea.Msg </span><span class="cov8" title="1">{ + args := buildVLCArgs(path, crop) + cmd := exec.Command("vlc", args...) + if err := cmd.Start(); err != nil </span><span class="cov0" title="0">{ + return playVideoMsg{path: path, err: err} + }</span> + <span class="cov8" title="1">go func() </span><span class="cov8" title="1">{ _ = cmd.Wait() }</span>() + <span class="cov8" title="1">return playVideoMsg{path: path}</span> + } +} + +func buildVLCArgs(path, crop string) []string <span class="cov8" title="1">{ + args := []string{} + if crop != "" </span><span class="cov0" title="0">{ + args = append(args, "--crop", crop) + }</span> + <span class="cov8" title="1">return append(args, path)</span> +} + +func isVideo(path string) bool <span class="cov8" title="1">{ + ext := strings.ToLower(filepath.Ext(path)) + _, ok := videoExtensions[ext] + return ok +}</span> + +// CollectVideoPathsForTest exposes collectVideoPaths for unit testing. +func CollectVideoPathsForTest(root string) ([]string, error) <span class="cov8" title="1">{ + return collectVideoPaths(root) +}</span> +</pre> + + <pre class="file" id="file6" style="display: none">package app + +import ( + "fmt" + "path/filepath" + "runtime" + "strings" + + "github.com/charmbracelet/bubbles/table" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" +) + +type sortField int + +const ( + sortByName sortField = iota + sortByDuration + sortByAge +) + +const ( + preferredNameColumnWidth = 40 + preferredDurationColumnWidth = 12 + preferredAgeColumnWidth = 14 + preferredTagsColumnWidth = 28 + nameColumnFloorWidth = 16 + durationColumnFloorWidth = 8 + ageColumnFloorWidth = 10 + tagsColumnFloorWidth = 12 +) + +type model struct { + table table.Model + videos []video + filtered []video + filters filterState + inputs filterInputs + showFilters bool + editingTags bool + sortField sortField + sortAscending bool + statusMessage string + loading bool + err error + root string + progress *loadProgress + cachePath string + cache *durationCache + pendingDurations []string + durationTotal int + durationDone int + durationInFlight int + cropValue string + cropEnabled bool + tagInput textinput.Model + tagEditPath string + baseStatus string + showHelp bool + viewportWidth int +} + +func newModel(opts Options) (model, error) <span class="cov8" title="1">{ + tbl := buildTable() + inputs := buildFilterInputs() + inputs.fields[0].Focus() + tagInput := buildTagInput() + + progress := &loadProgress{} + cachePath := filepath.Join(opts.Root, ".video_duration_cache.json") + + return model{ + table: tbl, + inputs: inputs, + tagInput: tagInput, + sortField: sortByName, + sortAscending: true, + statusMessage: "Scanning for videos...", + loading: true, + root: opts.Root, + progress: progress, + cachePath: cachePath, + cropValue: opts.Crop, + cropEnabled: opts.Crop != "", + showHelp: true, + }, nil +}</span> + +func buildTable() table.Model <span class="cov8" title="1">{ + columns := makeColumns( + preferredNameColumnWidth, + preferredDurationColumnWidth, + preferredAgeColumnWidth, + preferredTagsColumnWidth, + ) + tbl := table.New( + table.WithColumns(columns), + table.WithFocused(true), + table.WithHeight(15), + ) + tbl.SetStyles(table.DefaultStyles()) + return tbl +}</span> + +func buildFilterInputs() filterInputs <span class="cov8" title="1">{ + nameInput := textinput.New() + nameInput.Placeholder = "substring" + nameInput.Prompt = "Name: " + nameInput.CharLimit = 256 + + minInput := textinput.New() + minInput.Placeholder = "min minutes" + minInput.Prompt = "Min minutes: " + minInput.CharLimit = 4 + + maxInput := textinput.New() + maxInput.Placeholder = "max minutes" + maxInput.Prompt = "Max minutes: " + maxInput.CharLimit = 4 + + tagInput := textinput.New() + tagInput.Placeholder = "tag substring" + tagInput.Prompt = "Tags: " + tagInput.CharLimit = 256 + + return filterInputs{ + fields: []textinput.Model{nameInput, minInput, maxInput, tagInput}, + focus: 0, + } +}</span> + +func buildTagInput() textinput.Model <span class="cov8" title="1">{ + input := textinput.New() + input.Placeholder = "comma-separated tags" + input.Prompt = "Tags: " + input.CharLimit = 512 + return input +}</span> + +func makeColumns(nameWidth, durationWidth, ageWidth, tagsWidth int) []table.Column <span class="cov8" title="1">{ + return []table.Column{ + {Title: headerStyle.Render("Name"), Width: nameWidth}, + {Title: headerStyle.Render("Duration"), Width: durationWidth}, + {Title: headerStyle.Render("Age"), Width: ageWidth}, + {Title: headerStyle.Render("Tags"), Width: tagsWidth}, + } +}</span> + +func (m model) Init() tea.Cmd <span class="cov8" title="1">{ + if m.progress != nil </span><span class="cov8" title="1">{ + m.progress.Reset() + }</span> + <span class="cov8" title="1">loadCmd := loadVideosCmd(m.root, m.cachePath, m.progress) + if m.progress != nil </span><span class="cov8" title="1">{ + return tea.Batch(loadCmd, progressTickerCmd(m.progress)) + }</span> + <span class="cov0" title="0">return loadCmd</span> +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) <span class="cov8" title="1">{ + switch typed := msg.(type) </span>{ + case tea.KeyMsg:<span class="cov8" title="1"> + return m.handleKeyMsg(typed)</span> + case progressUpdateMsg:<span class="cov8" title="1"> + return m.handleProgressUpdate(typed)</span> + case durationUpdateMsg:<span class="cov8" title="1"> + return m.handleDurationUpdate(typed)</span> + case videosLoadedMsg:<span class="cov8" title="1"> + return m.handleVideosLoaded(typed)</span> + case playVideoMsg:<span class="cov8" title="1"> + return m.handlePlayVideo(typed), nil</span> + case reindexVideosMsg:<span class="cov0" title="0"> + return m.handleReindexVideos(typed)</span> + case tagsSavedMsg:<span class="cov0" title="0"> + return m.handleTagsSaved(typed)</span> + case tea.WindowSizeMsg:<span class="cov8" title="1"> + return m.handleWindowSize(typed)</span> + default:<span class="cov0" title="0"> + return m.updateTable(msg)</span> + } +} + +func (m model) View() string <span class="cov8" title="1">{ + if m.loading </span><span class="cov0" title="0">{ + return statusStyle.Render("Loading videos, please wait...") + }</span> + <span class="cov8" title="1">body := m.renderBody() + if m.editingTags </span><span class="cov0" title="0">{ + return body + "\n\n" + m.renderTagModal() + }</span> + <span class="cov8" title="1">if m.showFilters </span><span class="cov0" title="0">{ + return body + "\n\n" + m.renderFilterModal() + }</span> + <span class="cov8" title="1">return body</span> +} + +func (m model) renderBody() string <span class="cov8" title="1">{ + helpLines := []string{ + "↑/↓ navigate • enter play • s sort • / filter • c crop • t edit tags • i re-index • q quit", + } + info := statusStyle.Render(m.statusText()) + progressLine := m.renderProgressLine() + content := tableStyle.Render(m.table.View()) + parts := []string{content} + if progressLine != "" </span><span class="cov0" title="0">{ + parts = append(parts, progressLine) + }</span> + <span class="cov8" title="1">parts = append(parts, info) + if m.showHelp </span><span class="cov8" title="1">{ + help := strings.Join(helpLines, "\n") + parts = append(parts, help) + }</span> + <span class="cov8" title="1">return strings.Join(parts, "\n")</span> +} + +func (m model) statusText() string <span class="cov8" title="1">{ + status := strings.TrimSpace(m.statusMessage) + base := strings.TrimSpace(m.baseStatus) + if base == "" </span><span class="cov8" title="1">{ + return status + }</span> + <span class="cov8" title="1">if status == "" || status == base </span><span class="cov8" title="1">{ + return base + }</span> + <span class="cov8" title="1">return fmt.Sprintf("%s • %s", base, status)</span> +} + +func (m model) showHelpBar() (tea.Model, tea.Cmd) <span class="cov8" title="1">{ + if m.showHelp </span><span class="cov0" title="0">{ + return m, nil + }</span> + <span class="cov8" title="1">m.showHelp = true + if strings.Contains(m.statusMessage, "Help hidden") </span><span class="cov8" title="1">{ + m.statusMessage = "" + }</span> + <span class="cov8" title="1">return m, nil</span> +} + +func (m model) hideHelpBar() (tea.Model, tea.Cmd) <span class="cov8" title="1">{ + if !m.showHelp </span><span class="cov0" title="0">{ + return m, nil + }</span> + <span class="cov8" title="1">m.showHelp = false + m.statusMessage = "Help hidden (press h to show)" + return m, nil</span> +} + +func (m model) handleWindowSize(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) <span class="cov8" title="1">{ + m.viewportWidth = msg.Width + m.resizeColumns(msg.Width) + tbl, cmd := m.table.Update(msg) + m.table = tbl + if cmd == nil </span><span class="cov8" title="1">{ + return m, nil + }</span> + <span class="cov0" title="0">return m, cmd</span> +} + +func (m *model) resizeColumns(totalWidth int) <span class="cov8" title="1">{ + if totalWidth <= 0 </span><span class="cov0" title="0">{ + return + }</span> + <span class="cov8" title="1">frame := tableStyle.GetHorizontalFrameSize() + contentWidth := totalWidth - frame + minWidth := nameColumnFloorWidth + durationColumnFloorWidth + ageColumnFloorWidth + tagsColumnFloorWidth + if contentWidth < minWidth </span><span class="cov0" title="0">{ + contentWidth = minWidth + }</span> + <span class="cov8" title="1">preferred := preferredNameColumnWidth + preferredDurationColumnWidth + preferredAgeColumnWidth + preferredTagsColumnWidth + nameWidth := preferredNameColumnWidth + durationWidth := preferredDurationColumnWidth + ageWidth := preferredAgeColumnWidth + tagsWidth := preferredTagsColumnWidth + if contentWidth >= preferred </span><span class="cov8" title="1">{ + extra := contentWidth - preferred + nameWidth += extra + }</span> else<span class="cov8" title="1"> { + deficit := preferred - contentWidth + if deficit > 0 </span><span class="cov8" title="1">{ + reduce := min(deficit, nameWidth-nameColumnFloorWidth) + nameWidth -= reduce + deficit -= reduce + }</span> + <span class="cov8" title="1">if deficit > 0 </span><span class="cov8" title="1">{ + reduce := min(deficit, tagsWidth-tagsColumnFloorWidth) + tagsWidth -= reduce + deficit -= reduce + }</span> + <span class="cov8" title="1">if deficit > 0 </span><span class="cov0" title="0">{ + reduce := min(deficit, ageWidth-ageColumnFloorWidth) + ageWidth -= reduce + deficit -= reduce + }</span> + <span class="cov8" title="1">if deficit > 0 </span><span class="cov0" title="0">{ + reduce := min(deficit, durationWidth-durationColumnFloorWidth) + durationWidth -= reduce + }</span> + } + <span class="cov8" title="1">m.table.SetColumns(makeColumns(nameWidth, durationWidth, ageWidth, tagsWidth)) + m.table.SetWidth(contentWidth)</span> +} + +func min(a, b int) int <span class="cov8" title="1">{ + if a < b </span><span class="cov8" title="1">{ + return a + }</span> + <span class="cov8" title="1">return b</span> +} + +func (m model) renderProgressLine() string <span class="cov8" title="1">{ + if m.durationTotal == 0 </span><span class="cov8" title="1">{ + return "" + }</span> + <span class="cov8" title="1">bar := renderProgressBar(m.durationDone, m.durationTotal, 24) + return statusStyle.Render(fmt.Sprintf("Duration scan %s %d/%d", bar, m.durationDone, m.durationTotal))</span> +} + +func (m model) updateTable(msg tea.Msg) (tea.Model, tea.Cmd) <span class="cov8" title="1">{ + tbl, cmd := m.table.Update(msg) + m.table = tbl + return m, cmd +}</span> + +func (m model) handlePlayVideo(msg playVideoMsg) model <span class="cov8" title="1">{ + if msg.err != nil </span><span class="cov8" title="1">{ + m.statusMessage = fmt.Sprintf("Failed to launch VLC: %v", msg.err) + return m + }</span> + <span class="cov8" title="1">m.statusMessage = fmt.Sprintf("Playing via VLC: %s", trimPath(msg.path)) + return m</span> +} + +func (m model) handleReindexVideos(msg reindexVideosMsg) (tea.Model, tea.Cmd) <span class="cov0" title="0">{ + m.statusMessage = "Re-indexing videos..." + return m, loadVideosCmd(m.root, m.cachePath, m.progress) +}</span> + +func (m model) handleVideosLoaded(msg videosLoadedMsg) (tea.Model, tea.Cmd) <span class="cov8" title="1">{ + m.loading = false + if msg.err != nil </span><span class="cov0" title="0">{ + m.err = msg.err + m.statusMessage = fmt.Sprintf("error: %v", msg.err) + }</span> + + <span class="cov8" title="1">if len(m.videos) == 0 </span><span class="cov8" title="1">{ + m.videos = msg.videos + }</span> else<span class="cov0" title="0"> { + existingVideos := make(map[string]int) + for i, v := range m.videos </span><span class="cov0" title="0">{ + existingVideos[v.Path] = i + }</span> + + <span class="cov0" title="0">for _, newVideo := range msg.videos </span><span class="cov0" title="0">{ + if i, ok := existingVideos[newVideo.Path]; ok </span><span class="cov0" title="0">{ + m.videos[i] = newVideo + }</span> else<span class="cov0" title="0"> { + m.videos = append(m.videos, newVideo) + }</span> + } + } + + <span class="cov8" title="1">m.cache = msg.cache + m.pendingDurations = msg.pending + m.durationTotal = len(msg.pending) + m.durationDone = 0 + m.applyFiltersAndSort() + m.updateStatusAfterLoad(msg) + m.durationInFlight = 0 + if len(msg.pending) == 0 </span><span class="cov8" title="1">{ + return m, nil + }</span> + <span class="cov8" title="1">cmd := m.startDurationWorkers() + return m, cmd</span> +} + +func (m *model) updateStatusAfterLoad(msg videosLoadedMsg) <span class="cov8" title="1">{ + if len(m.filtered) == 0 </span><span class="cov0" title="0">{ + m.baseStatus = "No videos found" + m.statusMessage = m.baseStatus + return + }</span> + <span class="cov8" title="1">status := "" + if len(msg.pending) > 0 </span><span class="cov8" title="1">{ + status = fmt.Sprintf("Loaded %d videos, probing durations...", len(m.filtered)) + if msg.cacheErr != nil </span><span class="cov8" title="1">{ + status = fmt.Sprintf("Loaded %d videos (cache warning: %v), probing durations...", len(m.filtered), msg.cacheErr) + }</span> + } else<span class="cov8" title="1"> { + status = fmt.Sprintf("Loaded %d videos", len(m.filtered)) + if msg.cacheErr != nil </span><span class="cov8" title="1">{ + status = fmt.Sprintf("Loaded %d videos (cache warning: %v)", len(m.filtered), msg.cacheErr) + }</span> + } + <span class="cov8" title="1">if msg.tagErr != nil </span><span class="cov8" title="1">{ + status = fmt.Sprintf("%s (tag warning: %v)", status, msg.tagErr) + }</span> + <span class="cov8" title="1">m.baseStatus = status + m.statusMessage = status</span> +} + +func (m *model) startDurationWorkers() tea.Cmd <span class="cov8" title="1">{ + if len(m.pendingDurations) == 0 </span><span class="cov0" title="0">{ + return nil + }</span> + <span class="cov8" title="1">workers := runtime.NumCPU() + if workers < 1 </span><span class="cov0" title="0">{ + workers = 1 + }</span> + <span class="cov8" title="1">if workers > 6 </span><span class="cov8" title="1">{ + workers = 6 + }</span> + <span class="cov8" title="1">if workers > len(m.pendingDurations) </span><span class="cov8" title="1">{ + workers = len(m.pendingDurations) + }</span> + <span class="cov8" title="1">cmds := make([]tea.Cmd, 0, workers) + for i := 0; i < workers; i++ </span><span class="cov8" title="1">{ + cmd := m.dequeueDurationCmd() + if cmd != nil </span><span class="cov8" title="1">{ + cmds = append(cmds, cmd) + }</span> + } + <span class="cov8" title="1">if len(cmds) == 0 </span><span class="cov0" title="0">{ + return nil + }</span> + <span class="cov8" title="1">return tea.Batch(cmds...)</span> +} + +func (m *model) dequeueDurationCmd() tea.Cmd <span class="cov8" title="1">{ + if len(m.pendingDurations) == 0 </span><span class="cov0" title="0">{ + return nil + }</span> + <span class="cov8" title="1">path := m.pendingDurations[0] + m.pendingDurations = m.pendingDurations[1:] + m.durationInFlight++ + return probeDurationsCmd(path, m.cache)</span> +} + +func (m model) activeCrop() string <span class="cov8" title="1">{ + if m.cropEnabled && m.cropValue != "" </span><span class="cov8" title="1">{ + return m.cropValue + }</span> + <span class="cov8" title="1">return ""</span> +} + +func (m model) handleProgressUpdate(msg progressUpdateMsg) (tea.Model, tea.Cmd) <span class="cov8" title="1">{ + if !m.loading </span><span class="cov0" title="0">{ + return m, nil + }</span> + <span class="cov8" title="1">if msg.total == 0 && msg.done </span><span class="cov8" title="1">{ + m.statusMessage = "No videos found" + return m, nil + }</span> + <span class="cov8" title="1">if msg.done </span><span class="cov8" title="1">{ + m.statusMessage = fmt.Sprintf("Loaded %d videos", msg.total) + return m, nil + }</span> + <span class="cov8" title="1">m.statusMessage = fmt.Sprintf("Loading videos %d/%d...", msg.processed, msg.total) + return m, progressTickerCmd(m.progress)</span> +} +</pre> + + <pre class="file" id="file7" style="display: none">package app + +import ( + "fmt" + "path/filepath" + "time" + + tea "github.com/charmbracelet/bubbletea" +) + +func (m model) handleDurationUpdate(msg durationUpdateMsg) (tea.Model, tea.Cmd) <span class="cov8" title="1">{ + if msg.path != "" </span><span class="cov8" title="1">{ + m.updateVideoDuration(msg.path, msg.duration, msg.err) + m.durationDone++ + m.updateStatusForDuration(msg) + }</span> + <span class="cov8" title="1">if m.durationInFlight > 0 </span><span class="cov8" title="1">{ + m.durationInFlight-- + }</span> + <span class="cov8" title="1">selectedPath := m.currentSelectionPath() + m.applyFiltersAndSort() + m.restoreSelection(selectedPath) + if m.allDurationsResolved() </span><span class="cov8" title="1">{ + m.onDurationsComplete() + return m, nil + }</span> + <span class="cov8" title="1">cmd := m.dequeueDurationCmd() + return m, cmd</span> +} + +func (m *model) updateStatusForDuration(msg durationUpdateMsg) <span class="cov8" title="1">{ + if msg.err != nil </span><span class="cov8" title="1">{ + m.statusMessage = fmt.Sprintf("Duration error for %s: %v", filepath.Base(msg.path), msg.err) + return + }</span> + <span class="cov8" title="1">if m.durationTotal > 0 </span><span class="cov8" title="1">{ + m.statusMessage = fmt.Sprintf("Probing durations %d/%d...", m.durationDone, m.durationTotal) + }</span> +} + +func (m model) currentSelectionPath() string <span class="cov8" title="1">{ + idx := m.table.Cursor() + if idx < 0 || idx >= len(m.filtered) </span><span class="cov0" title="0">{ + return "" + }</span> + <span class="cov8" title="1">return m.filtered[idx].Path</span> +} + +func (m *model) restoreSelection(path string) <span class="cov8" title="1">{ + if path == "" </span><span class="cov0" title="0">{ + return + }</span> + <span class="cov8" title="1">for i, video := range m.filtered </span><span class="cov8" title="1">{ + if video.Path == path </span><span class="cov8" title="1">{ + m.table.SetCursor(i) + return + }</span> + } +} + +func (m *model) updateVideoDuration(path string, dur time.Duration, err error) <span class="cov8" title="1">{ + for i := range m.videos </span><span class="cov8" title="1">{ + if m.videos[i].Path != path </span><span class="cov0" title="0">{ + continue</span> + } + <span class="cov8" title="1">m.videos[i].Duration = dur + m.videos[i].Err = err + return</span> + } +} + +func (m model) allDurationsResolved() bool <span class="cov8" title="1">{ + return m.durationDone >= m.durationTotal && m.durationInFlight == 0 +}</span> + +func (m *model) onDurationsComplete() <span class="cov8" title="1">{ + if m.cache != nil </span><span class="cov8" title="1">{ + if err := m.cache.Flush(); err != nil </span><span class="cov0" title="0">{ + m.statusMessage = fmt.Sprintf("Duration cache flush error: %v", err) + }</span> else<span class="cov8" title="1"> { + m.statusMessage = fmt.Sprintf("Durations ready (%d videos)", len(m.filtered)) + }</span> + <span class="cov8" title="1">m.resetDurationState() + return</span> + } + <span class="cov0" title="0">m.statusMessage = fmt.Sprintf("Durations ready (%d videos)", len(m.filtered)) + m.resetDurationState()</span> +} + +func (m *model) resetDurationState() <span class="cov8" title="1">{ + m.pendingDurations = nil + m.durationTotal = 0 + m.durationDone = 0 + m.durationInFlight = 0 +}</span> + + + +</pre> + + <pre class="file" id="file8" style="display: none">package app + +import ( + "fmt" + "math/rand" + + tea "github.com/charmbracelet/bubbletea" +) + +func (m model) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) <span class="cov8" title="1">{ + if cmd, handled := globalKeyHandler(msg); handled </span><span class="cov0" title="0">{ + return m, cmd + }</span> + <span class="cov8" title="1">if m.loading </span><span class="cov0" title="0">{ + return m, nil + }</span> + <span class="cov8" title="1">if m.editingTags </span><span class="cov0" title="0">{ + return m.handleTagKey(msg) + }</span> + <span class="cov8" title="1">if m.showFilters </span><span class="cov8" title="1">{ + return m.handleFilterKey(msg) + }</span> + <span class="cov8" title="1">return m.handleTableKey(msg)</span> +} + +func globalKeyHandler(msg tea.KeyMsg) (tea.Cmd, bool) <span class="cov8" title="1">{ + switch msg.String() </span>{ + case "ctrl+c", "q":<span class="cov0" title="0"> + return tea.Quit, true</span> + default:<span class="cov8" title="1"> + return nil, false</span> + } +} + +func (m model) handleFilterKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) <span class="cov8" title="1">{ + switch msg.String() </span>{ + case "esc":<span class="cov0" title="0"> + m.showFilters = false + m.statusMessage = "Filter closed" + return m, nil</span> + case "enter":<span class="cov8" title="1"> + cmd := m.applyFiltersFromInputs() + return m, cmd</span> + case "tab":<span class="cov8" title="1"> + m.inputs.focus = (m.inputs.focus + 1) % len(m.inputs.fields)</span> + case "shift+tab":<span class="cov8" title="1"> + m.inputs.focus = (m.inputs.focus - 1 + len(m.inputs.fields)) % len(m.inputs.fields)</span> + } + <span class="cov8" title="1">m.syncFilterFocus() + updated, cmd := m.updateFilterInputs(msg) + m.inputs = updated + return m, cmd</span> +} + +func (m *model) applyFiltersFromInputs() tea.Cmd <span class="cov8" title="1">{ + if err := m.applyFilterInputs(); err != nil </span><span class="cov0" title="0">{ + m.statusMessage = err.Error() + return nil + }</span> + <span class="cov8" title="1">m.showFilters = false + m.applyFiltersAndSort() + m.statusMessage = fmt.Sprintf("Filters applied (%d videos)", len(m.filtered)) + return nil</span> +} + +func (m *model) syncFilterFocus() <span class="cov8" title="1">{ + for i := range m.inputs.fields </span><span class="cov8" title="1">{ + if i == m.inputs.focus </span><span class="cov8" title="1">{ + m.inputs.fields[i].Focus() + continue</span> + } + <span class="cov8" title="1">m.inputs.fields[i].Blur()</span> + } +} + +func (m model) handleTableKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) <span class="cov8" title="1">{ + switch msg.String() </span>{ + case "/", "f":<span class="cov8" title="1"> + return m.openFilters()</span> + case "enter":<span class="cov0" title="0"> + return m.playSelection()</span> + case "n":<span class="cov0" title="0"> + return m.sortAndReport(sortByName)</span> + case "l":<span class="cov8" title="1"> + return m.sortAndReport(sortByDuration)</span> + case "a":<span class="cov0" title="0"> + return m.sortAndReport(sortByAge)</span> + case "c":<span class="cov8" title="1"> + return m.toggleCrop()</span> + case "t":<span class="cov0" title="0"> + return m.openTagEditor()</span> + case "H":<span class="cov8" title="1"> + return m.hideHelpBar()</span> + case "h":<span class="cov8" title="1"> + return m.showHelpBar()</span> + case "r":<span class="cov8" title="1"> + return m.resetFilterState()</span> + case "i":<span class="cov0" title="0"> + return m, func() tea.Msg </span><span class="cov0" title="0">{ return reindexVideosMsg{} }</span> + case "x":<span class="cov8" title="1"> + return m.selectRandomVideo()</span> + default:<span class="cov8" title="1"> + return m.updateTable(msg)</span> + } +} + +func (m model) openFilters() (tea.Model, tea.Cmd) <span class="cov8" title="1">{ + m.showFilters = true + m.statusMessage = "Editing filters" + return m, nil +}</span> + +func (m model) playSelection() (tea.Model, tea.Cmd) <span class="cov8" title="1">{ + if len(m.filtered) == 0 </span><span class="cov0" title="0">{ + return m, nil + }</span> + <span class="cov8" title="1">idx := m.table.Cursor() + if idx < 0 || idx >= len(m.filtered) </span><span class="cov0" title="0">{ + return m, nil + }</span> + <span class="cov8" title="1">video := m.filtered[idx] + m.statusMessage = fmt.Sprintf("Launching VLC: %s", video.Name) + return m, playVideoCmd(video.Path, m.activeCrop())</span> +} + +func (m model) sortAndReport(field sortField) (tea.Model, tea.Cmd) <span class="cov8" title="1">{ + m.toggleSort(field) + m.applyFiltersAndSort() + m.statusMessage = fmt.Sprintf("Sorted %d videos", len(m.filtered)) + return m, nil +}</span> + +func (m model) toggleCrop() (tea.Model, tea.Cmd) <span class="cov8" title="1">{ + if m.cropValue == "" </span><span class="cov0" title="0">{ + m.statusMessage = "No crop value set (start with --crop)" + return m, nil + }</span> + <span class="cov8" title="1">m.cropEnabled = !m.cropEnabled + if m.cropEnabled </span><span class="cov8" title="1">{ + m.statusMessage = fmt.Sprintf("Crop enabled (%s)", m.cropValue) + return m, nil + }</span> + <span class="cov8" title="1">m.statusMessage = "Crop disabled" + return m, nil</span> +} + +func (m model) resetFilterState() (tea.Model, tea.Cmd) <span class="cov8" title="1">{ + m.resetFilters() + m.applyFiltersAndSort() + m.statusMessage = fmt.Sprintf("Filters cleared (%d videos)", len(m.filtered)) + return m, nil +}</span> + +func (m model) selectRandomVideo() (tea.Model, tea.Cmd) <span class="cov8" title="1">{ + if len(m.filtered) == 0 </span><span class="cov8" title="1">{ + m.statusMessage = "No videos to select from" + return m, nil + }</span> + <span class="cov8" title="1">idx := rand.Intn(len(m.filtered)) + m.table.SetCursor(idx) + video := m.filtered[idx] + m.statusMessage = fmt.Sprintf("Randomly selected: %s", video.Name) + return m, nil</span> +} +</pre> + + <pre class="file" id="file9" style="display: none">package app + +import ( + "sort" + "strings" + + "github.com/charmbracelet/bubbles/table" +) + +func (m *model) toggleSort(target sortField) <span class="cov8" title="1">{ + if m.sortField == target </span><span class="cov8" title="1">{ + m.sortAscending = !m.sortAscending + return + }</span> + <span class="cov8" title="1">m.sortField = target + m.sortAscending = true</span> +} + +func (m *model) applyFiltersAndSort() <span class="cov8" title="1">{ + filtered := make([]video, 0, len(m.videos)) + for _, v := range m.videos </span><span class="cov8" title="1">{ + if m.passesFilters(v) </span><span class="cov8" title="1">{ + filtered = append(filtered, v) + }</span> + } + <span class="cov8" title="1">sort.Slice(filtered, func(i, j int) bool </span><span class="cov8" title="1">{ + return m.less(filtered[i], filtered[j]) + }</span>) + <span class="cov8" title="1">m.filtered = filtered + m.updateTableRows()</span> +} + +func (m *model) less(a, b video) bool <span class="cov8" title="1">{ + var less bool + switch m.sortField </span>{ + case sortByName:<span class="cov8" title="1"> + less = strings.ToLower(a.Name) < strings.ToLower(b.Name)</span> + case sortByDuration:<span class="cov8" title="1"> + less = a.Duration < b.Duration</span> + case sortByAge:<span class="cov0" title="0"> + less = a.ModTime.Before(b.ModTime)</span> + } + <span class="cov8" title="1">if m.sortAscending </span><span class="cov8" title="1">{ + return less + }</span> + <span class="cov0" title="0">return !less</span> +} + +func (m *model) updateTableRows() <span class="cov8" title="1">{ + rows := make([]table.Row, 0, len(m.filtered)) + for _, v := range m.filtered </span><span class="cov8" title="1">{ + rows = append(rows, videoRow(v)) + }</span> + <span class="cov8" title="1">m.table.SetRows(rows) + if len(rows) > 0 </span><span class="cov8" title="1">{ + m.table.SetCursor(0) + }</span> +} +</pre> + + <pre class="file" id="file10" style="display: none">package app + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" +) + +func (m model) openTagEditor() (tea.Model, tea.Cmd) <span class="cov8" title="1">{ + if len(m.filtered) == 0 </span><span class="cov0" title="0">{ + m.statusMessage = "No videos to edit" + return m, nil + }</span> + <span class="cov8" title="1">cursor := m.table.Cursor() + if cursor < 0 || cursor >= len(m.filtered) </span><span class="cov0" title="0">{ + m.statusMessage = "No selection" + return m, nil + }</span> + <span class="cov8" title="1">video := m.filtered[cursor] + m.editingTags = true + m.tagEditPath = video.Path + m.tagInput = cloneInput(m.tagInput) + m.tagInput.SetValue(strings.Join(video.Tags, ", ")) + m.tagInput.CursorEnd() + m.tagInput.Focus() + m.statusMessage = fmt.Sprintf("Editing tags for %s", video.Name) + return m, nil</span> +} + +func (m model) handleTagKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) <span class="cov8" title="1">{ + switch msg.String() </span>{ + case "esc":<span class="cov0" title="0"> + m.editingTags = false + m.tagEditPath = "" + m.tagInput.Blur() + m.statusMessage = "Tag edit cancelled" + return m, nil</span> + case "enter":<span class="cov8" title="1"> + return m.commitTags()</span> + } + <span class="cov0" title="0">var cmd tea.Cmd + m.tagInput, cmd = m.tagInput.Update(msg) + return m, cmd</span> +} + +func (m model) commitTags() (tea.Model, tea.Cmd) <span class="cov8" title="1">{ + if m.tagEditPath == "" </span><span class="cov0" title="0">{ + m.editingTags = false + m.tagInput.Blur() + m.statusMessage = "No video selected" + return m, nil + }</span> + <span class="cov8" title="1">value := m.tagInput.Value() + tags := parseTagInput(value) + m.editingTags = false + m.tagInput.Blur() + path := m.tagEditPath + m.tagEditPath = "" + name := filepath.Base(path) + m.statusMessage = fmt.Sprintf("Saving tags for %s", name) + return m, saveTagsCmd(path, tags)</span> +} + +func (m model) handleTagsSaved(msg tagsSavedMsg) (tea.Model, tea.Cmd) <span class="cov8" title="1">{ + if msg.err != nil </span><span class="cov0" title="0">{ + m.editingTags = false + m.tagEditPath = "" + m.tagInput.Blur() + m.showHelp = true + m.statusMessage = fmt.Sprintf("Tag save error: %v", msg.err) + return m, nil + }</span> + <span class="cov8" title="1">m.editingTags = false + m.tagEditPath = "" + m.tagInput.Blur() + m.showHelp = true + m.setVideoTags(msg.path, msg.tags) + m.applyFiltersAndSort() + m.restoreSelection(msg.path) + if len(msg.tags) == 0 </span><span class="cov0" title="0">{ + m.statusMessage = "Tags cleared" + return m, nil + }</span> + <span class="cov8" title="1">m.statusMessage = fmt.Sprintf("Tags updated (%d)", len(msg.tags)) + return m, nil</span> +} + +func (m *model) setVideoTags(path string, tags []string) <span class="cov8" title="1">{ + for i := range m.videos </span><span class="cov8" title="1">{ + if m.videos[i].Path == path </span><span class="cov8" title="1">{ + m.videos[i].Tags = append([]string{}, tags...) + return + }</span> + } +} + +func parseTagInput(value string) []string <span class="cov8" title="1">{ + if strings.TrimSpace(value) == "" </span><span class="cov8" title="1">{ + return nil + }</span> + <span class="cov8" title="1">parts := strings.Split(value, ",") + var tags []string + seen := make(map[string]struct{}, len(parts)) + for _, part := range parts </span><span class="cov8" title="1">{ + trimmed := strings.TrimSpace(part) + if trimmed == "" </span><span class="cov8" title="1">{ + continue</span> + } + <span class="cov8" title="1">lower := strings.ToLower(trimmed) + if _, ok := seen[lower]; ok </span><span class="cov8" title="1">{ + continue</span> + } + <span class="cov8" title="1">seen[lower] = struct{}{} + tags = append(tags, trimmed)</span> + } + <span class="cov8" title="1">return tags</span> +} + +func cloneInput(in textinput.Model) textinput.Model <span class="cov8" title="1">{ + copy := in + return copy +}</span> + +func (m model) renderTagModal() string <span class="cov0" title="0">{ + var b strings.Builder + b.WriteString("Edit tags\n") + b.WriteString("(comma separated)\n\n") + b.WriteString(m.tagInput.View()) + b.WriteString("\n\n") + b.WriteString("Enter to save, Esc to cancel") + return filterStyle.Render(b.String()) +}</span> +</pre> + + <pre class="file" id="file11" style="display: none">package app + +import ( + "codeberg.org/snonux/yoga/internal/tags" + tea "github.com/charmbracelet/bubbletea" +) + +func saveTagsCmd(path string, entries []string) tea.Cmd <span class="cov8" title="1">{ + // Copy slice to avoid accidental mutation after scheduling command. + values := append([]string{}, entries...) + return func() tea.Msg </span><span class="cov8" title="1">{ + if err := tags.Save(path, values); err != nil </span><span class="cov0" title="0">{ + return tagsSavedMsg{path: path, err: err} + }</span> + <span class="cov8" title="1">sanitized, err := tags.Load(path) + if err != nil </span><span class="cov0" title="0">{ + return tagsSavedMsg{path: path, err: err} + }</span> + <span class="cov8" title="1">return tagsSavedMsg{path: path, tags: sanitized}</span> + } +} +</pre> + + <pre class="file" id="file12" style="display: none">package app + +import ( + "fmt" + "os" + "strings" + "time" + + "github.com/charmbracelet/bubbles/table" +) + +func videoRow(v video) table.Row <span class="cov8" title="1">{ + duration := "(unknown)" + if v.Duration > 0 </span><span class="cov8" title="1">{ + duration = formatDuration(v.Duration) + }</span> + <span class="cov8" title="1">age := humanizeAge(v.ModTime) + tags := formatTags(v.Tags) + if v.Err != nil </span><span class="cov8" title="1">{ + duration = "!" + v.Err.Error() + }</span> + <span class="cov8" title="1">return table.Row{v.Name, duration, age, tags}</span> +} + +func renderProgressBar(done, total, width int) string <span class="cov8" title="1">{ + if width <= 0 || total <= 0 </span><span class="cov8" title="1">{ + return "" + }</span> + <span class="cov8" title="1">if done < 0 </span><span class="cov8" title="1">{ + done = 0 + }</span> + <span class="cov8" title="1">if done > total </span><span class="cov0" title="0">{ + done = total + }</span> + <span class="cov8" title="1">filled := int(float64(done) / float64(total) * float64(width)) + if filled > width </span><span class="cov0" title="0">{ + filled = width + }</span> + <span class="cov8" title="1">bar := strings.Repeat("#", filled) + strings.Repeat("-", width-filled) + return fmt.Sprintf("[%s]", bar)</span> +} + +func formatDuration(d time.Duration) string <span class="cov8" title="1">{ + if d <= 0 </span><span class="cov0" title="0">{ + return "--" + }</span> + <span class="cov8" title="1">totalSeconds := int(d.Seconds() + 0.5) + hours := totalSeconds / 3600 + minutes := (totalSeconds % 3600) / 60 + seconds := totalSeconds % 60 + if hours > 0 </span><span class="cov0" title="0">{ + return fmt.Sprintf("%d:%02d:%02d", hours, minutes, seconds) + }</span> + <span class="cov8" title="1">return fmt.Sprintf("%02d:%02d", minutes, seconds)</span> +} + +func humanizeAge(t time.Time) string <span class="cov8" title="1">{ + if t.IsZero() </span><span class="cov8" title="1">{ + return "--" + }</span> + <span class="cov8" title="1">dur := time.Since(t) + if dur < time.Minute </span><span class="cov8" title="1">{ + return "just now" + }</span> + <span class="cov8" title="1">if dur < time.Hour </span><span class="cov0" title="0">{ + return fmt.Sprintf("%dm ago", int(dur.Minutes())) + }</span> + <span class="cov8" title="1">if dur < 24*time.Hour </span><span class="cov8" title="1">{ + return fmt.Sprintf("%dh ago", int(dur.Hours())) + }</span> + <span class="cov0" title="0">return t.Format("2006-01-02")</span> +} + +func trimPath(path string) string <span class="cov8" title="1">{ + home, err := os.UserHomeDir() + if err == nil && strings.HasPrefix(path, home) </span><span class="cov0" title="0">{ + return "~" + strings.TrimPrefix(path, home) + }</span> + <span class="cov8" title="1">return path</span> +} + +func formatTags(tags []string) string <span class="cov8" title="1">{ + if len(tags) == 0 </span><span class="cov8" title="1">{ + return "--" + }</span> + <span class="cov8" title="1">return strings.Join(tags, ", ")</span> +} +</pre> + + <pre class="file" id="file13" style="display: none">package fsutil + +import ( + "errors" + "fmt" + "io/fs" + "os" + "os/user" + "path/filepath" + "strings" +) + +// ResolveRootPath expands and validates the supplied root path. When the +// caller did not specify a value, defaultValue is used and created on demand. +func ResolveRootPath(input, defaultValue string) (string, error) <span class="cov8" title="1">{ + value, isDefault := normalizeRootInput(input, defaultValue) + expanded, err := expandPath(value) + if err != nil </span><span class="cov0" title="0">{ + return "", fmt.Errorf("cannot expand root path %q: %w", value, err) + }</span> + <span class="cov8" title="1">abs, err := filepath.Abs(expanded) + if err != nil </span><span class="cov0" title="0">{ + return "", fmt.Errorf("cannot resolve root path %q: %w", expanded, err) + }</span> + <span class="cov8" title="1">info, err := ensureRootExists(abs, isDefault) + if err != nil </span><span class="cov8" title="1">{ + return "", err + }</span> + <span class="cov8" title="1">if !info.IsDir() && !info.Mode().IsRegular() </span><span class="cov0" title="0">{ + return "", fmt.Errorf("root path %q is not a file or directory", abs) + }</span> + <span class="cov8" title="1">return abs, nil</span> +} + +func normalizeRootInput(input, defaultValue string) (value string, isDefault bool) <span class="cov8" title="1">{ + trimmed := strings.TrimSpace(input) + if trimmed == "" </span><span class="cov8" title="1">{ + return defaultValue, true + }</span> + <span class="cov8" title="1">return trimmed, false</span> +} + +func ensureRootExists(path string, allowCreate bool) (fs.FileInfo, error) <span class="cov8" title="1">{ + info, err := os.Stat(path) + if err == nil </span><span class="cov8" title="1">{ + return info, nil + }</span> + <span class="cov8" title="1">if !errors.Is(err, fs.ErrNotExist) </span><span class="cov0" title="0">{ + return nil, fmt.Errorf("cannot access root path %q: %w", path, err) + }</span> + <span class="cov8" title="1">if !allowCreate </span><span class="cov8" title="1">{ + return nil, fmt.Errorf("root path does not exist: %s", path) + }</span> + <span class="cov8" title="1">if mkErr := os.MkdirAll(path, 0o755); mkErr != nil </span><span class="cov0" title="0">{ + return nil, fmt.Errorf("cannot create default directory %q: %w", path, mkErr) + }</span> + <span class="cov8" title="1">info, err = os.Stat(path) + if err != nil </span><span class="cov0" title="0">{ + return nil, fmt.Errorf("cannot stat default directory %q: %w", path, err) + }</span> + <span class="cov8" title="1">return info, nil</span> +} + +func expandPath(p string) (string, error) <span class="cov8" title="1">{ + if p == "" || p[0] != '~' </span><span class="cov8" title="1">{ + return p, nil + }</span> + <span class="cov8" title="1">if len(p) == 1 </span><span class="cov8" title="1">{ + home, err := os.UserHomeDir() + if err != nil </span><span class="cov0" title="0">{ + return "", err + }</span> + <span class="cov8" title="1">return home, nil</span> + } + <span class="cov8" title="1">if p[1] == '/' </span><span class="cov8" title="1">{ + home, err := os.UserHomeDir() + if err != nil </span><span class="cov0" title="0">{ + return "", err + }</span> + <span class="cov8" title="1">return filepath.Join(home, p[2:]), nil</span> + } + <span class="cov8" title="1">username, rest := splitUserPath(p) + usr, err := user.Lookup(username) + if err != nil </span><span class="cov8" title="1">{ + return "", err + }</span> + <span class="cov8" title="1">if rest == "" </span><span class="cov8" title="1">{ + return usr.HomeDir, nil + }</span> + <span class="cov0" title="0">return filepath.Join(usr.HomeDir, rest), nil</span> +} + +func splitUserPath(p string) (string, string) <span class="cov8" title="1">{ + sep := strings.IndexRune(p, '/') + if sep == -1 </span><span class="cov8" title="1">{ + return p[1:], "" + }</span> + <span class="cov8" title="1">return p[1:sep], p[sep:]</span> +} +</pre> + + <pre class="file" id="file14" style="display: none">package tags + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "sort" + "strings" +) + +// PathFor returns the path to the tag metadata file for the given video path. +func PathFor(videoPath string) string <span class="cov8" title="1">{ + ext := filepath.Ext(videoPath) + if strings.EqualFold(ext, ".mp4") </span><span class="cov8" title="1">{ + return strings.TrimSuffix(videoPath, ext) + ".json" + }</span> + <span class="cov0" title="0">return videoPath + ".json"</span> +} + +// Load reads the tags associated with a video. Missing files yield an empty slice. +func Load(videoPath string) ([]string, error) <span class="cov8" title="1">{ + metadataPath := PathFor(videoPath) + data, err := os.ReadFile(metadataPath) + if err != nil </span><span class="cov8" title="1">{ + if errors.Is(err, os.ErrNotExist) </span><span class="cov8" title="1">{ + return nil, nil + }</span> + <span class="cov0" title="0">return nil, err</span> + } + <span class="cov8" title="1">var parsed []string + if err := json.Unmarshal(data, &parsed); err != nil </span><span class="cov0" title="0">{ + return nil, err + }</span> + <span class="cov8" title="1">return sanitize(parsed), nil</span> +} + +// Save persists the tags for a video to its metadata file. +func Save(videoPath string, tagValues []string) error <span class="cov8" title="1">{ + metadataPath := PathFor(videoPath) + cleaned := sanitize(tagValues) + payload, err := json.MarshalIndent(cleaned, "", " ") + if err != nil </span><span class="cov0" title="0">{ + return err + }</span> + <span class="cov8" title="1">return os.WriteFile(metadataPath, payload, 0o644)</span> +} + +func sanitize(raw []string) []string <span class="cov8" title="1">{ + if len(raw) == 0 </span><span class="cov0" title="0">{ + return []string{} + }</span> + <span class="cov8" title="1">seen := make(map[string]struct{}, len(raw)) + var cleaned []string + for _, tag := range raw </span><span class="cov8" title="1">{ + trimmed := strings.TrimSpace(tag) + if trimmed == "" </span><span class="cov0" title="0">{ + continue</span> + } + <span class="cov8" title="1">normalized := trimmed + if _, ok := seen[normalized]; ok </span><span class="cov8" title="1">{ + continue</span> + } + <span class="cov8" title="1">seen[normalized] = struct{}{} + cleaned = append(cleaned, normalized)</span> + } + <span class="cov8" title="1">sort.Strings(cleaned) + return cleaned</span> +} +</pre> + + </div> + </body> + <script> + (function() { + var files = document.getElementById('files'); + var visible; + files.addEventListener('change', onChange, false); + function select(part) { + if (visible) + visible.style.display = 'none'; + visible = document.getElementById(part); + if (!visible) + return; + files.value = part; + visible.style.display = 'block'; + location.hash = part; + } + function onChange() { + select(files.value); + window.scrollTo(0, 0); + } + if (location.hash != "") { + select(location.hash.substr(1)); + } + if (!visible) { + select("file0"); + } + })(); + </script> +</html> diff --git a/coverage.out b/coverage.out new file mode 100644 index 0000000..d7d58c6 --- /dev/null +++ b/coverage.out @@ -0,0 +1,582 @@ +mode: set +codeberg.org/snonux/yoga/cmd/yoga/main.go:22.13,24.2 1 1 +codeberg.org/snonux/yoga/cmd/yoga/main.go:26.55,32.39 6 1 +codeberg.org/snonux/yoga/cmd/yoga/main.go:32.39,34.3 1 0 +codeberg.org/snonux/yoga/cmd/yoga/main.go:35.2,35.18 1 1 +codeberg.org/snonux/yoga/cmd/yoga/main.go:35.18,38.3 2 1 +codeberg.org/snonux/yoga/cmd/yoga/main.go:39.2,40.16 2 1 +codeberg.org/snonux/yoga/cmd/yoga/main.go:40.16,43.3 2 0 +codeberg.org/snonux/yoga/cmd/yoga/main.go:44.2,45.37 2 1 +codeberg.org/snonux/yoga/cmd/yoga/main.go:45.37,48.3 2 1 +codeberg.org/snonux/yoga/cmd/yoga/main.go:49.2,49.10 1 1 +codeberg.org/snonux/yoga/internal/tags/tags.go:13.39,15.36 2 1 +codeberg.org/snonux/yoga/internal/tags/tags.go:15.36,17.3 1 1 +codeberg.org/snonux/yoga/internal/tags/tags.go:18.2,18.28 1 0 +codeberg.org/snonux/yoga/internal/tags/tags.go:22.47,25.16 3 1 +codeberg.org/snonux/yoga/internal/tags/tags.go:25.16,26.37 1 1 +codeberg.org/snonux/yoga/internal/tags/tags.go:26.37,28.4 1 1 +codeberg.org/snonux/yoga/internal/tags/tags.go:29.3,29.18 1 0 +codeberg.org/snonux/yoga/internal/tags/tags.go:31.2,32.54 2 1 +codeberg.org/snonux/yoga/internal/tags/tags.go:32.54,34.3 1 0 +codeberg.org/snonux/yoga/internal/tags/tags.go:35.2,35.30 1 1 +codeberg.org/snonux/yoga/internal/tags/tags.go:39.55,43.16 4 1 +codeberg.org/snonux/yoga/internal/tags/tags.go:43.16,45.3 1 0 +codeberg.org/snonux/yoga/internal/tags/tags.go:46.2,46.51 1 1 +codeberg.org/snonux/yoga/internal/tags/tags.go:49.38,50.19 1 1 +codeberg.org/snonux/yoga/internal/tags/tags.go:50.19,52.3 1 0 +codeberg.org/snonux/yoga/internal/tags/tags.go:53.2,55.26 3 1 +codeberg.org/snonux/yoga/internal/tags/tags.go:55.26,57.20 2 1 +codeberg.org/snonux/yoga/internal/tags/tags.go:57.20,58.12 1 0 +codeberg.org/snonux/yoga/internal/tags/tags.go:60.3,61.36 2 1 +codeberg.org/snonux/yoga/internal/tags/tags.go:61.36,62.12 1 1 +codeberg.org/snonux/yoga/internal/tags/tags.go:64.3,65.40 2 1 +codeberg.org/snonux/yoga/internal/tags/tags.go:67.2,68.16 2 1 +codeberg.org/snonux/yoga/internal/fsutil/path.go:15.66,18.16 3 1 +codeberg.org/snonux/yoga/internal/fsutil/path.go:18.16,20.3 1 0 +codeberg.org/snonux/yoga/internal/fsutil/path.go:21.2,22.16 2 1 +codeberg.org/snonux/yoga/internal/fsutil/path.go:22.16,24.3 1 0 +codeberg.org/snonux/yoga/internal/fsutil/path.go:25.2,26.16 2 1 +codeberg.org/snonux/yoga/internal/fsutil/path.go:26.16,28.3 1 1 +codeberg.org/snonux/yoga/internal/fsutil/path.go:29.2,29.47 1 1 +codeberg.org/snonux/yoga/internal/fsutil/path.go:29.47,31.3 1 0 +codeberg.org/snonux/yoga/internal/fsutil/path.go:32.2,32.17 1 1 +codeberg.org/snonux/yoga/internal/fsutil/path.go:35.84,37.19 2 1 +codeberg.org/snonux/yoga/internal/fsutil/path.go:37.19,39.3 1 1 +codeberg.org/snonux/yoga/internal/fsutil/path.go:40.2,40.23 1 1 +codeberg.org/snonux/yoga/internal/fsutil/path.go:43.75,45.16 2 1 +codeberg.org/snonux/yoga/internal/fsutil/path.go:45.16,47.3 1 1 +codeberg.org/snonux/yoga/internal/fsutil/path.go:48.2,48.37 1 1 +codeberg.org/snonux/yoga/internal/fsutil/path.go:48.37,50.3 1 0 +codeberg.org/snonux/yoga/internal/fsutil/path.go:51.2,51.18 1 1 +codeberg.org/snonux/yoga/internal/fsutil/path.go:51.18,53.3 1 1 +codeberg.org/snonux/yoga/internal/fsutil/path.go:54.2,54.53 1 1 +codeberg.org/snonux/yoga/internal/fsutil/path.go:54.53,56.3 1 0 +codeberg.org/snonux/yoga/internal/fsutil/path.go:57.2,58.16 2 1 +codeberg.org/snonux/yoga/internal/fsutil/path.go:58.16,60.3 1 0 +codeberg.org/snonux/yoga/internal/fsutil/path.go:61.2,61.18 1 1 +codeberg.org/snonux/yoga/internal/fsutil/path.go:64.43,65.28 1 1 +codeberg.org/snonux/yoga/internal/fsutil/path.go:65.28,67.3 1 1 +codeberg.org/snonux/yoga/internal/fsutil/path.go:68.2,68.17 1 1 +codeberg.org/snonux/yoga/internal/fsutil/path.go:68.17,70.17 2 1 +codeberg.org/snonux/yoga/internal/fsutil/path.go:70.17,72.4 1 0 +codeberg.org/snonux/yoga/internal/fsutil/path.go:73.3,73.19 1 1 +codeberg.org/snonux/yoga/internal/fsutil/path.go:75.2,75.17 1 1 +codeberg.org/snonux/yoga/internal/fsutil/path.go:75.17,77.17 2 1 +codeberg.org/snonux/yoga/internal/fsutil/path.go:77.17,79.4 1 0 +codeberg.org/snonux/yoga/internal/fsutil/path.go:80.3,80.41 1 1 +codeberg.org/snonux/yoga/internal/fsutil/path.go:82.2,84.16 3 1 +codeberg.org/snonux/yoga/internal/fsutil/path.go:84.16,86.3 1 1 +codeberg.org/snonux/yoga/internal/fsutil/path.go:87.2,87.16 1 1 +codeberg.org/snonux/yoga/internal/fsutil/path.go:87.16,89.3 1 1 +codeberg.org/snonux/yoga/internal/fsutil/path.go:90.2,90.46 1 0 +codeberg.org/snonux/yoga/internal/fsutil/path.go:93.47,95.15 2 1 +codeberg.org/snonux/yoga/internal/fsutil/path.go:95.15,97.3 1 1 +codeberg.org/snonux/yoga/internal/fsutil/path.go:98.2,98.26 1 1 +codeberg.org/snonux/yoga/internal/app/app.go:13.51,15.2 1 0 +codeberg.org/snonux/yoga/internal/app/app.go:18.30,20.16 2 1 +codeberg.org/snonux/yoga/internal/app/app.go:20.16,22.3 1 0 +codeberg.org/snonux/yoga/internal/app/app.go:23.2,24.41 2 1 +codeberg.org/snonux/yoga/internal/app/app.go:24.41,26.3 1 1 +codeberg.org/snonux/yoga/internal/app/app.go:27.2,27.12 1 1 +codeberg.org/snonux/yoga/internal/app/duration_cache.go:25.51,27.2 1 1 +codeberg.org/snonux/yoga/internal/app/duration_cache.go:29.61,32.16 3 1 +codeberg.org/snonux/yoga/internal/app/duration_cache.go:32.16,33.37 1 1 +codeberg.org/snonux/yoga/internal/app/duration_cache.go:33.37,35.4 1 1 +codeberg.org/snonux/yoga/internal/app/duration_cache.go:36.3,36.20 1 0 +codeberg.org/snonux/yoga/internal/app/duration_cache.go:38.2,38.20 1 1 +codeberg.org/snonux/yoga/internal/app/duration_cache.go:38.20,40.3 1 0 +codeberg.org/snonux/yoga/internal/app/duration_cache.go:41.2,41.61 1 1 +codeberg.org/snonux/yoga/internal/app/duration_cache.go:41.61,44.3 2 1 +codeberg.org/snonux/yoga/internal/app/duration_cache.go:45.2,45.19 1 1 +codeberg.org/snonux/yoga/internal/app/duration_cache.go:48.85,52.9 4 1 +codeberg.org/snonux/yoga/internal/app/duration_cache.go:52.9,54.3 1 1 +codeberg.org/snonux/yoga/internal/app/duration_cache.go:55.2,55.77 1 1 +codeberg.org/snonux/yoga/internal/app/duration_cache.go:55.77,59.3 3 1 +codeberg.org/snonux/yoga/internal/app/duration_cache.go:60.2,60.32 1 1 +codeberg.org/snonux/yoga/internal/app/duration_cache.go:60.32,62.3 1 0 +codeberg.org/snonux/yoga/internal/app/duration_cache.go:63.2,63.74 1 1 +codeberg.org/snonux/yoga/internal/app/duration_cache.go:66.88,67.26 1 1 +codeberg.org/snonux/yoga/internal/app/duration_cache.go:67.26,69.3 1 0 +codeberg.org/snonux/yoga/internal/app/duration_cache.go:70.2,72.22 3 1 +codeberg.org/snonux/yoga/internal/app/duration_cache.go:72.22,74.3 1 0 +codeberg.org/snonux/yoga/internal/app/duration_cache.go:75.2,81.12 3 1 +codeberg.org/snonux/yoga/internal/app/duration_cache.go:84.39,85.14 1 1 +codeberg.org/snonux/yoga/internal/app/duration_cache.go:85.14,87.3 1 0 +codeberg.org/snonux/yoga/internal/app/duration_cache.go:88.2,89.14 2 1 +codeberg.org/snonux/yoga/internal/app/duration_cache.go:89.14,92.3 2 1 +codeberg.org/snonux/yoga/internal/app/duration_cache.go:93.2,94.30 2 1 +codeberg.org/snonux/yoga/internal/app/duration_cache.go:94.30,96.3 1 1 +codeberg.org/snonux/yoga/internal/app/duration_cache.go:97.2,100.16 4 1 +codeberg.org/snonux/yoga/internal/app/duration_cache.go:100.16,102.3 1 0 +codeberg.org/snonux/yoga/internal/app/duration_cache.go:103.2,103.42 1 1 +codeberg.org/snonux/yoga/internal/app/filters.go:28.43,35.61 6 1 +codeberg.org/snonux/yoga/internal/app/filters.go:35.61,37.3 1 0 +codeberg.org/snonux/yoga/internal/app/filters.go:38.2,38.61 1 1 +codeberg.org/snonux/yoga/internal/app/filters.go:38.61,40.3 1 0 +codeberg.org/snonux/yoga/internal/app/filters.go:41.2,41.89 1 1 +codeberg.org/snonux/yoga/internal/app/filters.go:41.89,43.3 1 0 +codeberg.org/snonux/yoga/internal/app/filters.go:44.2,45.12 2 1 +codeberg.org/snonux/yoga/internal/app/filters.go:48.62,49.17 1 1 +codeberg.org/snonux/yoga/internal/app/filters.go:49.17,51.3 1 1 +codeberg.org/snonux/yoga/internal/app/filters.go:52.2,53.16 2 1 +codeberg.org/snonux/yoga/internal/app/filters.go:53.16,55.3 1 1 +codeberg.org/snonux/yoga/internal/app/filters.go:56.2,56.17 1 1 +codeberg.org/snonux/yoga/internal/app/filters.go:56.17,58.3 1 1 +codeberg.org/snonux/yoga/internal/app/filters.go:59.2,61.12 3 1 +codeberg.org/snonux/yoga/internal/app/filters.go:64.62,65.17 1 1 +codeberg.org/snonux/yoga/internal/app/filters.go:65.17,67.3 1 1 +codeberg.org/snonux/yoga/internal/app/filters.go:68.2,69.16 2 1 +codeberg.org/snonux/yoga/internal/app/filters.go:69.16,71.3 1 1 +codeberg.org/snonux/yoga/internal/app/filters.go:72.2,72.17 1 1 +codeberg.org/snonux/yoga/internal/app/filters.go:72.17,74.3 1 1 +codeberg.org/snonux/yoga/internal/app/filters.go:75.2,77.12 3 1 +codeberg.org/snonux/yoga/internal/app/filters.go:80.32,82.33 2 1 +codeberg.org/snonux/yoga/internal/app/filters.go:82.33,84.3 1 1 +codeberg.org/snonux/yoga/internal/app/filters.go:87.73,90.31 3 1 +codeberg.org/snonux/yoga/internal/app/filters.go:90.31,94.3 3 1 +codeberg.org/snonux/yoga/internal/app/filters.go:95.2,95.35 1 1 +codeberg.org/snonux/yoga/internal/app/filters.go:98.41,100.26 2 1 +codeberg.org/snonux/yoga/internal/app/filters.go:100.26,102.3 1 1 +codeberg.org/snonux/yoga/internal/app/filters.go:103.2,103.26 1 1 +codeberg.org/snonux/yoga/internal/app/filters.go:103.26,105.3 1 1 +codeberg.org/snonux/yoga/internal/app/filters.go:106.2,106.26 1 1 +codeberg.org/snonux/yoga/internal/app/filters.go:106.26,108.3 1 1 +codeberg.org/snonux/yoga/internal/app/filters.go:109.2,109.26 1 1 +codeberg.org/snonux/yoga/internal/app/filters.go:109.26,111.3 1 1 +codeberg.org/snonux/yoga/internal/app/filters.go:112.2,112.21 1 1 +codeberg.org/snonux/yoga/internal/app/filters.go:112.21,114.3 1 0 +codeberg.org/snonux/yoga/internal/app/filters.go:115.2,115.34 1 1 +codeberg.org/snonux/yoga/internal/app/filters.go:118.45,119.105 1 1 +codeberg.org/snonux/yoga/internal/app/filters.go:119.105,121.3 1 1 +codeberg.org/snonux/yoga/internal/app/filters.go:122.2,123.84 2 1 +codeberg.org/snonux/yoga/internal/app/filters.go:123.84,125.3 1 1 +codeberg.org/snonux/yoga/internal/app/filters.go:126.2,126.84 1 1 +codeberg.org/snonux/yoga/internal/app/filters.go:126.84,128.3 1 1 +codeberg.org/snonux/yoga/internal/app/filters.go:129.2,129.26 1 1 +codeberg.org/snonux/yoga/internal/app/filters.go:129.26,132.30 3 1 +codeberg.org/snonux/yoga/internal/app/filters.go:132.30,133.53 1 1 +codeberg.org/snonux/yoga/internal/app/filters.go:133.53,135.10 2 1 +codeberg.org/snonux/yoga/internal/app/filters.go:138.3,138.15 1 1 +codeberg.org/snonux/yoga/internal/app/filters.go:138.15,140.4 1 1 +codeberg.org/snonux/yoga/internal/app/filters.go:142.2,142.13 1 1 +codeberg.org/snonux/yoga/internal/app/filters.go:145.44,150.40 5 1 +codeberg.org/snonux/yoga/internal/app/filters.go:150.40,152.26 2 1 +codeberg.org/snonux/yoga/internal/app/filters.go:152.26,154.4 1 1 +codeberg.org/snonux/yoga/internal/app/filters.go:155.3,156.22 2 1 +codeberg.org/snonux/yoga/internal/app/filters.go:158.2,158.74 1 1 +codeberg.org/snonux/yoga/internal/app/filters.go:158.74,162.3 3 0 +codeberg.org/snonux/yoga/internal/app/filters.go:163.2,163.39 1 1 +codeberg.org/snonux/yoga/internal/app/load_progress.go:12.32,13.14 1 1 +codeberg.org/snonux/yoga/internal/app/load_progress.go:13.14,15.3 1 0 +codeberg.org/snonux/yoga/internal/app/load_progress.go:16.2,20.15 5 1 +codeberg.org/snonux/yoga/internal/app/load_progress.go:23.44,24.14 1 1 +codeberg.org/snonux/yoga/internal/app/load_progress.go:24.14,26.3 1 0 +codeberg.org/snonux/yoga/internal/app/load_progress.go:27.2,29.15 3 1 +codeberg.org/snonux/yoga/internal/app/load_progress.go:32.36,33.14 1 1 +codeberg.org/snonux/yoga/internal/app/load_progress.go:33.14,35.3 1 0 +codeberg.org/snonux/yoga/internal/app/load_progress.go:36.2,38.15 3 1 +codeberg.org/snonux/yoga/internal/app/load_progress.go:41.35,42.14 1 1 +codeberg.org/snonux/yoga/internal/app/load_progress.go:42.14,44.3 1 0 +codeberg.org/snonux/yoga/internal/app/load_progress.go:45.2,47.15 3 1 +codeberg.org/snonux/yoga/internal/app/load_progress.go:50.69,51.14 1 1 +codeberg.org/snonux/yoga/internal/app/load_progress.go:51.14,53.3 1 0 +codeberg.org/snonux/yoga/internal/app/load_progress.go:54.2,56.37 3 1 +codeberg.org/snonux/yoga/internal/app/loader.go:21.76,22.24 1 1 +codeberg.org/snonux/yoga/internal/app/loader.go:22.24,25.22 3 1 +codeberg.org/snonux/yoga/internal/app/loader.go:25.22,27.4 1 1 +codeberg.org/snonux/yoga/internal/app/loader.go:28.3,28.119 1 1 +codeberg.org/snonux/yoga/internal/app/loader.go:32.56,33.21 1 1 +codeberg.org/snonux/yoga/internal/app/loader.go:33.21,35.3 1 1 +codeberg.org/snonux/yoga/internal/app/loader.go:36.2,36.64 1 1 +codeberg.org/snonux/yoga/internal/app/loader.go:36.64,39.3 2 1 +codeberg.org/snonux/yoga/internal/app/loader.go:42.110,44.16 2 1 +codeberg.org/snonux/yoga/internal/app/loader.go:44.16,46.3 1 0 +codeberg.org/snonux/yoga/internal/app/loader.go:47.2,47.21 1 1 +codeberg.org/snonux/yoga/internal/app/loader.go:47.21,49.3 1 1 +codeberg.org/snonux/yoga/internal/app/loader.go:50.2,53.29 4 1 +codeberg.org/snonux/yoga/internal/app/loader.go:53.29,55.21 2 1 +codeberg.org/snonux/yoga/internal/app/loader.go:55.21,58.12 3 1 +codeberg.org/snonux/yoga/internal/app/loader.go:60.3,61.15 2 1 +codeberg.org/snonux/yoga/internal/app/loader.go:61.15,63.4 1 1 +codeberg.org/snonux/yoga/internal/app/loader.go:64.3,65.20 2 1 +codeberg.org/snonux/yoga/internal/app/loader.go:65.20,67.4 1 0 +codeberg.org/snonux/yoga/internal/app/loader.go:68.3,76.22 2 1 +codeberg.org/snonux/yoga/internal/app/loader.go:78.2,78.52 1 1 +codeberg.org/snonux/yoga/internal/app/loader.go:81.42,82.24 1 1 +codeberg.org/snonux/yoga/internal/app/loader.go:82.24,84.3 1 1 +codeberg.org/snonux/yoga/internal/app/loader.go:85.2,85.49 1 0 +codeberg.org/snonux/yoga/internal/app/loader.go:88.40,89.21 1 1 +codeberg.org/snonux/yoga/internal/app/loader.go:89.21,91.3 1 1 +codeberg.org/snonux/yoga/internal/app/loader.go:94.88,95.18 1 1 +codeberg.org/snonux/yoga/internal/app/loader.go:95.18,97.3 1 1 +codeberg.org/snonux/yoga/internal/app/loader.go:98.2,99.9 2 1 +codeberg.org/snonux/yoga/internal/app/loader.go:99.9,101.3 1 1 +codeberg.org/snonux/yoga/internal/app/loader.go:102.2,102.12 1 1 +codeberg.org/snonux/yoga/internal/app/loader.go:105.55,107.16 2 1 +codeberg.org/snonux/yoga/internal/app/loader.go:107.16,109.3 1 0 +codeberg.org/snonux/yoga/internal/app/loader.go:110.2,110.19 1 1 +codeberg.org/snonux/yoga/internal/app/loader.go:110.19,111.20 1 0 +codeberg.org/snonux/yoga/internal/app/loader.go:111.20,113.4 1 0 +codeberg.org/snonux/yoga/internal/app/loader.go:114.3,114.18 1 0 +codeberg.org/snonux/yoga/internal/app/loader.go:116.2,118.72 3 1 +codeberg.org/snonux/yoga/internal/app/loader.go:118.72,120.3 1 0 +codeberg.org/snonux/yoga/internal/app/loader.go:121.2,122.19 2 1 +codeberg.org/snonux/yoga/internal/app/loader.go:125.105,127.16 2 1 +codeberg.org/snonux/yoga/internal/app/loader.go:127.16,129.3 1 0 +codeberg.org/snonux/yoga/internal/app/loader.go:130.2,131.40 2 1 +codeberg.org/snonux/yoga/internal/app/loader.go:131.40,133.3 1 0 +codeberg.org/snonux/yoga/internal/app/loader.go:134.2,137.16 3 1 +codeberg.org/snonux/yoga/internal/app/loader.go:137.16,139.3 1 0 +codeberg.org/snonux/yoga/internal/app/loader.go:140.2,140.32 1 1 +codeberg.org/snonux/yoga/internal/app/loader.go:140.32,145.29 5 1 +codeberg.org/snonux/yoga/internal/app/loader.go:145.29,147.18 2 1 +codeberg.org/snonux/yoga/internal/app/loader.go:147.18,149.5 1 0 +codeberg.org/snonux/yoga/internal/app/loader.go:150.4,150.22 1 1 +codeberg.org/snonux/yoga/internal/app/loader.go:152.3,152.31 1 1 +codeberg.org/snonux/yoga/internal/app/loader.go:152.31,153.79 1 1 +codeberg.org/snonux/yoga/internal/app/loader.go:153.79,155.5 1 0 +codeberg.org/snonux/yoga/internal/app/loader.go:156.4,156.12 1 1 +codeberg.org/snonux/yoga/internal/app/loader.go:158.3,158.19 1 1 +codeberg.org/snonux/yoga/internal/app/loader.go:158.19,159.84 1 0 +codeberg.org/snonux/yoga/internal/app/loader.go:159.84,161.5 1 0 +codeberg.org/snonux/yoga/internal/app/loader.go:162.4,162.12 1 0 +codeberg.org/snonux/yoga/internal/app/loader.go:164.3,164.28 1 1 +codeberg.org/snonux/yoga/internal/app/loader.go:164.28,166.4 1 1 +codeberg.org/snonux/yoga/internal/app/loader.go:168.2,168.12 1 1 +codeberg.org/snonux/yoga/internal/app/loader.go:171.102,173.16 2 1 +codeberg.org/snonux/yoga/internal/app/loader.go:173.16,175.3 1 1 +codeberg.org/snonux/yoga/internal/app/loader.go:176.2,177.16 2 1 +codeberg.org/snonux/yoga/internal/app/loader.go:177.16,179.3 1 0 +codeberg.org/snonux/yoga/internal/app/loader.go:180.2,180.24 1 1 +codeberg.org/snonux/yoga/internal/app/loader.go:180.24,182.3 1 1 +codeberg.org/snonux/yoga/internal/app/loader.go:183.2,183.50 1 0 +codeberg.org/snonux/yoga/internal/app/loader.go:183.50,185.3 1 0 +codeberg.org/snonux/yoga/internal/app/loader.go:186.2,186.12 1 0 +codeberg.org/snonux/yoga/internal/app/loader.go:189.54,190.19 1 1 +codeberg.org/snonux/yoga/internal/app/loader.go:190.19,192.3 1 1 +codeberg.org/snonux/yoga/internal/app/loader.go:193.2,193.12 1 1 +codeberg.org/snonux/yoga/internal/app/loader.go:196.67,197.24 1 1 +codeberg.org/snonux/yoga/internal/app/loader.go:197.24,199.33 2 1 +codeberg.org/snonux/yoga/internal/app/loader.go:199.33,200.54 1 0 +codeberg.org/snonux/yoga/internal/app/loader.go:200.54,202.5 1 0 +codeberg.org/snonux/yoga/internal/app/loader.go:204.3,204.64 1 1 +codeberg.org/snonux/yoga/internal/app/loader.go:208.56,214.16 5 1 +codeberg.org/snonux/yoga/internal/app/loader.go:214.16,216.3 1 1 +codeberg.org/snonux/yoga/internal/app/loader.go:217.2,218.15 2 1 +codeberg.org/snonux/yoga/internal/app/loader.go:218.15,220.3 1 0 +codeberg.org/snonux/yoga/internal/app/loader.go:221.2,222.16 2 1 +codeberg.org/snonux/yoga/internal/app/loader.go:222.16,224.3 1 0 +codeberg.org/snonux/yoga/internal/app/loader.go:225.2,225.59 1 1 +codeberg.org/snonux/yoga/internal/app/loader.go:228.46,229.24 1 1 +codeberg.org/snonux/yoga/internal/app/loader.go:229.24,232.37 3 1 +codeberg.org/snonux/yoga/internal/app/loader.go:232.37,234.4 1 0 +codeberg.org/snonux/yoga/internal/app/loader.go:235.3,235.13 1 1 +codeberg.org/snonux/yoga/internal/app/loader.go:235.13,235.31 1 1 +codeberg.org/snonux/yoga/internal/app/loader.go:236.3,236.34 1 1 +codeberg.org/snonux/yoga/internal/app/loader.go:240.47,242.16 2 1 +codeberg.org/snonux/yoga/internal/app/loader.go:242.16,244.3 1 0 +codeberg.org/snonux/yoga/internal/app/loader.go:245.2,245.27 1 1 +codeberg.org/snonux/yoga/internal/app/loader.go:248.32,252.2 3 1 +codeberg.org/snonux/yoga/internal/app/loader.go:255.62,257.2 1 1 +codeberg.org/snonux/yoga/internal/app/model.go:63.44,87.2 7 1 +codeberg.org/snonux/yoga/internal/app/model.go:89.31,103.2 4 1 +codeberg.org/snonux/yoga/internal/app/model.go:105.39,130.2 17 1 +codeberg.org/snonux/yoga/internal/app/model.go:132.38,138.2 5 1 +codeberg.org/snonux/yoga/internal/app/model.go:140.84,147.2 1 1 +codeberg.org/snonux/yoga/internal/app/model.go:149.31,150.23 1 1 +codeberg.org/snonux/yoga/internal/app/model.go:150.23,152.3 1 1 +codeberg.org/snonux/yoga/internal/app/model.go:153.2,154.23 2 1 +codeberg.org/snonux/yoga/internal/app/model.go:154.23,156.3 1 1 +codeberg.org/snonux/yoga/internal/app/model.go:157.2,157.16 1 0 +codeberg.org/snonux/yoga/internal/app/model.go:160.57,161.29 1 1 +codeberg.org/snonux/yoga/internal/app/model.go:162.18,163.31 1 1 +codeberg.org/snonux/yoga/internal/app/model.go:164.25,165.39 1 1 +codeberg.org/snonux/yoga/internal/app/model.go:166.25,167.39 1 1 +codeberg.org/snonux/yoga/internal/app/model.go:168.23,169.37 1 1 +codeberg.org/snonux/yoga/internal/app/model.go:170.20,171.39 1 1 +codeberg.org/snonux/yoga/internal/app/model.go:172.24,173.38 1 0 +codeberg.org/snonux/yoga/internal/app/model.go:174.20,175.34 1 0 +codeberg.org/snonux/yoga/internal/app/model.go:176.25,177.35 1 1 +codeberg.org/snonux/yoga/internal/app/model.go:178.10,179.28 1 0 +codeberg.org/snonux/yoga/internal/app/model.go:183.30,184.15 1 1 +codeberg.org/snonux/yoga/internal/app/model.go:184.15,186.3 1 0 +codeberg.org/snonux/yoga/internal/app/model.go:187.2,188.19 2 1 +codeberg.org/snonux/yoga/internal/app/model.go:188.19,190.3 1 0 +codeberg.org/snonux/yoga/internal/app/model.go:191.2,191.19 1 1 +codeberg.org/snonux/yoga/internal/app/model.go:191.19,193.3 1 0 +codeberg.org/snonux/yoga/internal/app/model.go:194.2,194.13 1 1 +codeberg.org/snonux/yoga/internal/app/model.go:197.36,205.24 6 1 +codeberg.org/snonux/yoga/internal/app/model.go:205.24,207.3 1 0 +codeberg.org/snonux/yoga/internal/app/model.go:208.2,209.16 2 1 +codeberg.org/snonux/yoga/internal/app/model.go:209.16,212.3 2 1 +codeberg.org/snonux/yoga/internal/app/model.go:213.2,213.34 1 1 +codeberg.org/snonux/yoga/internal/app/model.go:216.36,219.16 3 1 +codeberg.org/snonux/yoga/internal/app/model.go:219.16,221.3 1 1 +codeberg.org/snonux/yoga/internal/app/model.go:222.2,222.36 1 1 +codeberg.org/snonux/yoga/internal/app/model.go:222.36,224.3 1 1 +codeberg.org/snonux/yoga/internal/app/model.go:225.2,225.47 1 1 +codeberg.org/snonux/yoga/internal/app/model.go:228.51,229.16 1 1 +codeberg.org/snonux/yoga/internal/app/model.go:229.16,231.3 1 0 +codeberg.org/snonux/yoga/internal/app/model.go:232.2,233.54 2 1 +codeberg.org/snonux/yoga/internal/app/model.go:233.54,235.3 1 1 +codeberg.org/snonux/yoga/internal/app/model.go:236.2,236.15 1 1 +codeberg.org/snonux/yoga/internal/app/model.go:239.51,240.17 1 1 +codeberg.org/snonux/yoga/internal/app/model.go:240.17,242.3 1 0 +codeberg.org/snonux/yoga/internal/app/model.go:243.2,245.15 3 1 +codeberg.org/snonux/yoga/internal/app/model.go:248.77,253.16 5 1 +codeberg.org/snonux/yoga/internal/app/model.go:253.16,255.3 1 1 +codeberg.org/snonux/yoga/internal/app/model.go:256.2,256.15 1 0 +codeberg.org/snonux/yoga/internal/app/model.go:259.47,260.21 1 1 +codeberg.org/snonux/yoga/internal/app/model.go:260.21,262.3 1 0 +codeberg.org/snonux/yoga/internal/app/model.go:263.2,266.29 4 1 +codeberg.org/snonux/yoga/internal/app/model.go:266.29,268.3 1 0 +codeberg.org/snonux/yoga/internal/app/model.go:269.2,274.31 6 1 +codeberg.org/snonux/yoga/internal/app/model.go:274.31,277.3 2 1 +codeberg.org/snonux/yoga/internal/app/model.go:277.8,279.18 2 1 +codeberg.org/snonux/yoga/internal/app/model.go:279.18,283.4 3 1 +codeberg.org/snonux/yoga/internal/app/model.go:284.3,284.18 1 1 +codeberg.org/snonux/yoga/internal/app/model.go:284.18,288.4 3 1 +codeberg.org/snonux/yoga/internal/app/model.go:289.3,289.18 1 1 +codeberg.org/snonux/yoga/internal/app/model.go:289.18,293.4 3 0 +codeberg.org/snonux/yoga/internal/app/model.go:294.3,294.18 1 1 +codeberg.org/snonux/yoga/internal/app/model.go:294.18,297.4 2 0 +codeberg.org/snonux/yoga/internal/app/model.go:299.2,300.32 2 1 +codeberg.org/snonux/yoga/internal/app/model.go:303.24,304.11 1 1 +codeberg.org/snonux/yoga/internal/app/model.go:304.11,306.3 1 1 +codeberg.org/snonux/yoga/internal/app/model.go:307.2,307.10 1 1 +codeberg.org/snonux/yoga/internal/app/model.go:310.44,311.26 1 1 +codeberg.org/snonux/yoga/internal/app/model.go:311.26,313.3 1 1 +codeberg.org/snonux/yoga/internal/app/model.go:314.2,315.104 2 1 +codeberg.org/snonux/yoga/internal/app/model.go:318.62,322.2 3 1 +codeberg.org/snonux/yoga/internal/app/model.go:324.56,325.20 1 1 +codeberg.org/snonux/yoga/internal/app/model.go:325.20,328.3 2 1 +codeberg.org/snonux/yoga/internal/app/model.go:329.2,330.10 2 1 +codeberg.org/snonux/yoga/internal/app/model.go:333.79,336.2 2 0 +codeberg.org/snonux/yoga/internal/app/model.go:338.77,340.20 2 1 +codeberg.org/snonux/yoga/internal/app/model.go:340.20,343.3 2 0 +codeberg.org/snonux/yoga/internal/app/model.go:345.2,345.24 1 1 +codeberg.org/snonux/yoga/internal/app/model.go:345.24,347.3 1 1 +codeberg.org/snonux/yoga/internal/app/model.go:347.8,349.30 2 0 +codeberg.org/snonux/yoga/internal/app/model.go:349.30,351.4 1 0 +codeberg.org/snonux/yoga/internal/app/model.go:353.3,353.39 1 0 +codeberg.org/snonux/yoga/internal/app/model.go:353.39,354.50 1 0 +codeberg.org/snonux/yoga/internal/app/model.go:354.50,356.5 1 0 +codeberg.org/snonux/yoga/internal/app/model.go:356.10,358.5 1 0 +codeberg.org/snonux/yoga/internal/app/model.go:362.2,369.27 8 1 +codeberg.org/snonux/yoga/internal/app/model.go:369.27,371.3 1 1 +codeberg.org/snonux/yoga/internal/app/model.go:372.2,373.15 2 1 +codeberg.org/snonux/yoga/internal/app/model.go:376.60,377.26 1 1 +codeberg.org/snonux/yoga/internal/app/model.go:377.26,381.3 3 0 +codeberg.org/snonux/yoga/internal/app/model.go:382.2,383.26 2 1 +codeberg.org/snonux/yoga/internal/app/model.go:383.26,385.26 2 1 +codeberg.org/snonux/yoga/internal/app/model.go:385.26,387.4 1 1 +codeberg.org/snonux/yoga/internal/app/model.go:388.8,390.26 2 1 +codeberg.org/snonux/yoga/internal/app/model.go:390.26,392.4 1 1 +codeberg.org/snonux/yoga/internal/app/model.go:394.2,394.23 1 1 +codeberg.org/snonux/yoga/internal/app/model.go:394.23,396.3 1 1 +codeberg.org/snonux/yoga/internal/app/model.go:397.2,398.26 2 1 +codeberg.org/snonux/yoga/internal/app/model.go:401.48,402.34 1 1 +codeberg.org/snonux/yoga/internal/app/model.go:402.34,404.3 1 0 +codeberg.org/snonux/yoga/internal/app/model.go:405.2,406.17 2 1 +codeberg.org/snonux/yoga/internal/app/model.go:406.17,408.3 1 0 +codeberg.org/snonux/yoga/internal/app/model.go:409.2,409.17 1 1 +codeberg.org/snonux/yoga/internal/app/model.go:409.17,411.3 1 1 +codeberg.org/snonux/yoga/internal/app/model.go:412.2,412.39 1 1 +codeberg.org/snonux/yoga/internal/app/model.go:412.39,414.3 1 1 +codeberg.org/snonux/yoga/internal/app/model.go:415.2,416.31 2 1 +codeberg.org/snonux/yoga/internal/app/model.go:416.31,418.17 2 1 +codeberg.org/snonux/yoga/internal/app/model.go:418.17,420.4 1 1 +codeberg.org/snonux/yoga/internal/app/model.go:422.2,422.20 1 1 +codeberg.org/snonux/yoga/internal/app/model.go:422.20,424.3 1 0 +codeberg.org/snonux/yoga/internal/app/model.go:425.2,425.27 1 1 +codeberg.org/snonux/yoga/internal/app/model.go:428.46,429.34 1 1 +codeberg.org/snonux/yoga/internal/app/model.go:429.34,431.3 1 0 +codeberg.org/snonux/yoga/internal/app/model.go:432.2,435.41 4 1 +codeberg.org/snonux/yoga/internal/app/model.go:438.36,439.40 1 1 +codeberg.org/snonux/yoga/internal/app/model.go:439.40,441.3 1 1 +codeberg.org/snonux/yoga/internal/app/model.go:442.2,442.11 1 1 +codeberg.org/snonux/yoga/internal/app/model.go:445.81,446.16 1 1 +codeberg.org/snonux/yoga/internal/app/model.go:446.16,448.3 1 0 +codeberg.org/snonux/yoga/internal/app/model.go:449.2,449.32 1 1 +codeberg.org/snonux/yoga/internal/app/model.go:449.32,452.3 2 1 +codeberg.org/snonux/yoga/internal/app/model.go:453.2,453.14 1 1 +codeberg.org/snonux/yoga/internal/app/model.go:453.14,456.3 2 1 +codeberg.org/snonux/yoga/internal/app/model.go:457.2,458.41 2 1 +codeberg.org/snonux/yoga/internal/app/model_durations.go:11.81,12.20 1 1 +codeberg.org/snonux/yoga/internal/app/model_durations.go:12.20,16.3 3 1 +codeberg.org/snonux/yoga/internal/app/model_durations.go:17.2,17.28 1 1 +codeberg.org/snonux/yoga/internal/app/model_durations.go:17.28,19.3 1 1 +codeberg.org/snonux/yoga/internal/app/model_durations.go:20.2,23.30 4 1 +codeberg.org/snonux/yoga/internal/app/model_durations.go:23.30,26.3 2 1 +codeberg.org/snonux/yoga/internal/app/model_durations.go:27.2,28.15 2 1 +codeberg.org/snonux/yoga/internal/app/model_durations.go:31.64,32.20 1 1 +codeberg.org/snonux/yoga/internal/app/model_durations.go:32.20,35.3 2 1 +codeberg.org/snonux/yoga/internal/app/model_durations.go:36.2,36.25 1 1 +codeberg.org/snonux/yoga/internal/app/model_durations.go:36.25,38.3 1 1 +codeberg.org/snonux/yoga/internal/app/model_durations.go:41.46,43.39 2 1 +codeberg.org/snonux/yoga/internal/app/model_durations.go:43.39,45.3 1 0 +codeberg.org/snonux/yoga/internal/app/model_durations.go:46.2,46.29 1 1 +codeberg.org/snonux/yoga/internal/app/model_durations.go:49.47,50.16 1 1 +codeberg.org/snonux/yoga/internal/app/model_durations.go:50.16,52.3 1 0 +codeberg.org/snonux/yoga/internal/app/model_durations.go:53.2,53.35 1 1 +codeberg.org/snonux/yoga/internal/app/model_durations.go:53.35,54.25 1 1 +codeberg.org/snonux/yoga/internal/app/model_durations.go:54.25,57.4 2 1 +codeberg.org/snonux/yoga/internal/app/model_durations.go:61.80,62.26 1 1 +codeberg.org/snonux/yoga/internal/app/model_durations.go:62.26,63.31 1 1 +codeberg.org/snonux/yoga/internal/app/model_durations.go:63.31,64.12 1 0 +codeberg.org/snonux/yoga/internal/app/model_durations.go:66.3,68.9 3 1 +codeberg.org/snonux/yoga/internal/app/model_durations.go:72.44,74.2 1 1 +codeberg.org/snonux/yoga/internal/app/model_durations.go:76.39,77.20 1 1 +codeberg.org/snonux/yoga/internal/app/model_durations.go:77.20,78.41 1 1 +codeberg.org/snonux/yoga/internal/app/model_durations.go:78.41,80.4 1 0 +codeberg.org/snonux/yoga/internal/app/model_durations.go:80.9,82.4 1 1 +codeberg.org/snonux/yoga/internal/app/model_durations.go:83.3,84.9 2 1 +codeberg.org/snonux/yoga/internal/app/model_durations.go:86.2,87.24 2 0 +codeberg.org/snonux/yoga/internal/app/model_durations.go:90.38,95.2 4 1 +codeberg.org/snonux/yoga/internal/app/model_keys.go:10.66,11.52 1 1 +codeberg.org/snonux/yoga/internal/app/model_keys.go:11.52,13.3 1 0 +codeberg.org/snonux/yoga/internal/app/model_keys.go:14.2,14.15 1 1 +codeberg.org/snonux/yoga/internal/app/model_keys.go:14.15,16.3 1 0 +codeberg.org/snonux/yoga/internal/app/model_keys.go:17.2,17.19 1 1 +codeberg.org/snonux/yoga/internal/app/model_keys.go:17.19,19.3 1 0 +codeberg.org/snonux/yoga/internal/app/model_keys.go:20.2,20.19 1 1 +codeberg.org/snonux/yoga/internal/app/model_keys.go:20.19,22.3 1 1 +codeberg.org/snonux/yoga/internal/app/model_keys.go:23.2,23.30 1 1 +codeberg.org/snonux/yoga/internal/app/model_keys.go:26.55,27.22 1 1 +codeberg.org/snonux/yoga/internal/app/model_keys.go:28.21,29.24 1 0 +codeberg.org/snonux/yoga/internal/app/model_keys.go:30.10,31.20 1 1 +codeberg.org/snonux/yoga/internal/app/model_keys.go:35.69,36.22 1 1 +codeberg.org/snonux/yoga/internal/app/model_keys.go:37.13,40.16 3 0 +codeberg.org/snonux/yoga/internal/app/model_keys.go:41.15,43.16 2 1 +codeberg.org/snonux/yoga/internal/app/model_keys.go:44.13,45.63 1 1 +codeberg.org/snonux/yoga/internal/app/model_keys.go:46.19,47.86 1 1 +codeberg.org/snonux/yoga/internal/app/model_keys.go:49.2,52.15 4 1 +codeberg.org/snonux/yoga/internal/app/model_keys.go:55.50,56.46 1 1 +codeberg.org/snonux/yoga/internal/app/model_keys.go:56.46,59.3 2 0 +codeberg.org/snonux/yoga/internal/app/model_keys.go:60.2,63.12 4 1 +codeberg.org/snonux/yoga/internal/app/model_keys.go:66.35,67.33 1 1 +codeberg.org/snonux/yoga/internal/app/model_keys.go:67.33,68.26 1 1 +codeberg.org/snonux/yoga/internal/app/model_keys.go:68.26,70.12 2 1 +codeberg.org/snonux/yoga/internal/app/model_keys.go:72.3,72.28 1 1 +codeberg.org/snonux/yoga/internal/app/model_keys.go:76.68,77.22 1 1 +codeberg.org/snonux/yoga/internal/app/model_keys.go:78.16,79.25 1 1 +codeberg.org/snonux/yoga/internal/app/model_keys.go:80.15,81.27 1 0 +codeberg.org/snonux/yoga/internal/app/model_keys.go:82.11,83.37 1 0 +codeberg.org/snonux/yoga/internal/app/model_keys.go:84.11,85.41 1 1 +codeberg.org/snonux/yoga/internal/app/model_keys.go:86.11,87.36 1 0 +codeberg.org/snonux/yoga/internal/app/model_keys.go:88.11,89.24 1 1 +codeberg.org/snonux/yoga/internal/app/model_keys.go:90.11,91.27 1 0 +codeberg.org/snonux/yoga/internal/app/model_keys.go:92.11,93.25 1 1 +codeberg.org/snonux/yoga/internal/app/model_keys.go:94.11,95.25 1 1 +codeberg.org/snonux/yoga/internal/app/model_keys.go:96.11,97.30 1 1 +codeberg.org/snonux/yoga/internal/app/model_keys.go:98.11,99.28 1 0 +codeberg.org/snonux/yoga/internal/app/model_keys.go:99.28,99.57 1 0 +codeberg.org/snonux/yoga/internal/app/model_keys.go:100.11,101.31 1 1 +codeberg.org/snonux/yoga/internal/app/model_keys.go:102.10,103.28 1 1 +codeberg.org/snonux/yoga/internal/app/model_keys.go:107.51,111.2 3 1 +codeberg.org/snonux/yoga/internal/app/model_keys.go:113.53,114.26 1 1 +codeberg.org/snonux/yoga/internal/app/model_keys.go:114.26,116.3 1 0 +codeberg.org/snonux/yoga/internal/app/model_keys.go:117.2,118.39 2 1 +codeberg.org/snonux/yoga/internal/app/model_keys.go:118.39,120.3 1 0 +codeberg.org/snonux/yoga/internal/app/model_keys.go:121.2,123.52 3 1 +codeberg.org/snonux/yoga/internal/app/model_keys.go:126.68,131.2 4 1 +codeberg.org/snonux/yoga/internal/app/model_keys.go:133.50,134.23 1 1 +codeberg.org/snonux/yoga/internal/app/model_keys.go:134.23,137.3 2 0 +codeberg.org/snonux/yoga/internal/app/model_keys.go:138.2,139.19 2 1 +codeberg.org/snonux/yoga/internal/app/model_keys.go:139.19,142.3 2 1 +codeberg.org/snonux/yoga/internal/app/model_keys.go:143.2,144.15 2 1 +codeberg.org/snonux/yoga/internal/app/model_keys.go:147.56,152.2 4 1 +codeberg.org/snonux/yoga/internal/app/model_keys.go:154.57,155.26 1 1 +codeberg.org/snonux/yoga/internal/app/model_keys.go:155.26,158.3 2 1 +codeberg.org/snonux/yoga/internal/app/model_keys.go:159.2,163.15 5 1 +codeberg.org/snonux/yoga/internal/app/model_sort.go:10.46,11.27 1 1 +codeberg.org/snonux/yoga/internal/app/model_sort.go:11.27,14.3 2 1 +codeberg.org/snonux/yoga/internal/app/model_sort.go:15.2,16.24 2 1 +codeberg.org/snonux/yoga/internal/app/model_sort.go:19.39,21.29 2 1 +codeberg.org/snonux/yoga/internal/app/model_sort.go:21.29,22.25 1 1 +codeberg.org/snonux/yoga/internal/app/model_sort.go:22.25,24.4 1 1 +codeberg.org/snonux/yoga/internal/app/model_sort.go:26.2,26.43 1 1 +codeberg.org/snonux/yoga/internal/app/model_sort.go:26.43,28.3 1 1 +codeberg.org/snonux/yoga/internal/app/model_sort.go:29.2,30.21 2 1 +codeberg.org/snonux/yoga/internal/app/model_sort.go:33.39,35.21 2 1 +codeberg.org/snonux/yoga/internal/app/model_sort.go:36.18,37.59 1 1 +codeberg.org/snonux/yoga/internal/app/model_sort.go:38.22,39.33 1 1 +codeberg.org/snonux/yoga/internal/app/model_sort.go:40.17,41.37 1 0 +codeberg.org/snonux/yoga/internal/app/model_sort.go:43.2,43.21 1 1 +codeberg.org/snonux/yoga/internal/app/model_sort.go:43.21,45.3 1 1 +codeberg.org/snonux/yoga/internal/app/model_sort.go:46.2,46.14 1 0 +codeberg.org/snonux/yoga/internal/app/model_sort.go:49.35,51.31 2 1 +codeberg.org/snonux/yoga/internal/app/model_sort.go:51.31,53.3 1 1 +codeberg.org/snonux/yoga/internal/app/model_sort.go:54.2,55.19 2 1 +codeberg.org/snonux/yoga/internal/app/model_sort.go:55.19,57.3 1 1 +codeberg.org/snonux/yoga/internal/app/model_tags.go:12.53,13.26 1 1 +codeberg.org/snonux/yoga/internal/app/model_tags.go:13.26,16.3 2 0 +codeberg.org/snonux/yoga/internal/app/model_tags.go:17.2,18.45 2 1 +codeberg.org/snonux/yoga/internal/app/model_tags.go:18.45,21.3 2 0 +codeberg.org/snonux/yoga/internal/app/model_tags.go:22.2,30.15 9 1 +codeberg.org/snonux/yoga/internal/app/model_tags.go:33.66,34.22 1 1 +codeberg.org/snonux/yoga/internal/app/model_tags.go:35.13,40.16 5 0 +codeberg.org/snonux/yoga/internal/app/model_tags.go:41.15,42.24 1 1 +codeberg.org/snonux/yoga/internal/app/model_tags.go:44.2,46.15 3 0 +codeberg.org/snonux/yoga/internal/app/model_tags.go:49.50,50.25 1 1 +codeberg.org/snonux/yoga/internal/app/model_tags.go:50.25,55.3 4 0 +codeberg.org/snonux/yoga/internal/app/model_tags.go:56.2,64.35 9 1 +codeberg.org/snonux/yoga/internal/app/model_tags.go:67.71,68.20 1 1 +codeberg.org/snonux/yoga/internal/app/model_tags.go:68.20,75.3 6 0 +codeberg.org/snonux/yoga/internal/app/model_tags.go:76.2,83.24 8 1 +codeberg.org/snonux/yoga/internal/app/model_tags.go:83.24,86.3 2 0 +codeberg.org/snonux/yoga/internal/app/model_tags.go:87.2,88.15 2 1 +codeberg.org/snonux/yoga/internal/app/model_tags.go:91.58,92.26 1 1 +codeberg.org/snonux/yoga/internal/app/model_tags.go:92.26,93.31 1 1 +codeberg.org/snonux/yoga/internal/app/model_tags.go:93.31,96.4 2 1 +codeberg.org/snonux/yoga/internal/app/model_tags.go:100.43,101.36 1 1 +codeberg.org/snonux/yoga/internal/app/model_tags.go:101.36,103.3 1 1 +codeberg.org/snonux/yoga/internal/app/model_tags.go:104.2,107.29 4 1 +codeberg.org/snonux/yoga/internal/app/model_tags.go:107.29,109.20 2 1 +codeberg.org/snonux/yoga/internal/app/model_tags.go:109.20,110.12 1 1 +codeberg.org/snonux/yoga/internal/app/model_tags.go:112.3,113.31 2 1 +codeberg.org/snonux/yoga/internal/app/model_tags.go:113.31,114.12 1 1 +codeberg.org/snonux/yoga/internal/app/model_tags.go:116.3,117.31 2 1 +codeberg.org/snonux/yoga/internal/app/model_tags.go:119.2,119.13 1 1 +codeberg.org/snonux/yoga/internal/app/model_tags.go:122.53,125.2 2 1 +codeberg.org/snonux/yoga/internal/app/model_tags.go:127.40,135.2 7 0 +codeberg.org/snonux/yoga/internal/app/tag_commands.go:8.57,11.24 2 1 +codeberg.org/snonux/yoga/internal/app/tag_commands.go:11.24,12.49 1 1 +codeberg.org/snonux/yoga/internal/app/tag_commands.go:12.49,14.4 1 0 +codeberg.org/snonux/yoga/internal/app/tag_commands.go:15.3,16.17 2 1 +codeberg.org/snonux/yoga/internal/app/tag_commands.go:16.17,18.4 1 0 +codeberg.org/snonux/yoga/internal/app/tag_commands.go:19.3,19.51 1 1 +codeberg.org/snonux/yoga/internal/app/view_helpers.go:12.34,14.20 2 1 +codeberg.org/snonux/yoga/internal/app/view_helpers.go:14.20,16.3 1 1 +codeberg.org/snonux/yoga/internal/app/view_helpers.go:17.2,19.18 3 1 +codeberg.org/snonux/yoga/internal/app/view_helpers.go:19.18,21.3 1 1 +codeberg.org/snonux/yoga/internal/app/view_helpers.go:22.2,22.47 1 1 +codeberg.org/snonux/yoga/internal/app/view_helpers.go:25.55,26.30 1 1 +codeberg.org/snonux/yoga/internal/app/view_helpers.go:26.30,28.3 1 1 +codeberg.org/snonux/yoga/internal/app/view_helpers.go:29.2,29.14 1 1 +codeberg.org/snonux/yoga/internal/app/view_helpers.go:29.14,31.3 1 1 +codeberg.org/snonux/yoga/internal/app/view_helpers.go:32.2,32.18 1 1 +codeberg.org/snonux/yoga/internal/app/view_helpers.go:32.18,34.3 1 0 +codeberg.org/snonux/yoga/internal/app/view_helpers.go:35.2,36.20 2 1 +codeberg.org/snonux/yoga/internal/app/view_helpers.go:36.20,38.3 1 0 +codeberg.org/snonux/yoga/internal/app/view_helpers.go:39.2,40.33 2 1 +codeberg.org/snonux/yoga/internal/app/view_helpers.go:43.45,44.12 1 1 +codeberg.org/snonux/yoga/internal/app/view_helpers.go:44.12,46.3 1 0 +codeberg.org/snonux/yoga/internal/app/view_helpers.go:47.2,51.15 5 1 +codeberg.org/snonux/yoga/internal/app/view_helpers.go:51.15,53.3 1 0 +codeberg.org/snonux/yoga/internal/app/view_helpers.go:54.2,54.51 1 1 +codeberg.org/snonux/yoga/internal/app/view_helpers.go:57.38,58.16 1 1 +codeberg.org/snonux/yoga/internal/app/view_helpers.go:58.16,60.3 1 1 +codeberg.org/snonux/yoga/internal/app/view_helpers.go:61.2,62.23 2 1 +codeberg.org/snonux/yoga/internal/app/view_helpers.go:62.23,64.3 1 1 +codeberg.org/snonux/yoga/internal/app/view_helpers.go:65.2,65.21 1 1 +codeberg.org/snonux/yoga/internal/app/view_helpers.go:65.21,67.3 1 0 +codeberg.org/snonux/yoga/internal/app/view_helpers.go:68.2,68.24 1 1 +codeberg.org/snonux/yoga/internal/app/view_helpers.go:68.24,70.3 1 1 +codeberg.org/snonux/yoga/internal/app/view_helpers.go:71.2,71.31 1 0 +codeberg.org/snonux/yoga/internal/app/view_helpers.go:74.35,76.49 2 1 +codeberg.org/snonux/yoga/internal/app/view_helpers.go:76.49,78.3 1 0 +codeberg.org/snonux/yoga/internal/app/view_helpers.go:79.2,79.13 1 1 +codeberg.org/snonux/yoga/internal/app/view_helpers.go:82.39,83.20 1 1 +codeberg.org/snonux/yoga/internal/app/view_helpers.go:83.20,85.3 1 1 +codeberg.org/snonux/yoga/internal/app/view_helpers.go:86.2,86.33 1 1 diff --git a/internal/app/model_keys.go b/internal/app/model_keys.go index 0e786d4..facf466 100644 --- a/internal/app/model_keys.go +++ b/internal/app/model_keys.go @@ -2,6 +2,7 @@ package app import ( "fmt" + "math/rand" tea "github.com/charmbracelet/bubbletea" ) @@ -96,6 +97,8 @@ func (m model) handleTableKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.resetFilterState() case "i": return m, func() tea.Msg { return reindexVideosMsg{} } + case "x": + return m.selectRandomVideo() default: return m.updateTable(msg) } @@ -147,3 +150,15 @@ func (m model) resetFilterState() (tea.Model, tea.Cmd) { m.statusMessage = fmt.Sprintf("Filters cleared (%d videos)", len(m.filtered)) return m, nil } + +func (m model) selectRandomVideo() (tea.Model, tea.Cmd) { + if len(m.filtered) == 0 { + m.statusMessage = "No videos to select from" + return m, nil + } + idx := rand.Intn(len(m.filtered)) + m.table.SetCursor(idx) + video := m.filtered[idx] + m.statusMessage = fmt.Sprintf("Randomly selected: %s", video.Name) + return m, nil +} diff --git a/internal/app/model_test.go b/internal/app/model_test.go index 4cb9bcf..62efc0d 100644 --- a/internal/app/model_test.go +++ b/internal/app/model_test.go @@ -2,6 +2,7 @@ package app import ( "errors" + "fmt" "os" "path/filepath" "strings" @@ -717,3 +718,491 @@ func keyMsg(value string) tea.KeyMsg { } return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(value), Alt: false} } + +func TestSelectRandomVideoWithVideos(t *testing.T) { + root := t.TempDir() + m, err := newModel(Options{Root: root}) + if err != nil { + t.Fatalf("newModel: %v", err) + } + videos := []video{ + {Name: "yoga1.mp4", Path: filepath.Join(root, "yoga1.mp4"), Duration: 10 * time.Minute}, + {Name: "yoga2.mp4", Path: filepath.Join(root, "yoga2.mp4"), Duration: 20 * time.Minute}, + {Name: "yoga3.mp4", Path: filepath.Join(root, "yoga3.mp4"), Duration: 30 * time.Minute}, + } + m.loading = false + m.filtered = videos + m.table.SetRows([]table.Row{videoRow(videos[0]), videoRow(videos[1]), videoRow(videos[2])}) + + modelAny, cmd := m.selectRandomVideo() + m = modelAny.(model) + + if cmd != nil { + t.Fatalf("expected no command") + } + if !strings.Contains(m.statusMessage, "Randomly selected:") { + t.Fatalf("expected random selection message, got %s", m.statusMessage) + } + cursor := m.table.Cursor() + if cursor < 0 || cursor >= len(videos) { + t.Fatalf("expected cursor in valid range, got %d", cursor) + } + if !strings.Contains(m.statusMessage, videos[cursor].Name) { + t.Fatalf("expected selected video name in message") + } +} + +func TestSelectRandomVideoWithNoVideos(t *testing.T) { + root := t.TempDir() + m, err := newModel(Options{Root: root}) + if err != nil { + t.Fatalf("newModel: %v", err) + } + m.loading = false + m.filtered = []video{} + modelAny, cmd := m.selectRandomVideo() + m = modelAny.(model) + + if cmd != nil { + t.Fatalf("expected no command") + } + if !strings.Contains(m.statusMessage, "No videos to select from") { + t.Fatalf("expected no videos message, got %s", m.statusMessage) + } +} + +func TestSelectRandomVideoViaKeyHandler(t *testing.T) { + root := t.TempDir() + m, err := newModel(Options{Root: root}) + if err != nil { + t.Fatalf("newModel: %v", err) + } + videos := []video{ + {Name: "clip1.mp4", Path: filepath.Join(root, "clip1.mp4")}, + {Name: "clip2.mp4", Path: filepath.Join(root, "clip2.mp4")}, + } + m.loading = false + m.filtered = videos + m.table.SetRows([]table.Row{videoRow(videos[0]), videoRow(videos[1])}) + + modelAny, _ := m.handleKeyMsg(keyMsg("x")) + m = modelAny.(model) + + if !strings.Contains(m.statusMessage, "Randomly selected:") { + t.Fatalf("expected random selection via key handler") + } +} + +func TestSelectRandomVideoMultipleTimes(t *testing.T) { + root := t.TempDir() + m, err := newModel(Options{Root: root}) + if err != nil { + t.Fatalf("newModel: %v", err) + } + videos := make([]video, 10) + rows := make([]table.Row, 10) + for i := 0; i < 10; i++ { + videos[i] = video{Name: fmt.Sprintf("video%d.mp4", i), Path: filepath.Join(root, fmt.Sprintf("video%d.mp4", i))} + rows[i] = videoRow(videos[i]) + } + m.loading = false + m.filtered = videos + m.table.SetRows(rows) + + selections := make(map[string]int) + for i := 0; i < 50; i++ { + modelAny, _ := m.selectRandomVideo() + m = modelAny.(model) + cursor := m.table.Cursor() + selections[videos[cursor].Name]++ + } + + if len(selections) < 5 { + t.Fatalf("expected randomness across multiple calls, only got %d unique selections", len(selections)) + } +} + +func TestSelectRandomVideoWithFilteredResults(t *testing.T) { + root := t.TempDir() + m, err := newModel(Options{Root: root}) + if err != nil { + t.Fatalf("newModel: %v", err) + } + m.loading = false + m.videos = []video{ + {Name: "morning flow.mp4", Path: filepath.Join(root, "morning.mp4"), Duration: 10 * time.Minute}, + {Name: "evening flow.mp4", Path: filepath.Join(root, "evening.mp4"), Duration: 30 * time.Minute}, + {Name: "power.mp4", Path: filepath.Join(root, "power.mp4"), Duration: 45 * time.Minute}, + } + + m.filters = filterState{name: "flow"} + m.applyFiltersAndSort() + + if len(m.filtered) != 2 { + t.Fatalf("expected 2 filtered videos, got %d", len(m.filtered)) + } + + modelAny, _ := m.selectRandomVideo() + m = modelAny.(model) + + cursor := m.table.Cursor() + selected := m.filtered[cursor] + if !strings.Contains(selected.Name, "flow") { + t.Fatalf("expected selected video to match filter, got %s", selected.Name) + } +} + +func TestSelectRandomVideoPluralityOfSelections(t *testing.T) { + root := t.TempDir() + m, err := newModel(Options{Root: root}) + if err != nil { + t.Fatalf("newModel: %v", err) + } + m.loading = false + videos := make([]video, 3) + rows := make([]table.Row, 3) + for i := 0; i < 3; i++ { + videos[i] = video{Name: fmt.Sprintf("yoga%d.mp4", i), Path: filepath.Join(root, fmt.Sprintf("yoga%d.mp4", i))} + rows[i] = videoRow(videos[i]) + } + m.filtered = videos + m.table.SetRows(rows) + + first, _ := m.selectRandomVideo() + firstModel := first.(model) + firstCursor := firstModel.table.Cursor() + + second, _ := firstModel.selectRandomVideo() + secondModel := second.(model) + secondCursor := secondModel.table.Cursor() + + if firstCursor == secondCursor { + third, _ := secondModel.selectRandomVideo() + thirdModel := third.(model) + thirdCursor := thirdModel.table.Cursor() + if secondCursor == thirdCursor { + t.Fatalf("expected randomness shown by at least some different selections") + } + } +} + +func TestHandleKeyMsgDispatchRandom(t *testing.T) { + m, err := newModel(Options{Root: t.TempDir()}) + if err != nil { + t.Fatalf("newModel: %v", err) + } + m.loading = false + m.filtered = []video{ + {Name: "a.mp4", Path: "a.mp4"}, + {Name: "b.mp4", Path: "b.mp4"}, + } + m.table.SetRows([]table.Row{videoRow(m.filtered[0]), videoRow(m.filtered[1])}) + + modelAny, cmd := m.handleKeyMsg(keyMsg("x")) + m = modelAny.(model) + + if cmd != nil { + t.Fatalf("expected no command for random selection") + } + if !strings.Contains(m.statusMessage, "Randomly selected") { + t.Fatalf("expected random selection triggered via dispatch") + } +} + +func TestHandleKeyMsgDispatchUnknownKey(t *testing.T) { + m, err := newModel(Options{Root: t.TempDir()}) + if err != nil { + t.Fatalf("newModel: %v", err) + } + m.loading = false + m.filtered = []video{{Name: "a.mp4", Path: "a.mp4"}} + m.table.SetRows([]table.Row{videoRow(m.filtered[0])}) + + modelAny, _ := m.handleKeyMsg(keyMsg("Z")) + _ = modelAny.(model) +} + +func TestHandleKeyMsgTableKeyWhileLoading(t *testing.T) { + m, err := newModel(Options{Root: t.TempDir()}) + if err != nil { + t.Fatalf("newModel: %v", err) + } + m.loading = true + + modelAny, cmd := m.handleKeyMsg(keyMsg("x")) + m = modelAny.(model) + + if cmd != nil { + t.Fatalf("expected no command while loading") + } + if !strings.Contains(m.statusMessage, "Scanning") { + t.Fatalf("should ignore key input while loading") + } +} + +func TestHandleReindexVideosCmd(t *testing.T) { + root := t.TempDir() + m, err := newModel(Options{Root: root}) + if err != nil { + t.Fatalf("newModel: %v", err) + } + m.loading = false + m.filtered = []video{{Name: "test.mp4", Path: filepath.Join(root, "test.mp4")}} + + modelAny, cmd := m.handleReindexVideos(reindexVideosMsg{}) + m = modelAny.(model) + + if cmd == nil { + t.Fatalf("expected command for re-index") + } + if !strings.Contains(m.statusMessage, "Re-indexing") { + t.Fatalf("expected re-indexing status, got %s", m.statusMessage) + } +} + +func TestRenderModalRendering(t *testing.T) { + m, err := newModel(Options{Root: t.TempDir()}) + if err != nil { + t.Fatalf("newModel: %v", err) + } + m.loading = false + m.editingTags = true + m.filtered = []video{{Name: "test.mp4", Path: "test.mp4", Tags: []string{"calm"}}} + + view := m.View() + if !strings.Contains(view, "Tags:") { + t.Fatalf("expected tag input in view") + } +} + +func TestSelectRandomVideoSingleItem(t *testing.T) { + root := t.TempDir() + m, err := newModel(Options{Root: root}) + if err != nil { + t.Fatalf("newModel: %v", err) + } + m.loading = false + m.filtered = []video{{Name: "only.mp4", Path: filepath.Join(root, "only.mp4")}} + m.table.SetRows([]table.Row{videoRow(m.filtered[0])}) + + modelAny, _ := m.selectRandomVideo() + m = modelAny.(model) + + if m.table.Cursor() != 0 { + t.Fatalf("expected cursor at 0 for single item") + } + if !strings.Contains(m.statusMessage, "only.mp4") { + t.Fatalf("expected status with video name") + } +} + +func TestHandleTableKeyAllShortcuts(t *testing.T) { + root := t.TempDir() + m, err := newModel(Options{Root: root}) + if err != nil { + t.Fatalf("newModel: %v", err) + } + m.loading = false + m.filtered = []video{{Name: "a.mp4", Path: filepath.Join(root, "a.mp4"), Duration: 10 * time.Minute}} + m.table.SetRows([]table.Row{videoRow(m.filtered[0])}) + + tests := []struct { + key string + fn func(model) model + }{ + {"n", func(m model) model { modelAny, _ := m.handleKeyMsg(keyMsg("n")); return modelAny.(model) }}, + {"l", func(m model) model { modelAny, _ := m.handleKeyMsg(keyMsg("l")); return modelAny.(model) }}, + {"a", func(m model) model { modelAny, _ := m.handleKeyMsg(keyMsg("a")); return modelAny.(model) }}, + {"r", func(m model) model { modelAny, _ := m.handleKeyMsg(keyMsg("r")); return modelAny.(model) }}, + } + + for _, test := range tests { + result := test.fn(m) + if result.statusMessage == "" { + t.Fatalf("expected status for key %s", test.key) + } + } +} + +func TestUpdateWithPlayVideoMsg(t *testing.T) { + m, err := newModel(Options{Root: t.TempDir()}) + if err != nil { + t.Fatalf("newModel: %v", err) + } + m.loading = false + + modelAny, _ := m.Update(playVideoMsg{path: "video.mp4"}) + m = modelAny.(model) + + if !strings.Contains(m.statusMessage, "Playing") { + t.Fatalf("expected playing status") + } +} + +func TestUpdateWithWindowSizeMsg(t *testing.T) { + m, err := newModel(Options{Root: t.TempDir()}) + if err != nil { + t.Fatalf("newModel: %v", err) + } + m.loading = false + + modelAny, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + m = modelAny.(model) + + if m.viewportWidth != 100 { + t.Fatalf("expected viewport width updated") + } +} + +func TestUpdateWithTagsSavedMsg(t *testing.T) { + root := t.TempDir() + m, err := newModel(Options{Root: root}) + if err != nil { + t.Fatalf("newModel: %v", err) + } + m.loading = false + videoPath := filepath.Join(root, "test.mp4") + m.videos = []video{{Name: "test.mp4", Path: videoPath}} + m.filtered = m.videos + + modelAny, _ := m.Update(tagsSavedMsg{path: videoPath, tags: []string{"new"}, err: nil}) + m = modelAny.(model) + + if !strings.Contains(m.statusMessage, "Tags updated") { + t.Fatalf("expected tags updated message") + } +} + +func TestUpdateKeyMsgRouting(t *testing.T) { + m, err := newModel(Options{Root: t.TempDir()}) + if err != nil { + t.Fatalf("newModel: %v", err) + } + m.loading = false + m.filtered = []video{{Name: "a.mp4", Path: "a.mp4"}} + m.table.SetRows([]table.Row{videoRow(m.filtered[0])}) + + modelAny, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")}) + m = modelAny.(model) + + if !strings.Contains(m.statusMessage, "Randomly selected") { + t.Fatalf("expected key message routed to handler") + } +} + +func TestSelectRandomVideoIntegrationWithFilter(t *testing.T) { + root := t.TempDir() + m, err := newModel(Options{Root: root}) + if err != nil { + t.Fatalf("newModel: %v", err) + } + m.loading = false + m.videos = []video{ + {Name: "morning1.mp4", Path: filepath.Join(root, "morning1.mp4"), Duration: 15 * time.Minute}, + {Name: "morning2.mp4", Path: filepath.Join(root, "morning2.mp4"), Duration: 25 * time.Minute}, + {Name: "evening1.mp4", Path: filepath.Join(root, "evening1.mp4"), Duration: 45 * time.Minute}, + } + + m.filters = filterState{minEnabled: true, minMinutes: 20} + m.applyFiltersAndSort() + + if len(m.filtered) != 2 { + t.Fatalf("expected 2 filtered videos") + } + + modelAny, _ := m.selectRandomVideo() + m = modelAny.(model) + + selected := m.filtered[m.table.Cursor()] + if selected.Duration < 20*time.Minute { + t.Fatalf("expected selected video to respect filter") + } +} + +func TestDurationCacheRecord(t *testing.T) { + tmpDir := t.TempDir() + videoPath := filepath.Join(tmpDir, "video.mp4") + if err := os.WriteFile(videoPath, []byte("test"), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + + cacheFile := filepath.Join(tmpDir, "cache.json") + cache := newDurationCache(cacheFile) + + info, err := os.Stat(videoPath) + if err != nil { + t.Fatalf("stat: %v", err) + } + + if err := cache.Record(videoPath, info, 5*time.Minute); err != nil { + t.Fatalf("Record: %v", err) + } + + result, ok := cache.Lookup(videoPath, info) + if !ok || result != 5*time.Minute { + t.Fatalf("expected duration to be recorded and retrieved") + } +} + +func TestApplyFilterInputsCoverage(t *testing.T) { + root := t.TempDir() + m, err := newModel(Options{Root: root}) + if err != nil { + t.Fatalf("newModel: %v", err) + } + m.inputs.fields[0].SetValue("test") + m.inputs.fields[1].SetValue("5") + m.inputs.fields[2].SetValue("10") + m.inputs.fields[3].SetValue("tag") + + if err := m.applyFilterInputs(); err != nil { + t.Fatalf("applyFilterInputs: %v", err) + } + if m.filters.name != "test" || m.filters.minMinutes != 5 || m.filters.maxMinutes != 10 || m.filters.tags != "tag" { + t.Fatalf("expected all filter fields populated") + } +} + +func TestHideHelpBar(t *testing.T) { + m, err := newModel(Options{Root: t.TempDir()}) + if err != nil { + t.Fatalf("newModel: %v", err) + } + if !m.showHelp { + t.Fatalf("expected help to start shown") + } + + modelAny, _ := m.hideHelpBar() + m = modelAny.(model) + + if m.showHelp { + t.Fatalf("expected help to be hidden") + } +} + +func TestCanSelectRandomWhenCached(t *testing.T) { + root := t.TempDir() + m, err := newModel(Options{Root: root}) + if err != nil { + t.Fatalf("newModel: %v", err) + } + m.loading = false + m.filtered = make([]video, 20) + for i := 0; i < 20; i++ { + m.filtered[i] = video{Name: fmt.Sprintf("v%d.mp4", i), Path: fmt.Sprintf("path%d", i)} + } + + rows := make([]table.Row, 20) + for i := 0; i < 20; i++ { + rows[i] = videoRow(m.filtered[i]) + } + m.table.SetRows(rows) + + m.table.SetCursor(0) + modelAny, _ := m.selectRandomVideo() + m = modelAny.(model) + + if m.table.Cursor() == 0 { + t.Fatalf("expected cursor to move to random position") + } +} Binary files differ |
