From b1c2e1a3205efd4a965e55f02741734cffc87672 Mon Sep 17 00:00:00 2001 From: repparw Date: Wed, 9 Sep 2026 17:36:02 -0300 Subject: [PATCH 1/4] feat: copy share link for the playing track Adds spotify-player CopyLink parity: a `cliamp share` IPC subcommand that prints a shareable link for the current track (plus --copy for the clipboard), and a Ctrl+Y client binding with in-app feedback. playlist.ShareLink derives links from the track path: Spotify URIs map to open.spotify.com pages (track/episode/album/playlist/artist/show), plain http(s) URLs pass through, and anything without a public page (local files, yt-dlp search expressions) reports no link. internal/ clipboard resolves wl-copy/xclip/xsel/pbcopy/clip at call time with no new dependencies; callers fall back to showing the text. y is taken by lyrics and Y opens the YouTube provider, hence Ctrl+Y (right next to Ctrl+S save). No default-plain-key claim. Closes #456 --- commands.go | 33 ++++++++++++ docs/cli.md | 2 + docs/keybindings.md | 1 + internal/clipboard/clipboard.go | 91 +++++++++++++++++++++++++++++++++ playlist/sharelink.go | 39 ++++++++++++++ playlist/sharelink_test.go | 81 +++++++++++++++++++++++++++++ ui/model/command_registry.go | 1 + ui/model/keys.go | 26 ++++++++++ 8 files changed, 274 insertions(+) create mode 100644 internal/clipboard/clipboard.go create mode 100644 playlist/sharelink.go create mode 100644 playlist/sharelink_test.go diff --git a/commands.go b/commands.go index dd167d30e..c0b82884b 100644 --- a/commands.go +++ b/commands.go @@ -17,8 +17,10 @@ import ( "github.com/bjarneo/cliamp/external/qobuz" "github.com/bjarneo/cliamp/external/spotify" "github.com/bjarneo/cliamp/external/tidal" + "github.com/bjarneo/cliamp/internal/clipboard" "github.com/bjarneo/cliamp/ipc" "github.com/bjarneo/cliamp/player" + "github.com/bjarneo/cliamp/playlist" "github.com/bjarneo/cliamp/pluginmgr" "github.com/bjarneo/cliamp/theme" "github.com/bjarneo/cliamp/ui" @@ -83,6 +85,7 @@ func buildApp() *cli.Command { ipcSimpleCommand("prev", "previous track"), ipcSimpleCommand("stop", "stop playback"), statusCommand(), + shareCommand(), volumeCommand(), seekCommand(), loadCommand(), @@ -757,6 +760,36 @@ func statusCommand() *cli.Command { } } +func shareCommand() *cli.Command { + return &cli.Command{ + Name: "share", + Usage: "print a shareable link for the playing track", + Flags: []cli.Flag{ + &cli.BoolFlag{Name: "copy", Usage: "copy the link to the clipboard as well"}, + }, + Action: func(ctx context.Context, c *cli.Command) error { + snapshot, err := ipcState() + if err != nil { + return err + } + if snapshot.Track == nil || snapshot.Track.Path == "" { + return fmt.Errorf("nothing playing") + } + link, ok := playlist.ShareLink(snapshot.Track.Path) + if !ok { + return fmt.Errorf("no shareable link for the current track") + } + if c.Bool("copy") { + if err := clipboard.Copy(link); err != nil { + return fmt.Errorf("copy to clipboard: %w", err) + } + } + fmt.Println(link) + return nil + }, + } +} + func volumeCommand() *cli.Command { return &cli.Command{ Name: "volume", diff --git a/docs/cli.md b/docs/cli.md index 5be74fe60..54e9eda66 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -232,6 +232,8 @@ cliamp play / pause / toggle / stop # playback control cliamp next / prev # track navigation cliamp status # current state cliamp status --json # machine-readable state +cliamp share # print a shareable link for the playing track +cliamp share --copy # print it and copy it to the clipboard cliamp volume -5 # adjust volume (dB) cliamp seek 30 # seek relative to current position (seconds) cliamp load "Playlist Name" # load a playlist diff --git a/docs/keybindings.md b/docs/keybindings.md index 63dc04cf9..47c2e1971 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -105,6 +105,7 @@ and `Esc` clears it. | `i` | From the playlist, open full info for the highlighted item, including Path (`Up`/`Down` or `j`/`k` scroll; `i`/`Esc` closes) | | `Ctrl+I` | Toggle Metadata below Settings for the highlighted playlist item (remembered in `show_metadata`; requires a terminal that distinguishes Ctrl+I from Tab) | | `Ctrl+S` | Save track to `~/Music/cliamp` | +| `Ctrl+Y` | Copy a shareable link for the playing track (Spotify URIs become open.spotify.com pages, URLs pass through; local files report no link) | | `w` | Write the highlighted track to a local playlist | | `N` | Open the active provider browser. On a selected Mixcloud show, open that creator's Uploads/Favorites. In the radio pane, open the country browser. | | `L` | Browse local playlists (with cliamp radio) | diff --git a/internal/clipboard/clipboard.go b/internal/clipboard/clipboard.go new file mode 100644 index 000000000..41d933ba0 --- /dev/null +++ b/internal/clipboard/clipboard.go @@ -0,0 +1,91 @@ +// Package clipboard copies text to the system clipboard using whatever +// backend the platform provides. It is deliberately dependency-free: +// backends are resolved with exec.LookPath at call time so headless and +// minimal installs simply report an error instead of failing to build. +package clipboard + +import ( + "fmt" + "os" + "os/exec" + "runtime" + "strings" +) + +// Copy writes text to the system clipboard. It returns an error when no +// supported backend is installed; callers should fall back to showing the +// text (footer message, stdout) in that case. +func Copy(text string) error { + candidates, err := backends() + if err != nil { + return err + } + var lastErr error + for _, c := range candidates { + if _, err := exec.LookPath(c.name); err != nil { + continue + } + cmd := exec.Command(c.name, c.args...) + cmd.Stdin = strings.NewReader(text) + // Clipboard backends daemonize (wl-copy and xclip fork to + // keep serving the selection). Capturing their output would + // hold the pipes open until the daemon exits, so Run with + // discarded output instead of CombinedOutput. + cmd.Stdout = nil + cmd.Stderr = nil + if err := cmd.Run(); err != nil { + lastErr = fmt.Errorf("%s: %w", c.name, err) + continue + } + return nil + } + if lastErr != nil { + return lastErr + } + return fmt.Errorf("no clipboard backend found (%s)", backendNames(candidates)) +} + +type backend struct { + name string + args []string +} + +func backends() ([]backend, error) { + switch runtime.GOOS { + case "darwin": + return []backend{{name: "pbcopy"}}, nil + case "windows": + return []backend{{name: "clip"}}, nil + default: + // Wayland first when a Wayland session is present, then X11. + // WSLg sets WAYLAND_DISPLAY; plain WSL falls through to the error, + // where the caller prints the text instead. + if isWayland() { + return []backend{ + {name: "wl-copy"}, + {name: "xclip", args: []string{"-selection", "clipboard"}}, + {name: "xsel", args: []string{"--clipboard", "--input"}}, + }, nil + } + return []backend{ + {name: "xclip", args: []string{"-selection", "clipboard"}}, + {name: "xsel", args: []string{"--clipboard", "--input"}}, + {name: "wl-copy"}, + }, nil + } +} + +func isWayland() bool { + if os.Getenv("WAYLAND_DISPLAY") != "" { + return true + } + return strings.EqualFold(os.Getenv("XDG_SESSION_TYPE"), "wayland") +} + +func backendNames(candidates []backend) string { + names := make([]string, 0, len(candidates)) + for _, c := range candidates { + names = append(names, c.name) + } + return strings.Join(names, ", ") +} diff --git a/playlist/sharelink.go b/playlist/sharelink.go new file mode 100644 index 000000000..afc102991 --- /dev/null +++ b/playlist/sharelink.go @@ -0,0 +1,39 @@ +package playlist + +import "strings" + +// ShareLink derives a shareable https URL for a track path. +// +// - Spotify URIs (spotify::) map to the corresponding +// open.spotify.com page, e.g. spotify:track: becomes +// https://open.spotify.com/track/. +// - Plain http(s) URLs are returned unchanged, since they are already +// links (radio streams, direct files, video pages). +// +// Anything else (local files, yt-dlp search expressions and other +// pseudo-protocols, provider URIs without a public page) has no shareable +// form and reports false. +func ShareLink(path string) (string, bool) { + if path == "" { + return "", false + } + if rest, ok := strings.CutPrefix(path, "spotify:"); ok { + typ, id, ok := strings.Cut(rest, ":") + if !ok || typ == "" || id == "" || strings.Contains(id, ":") { + return "", false + } + switch typ { + case "track", "episode", "album", "playlist", "artist", "show": + return "https://open.spotify.com/" + typ + "/" + id, true + default: + return "", false + } + } + if IsYTSearch(path) { + return "", false + } + if IsURL(path) { + return path, true + } + return "", false +} diff --git a/playlist/sharelink_test.go b/playlist/sharelink_test.go new file mode 100644 index 000000000..991e7a6f9 --- /dev/null +++ b/playlist/sharelink_test.go @@ -0,0 +1,81 @@ +package playlist + +import "testing" + +func TestShareLink(t *testing.T) { + tests := []struct { + name string + path string + want string + share bool + }{ + { + name: "spotify track", + path: "spotify:track:5FFTCVlkmd78TIw9mTfDUP", + want: "https://open.spotify.com/track/5FFTCVlkmd78TIw9mTfDUP", + share: true, + }, + { + name: "spotify episode", + path: "spotify:episode:4rOoJ6Egrf8K2IrywzwOMk", + want: "https://open.spotify.com/episode/4rOoJ6Egrf8K2IrywzwOMk", + share: true, + }, + { + name: "spotify album", + path: "spotify:album:6akEvsycLGftJxsoqdWqaw", + want: "https://open.spotify.com/album/6akEvsycLGftJxsoqdWqaw", + share: true, + }, + { + name: "spotify truncated id", + path: "spotify:track:", + want: "", + share: false, + }, + { + name: "spotify unknown type", + path: "spotify:user:repparw", + want: "", + share: false, + }, + { + name: "https stream", + path: "http://radio.cliamp.stream/lofi/stream", + want: "http://radio.cliamp.stream/lofi/stream", + share: true, + }, + { + name: "youtube page", + path: "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + want: "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + share: true, + }, + { + name: "yt-dlp search is not a link", + path: "ytsearch:never gonna give you up", + want: "", + share: false, + }, + { + name: "local file", + path: "/home/me/Music/song.flac", + want: "", + share: false, + }, + { + name: "empty", + path: "", + want: "", + share: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := ShareLink(tt.path) + if ok != tt.share || got != tt.want { + t.Fatalf("ShareLink(%q) = (%q, %v), want (%q, %v)", tt.path, got, ok, tt.want, tt.share) + } + }) + } +} diff --git a/ui/model/command_registry.go b/ui/model/command_registry.go index 6aaa44d1c..ea601ff42 100644 --- a/ui/model/command_registry.go +++ b/ui/model/command_registry.go @@ -147,6 +147,7 @@ var commandRegistry = []commandSpec{ {Mode: commandModeMain, Keys: []string{"i"}, KeyLabel: "i", Label: "Track info / metadata", Keymap: true, ContextHelp: true}, {Mode: commandModeMain | commandModeInfo, Keys: []string{"ctrl+i"}, KeyLabel: "Ctrl+I", Label: "Metadata", Keymap: true, ContextHelp: true}, {Mode: commandModeMain, Keys: []string{"ctrl+s"}, KeyLabel: "Ctrl+S", Label: "Save/download track to ~/Music/cliamp", Keymap: true}, + {Mode: commandModeMain, Keys: []string{"ctrl+y"}, KeyLabel: "Ctrl+Y", Label: "Copy share link for the playing track", Keymap: true}, {Mode: commandModeMain, Keys: []string{"ctrl+x"}, KeyLabel: "Ctrl+X", Label: "Expand/collapse view", Enabled: func(m Model) bool { return !m.simplified }, Keymap: true}, {Mode: commandModeMain, Keys: []string{"ctrl+x"}, KeyLabel: "Ctrl+X", Label: "Expand", Enabled: func(m Model) bool { return !m.simplified && !m.heightExpanded && m.layout.bodyRows > m.plVisible diff --git a/ui/model/keys.go b/ui/model/keys.go index 80f2934cc..38a4ed994 100644 --- a/ui/model/keys.go +++ b/ui/model/keys.go @@ -14,6 +14,7 @@ import ( "github.com/bjarneo/cliamp/favorites" "github.com/bjarneo/cliamp/history" + "github.com/bjarneo/cliamp/internal/clipboard" "github.com/bjarneo/cliamp/internal/fileutil" "github.com/bjarneo/cliamp/playlist" "github.com/bjarneo/cliamp/provider" @@ -858,6 +859,8 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd { case "ctrl+s": return m.saveTrack() + case "ctrl+y": + return m.shareTrack() case "S": return m.switchToProvider("spotify") @@ -1123,6 +1126,29 @@ func (m *Model) saveTrack() tea.Cmd { return nil } +// shareTrack copies a shareable link for the playing track to the clipboard. +// Spotify URIs become open.spotify.com pages and plain URLs pass through; +// anything without a public link (local files, search expressions) reports +// why instead of copying. +func (m *Model) shareTrack() tea.Cmd { + track, idx := m.currentPlaybackTrack() + if idx < 0 { + m.status.Warning("Nothing to share", statusTTLShort) + return nil + } + link, ok := playlist.ShareLink(track.Path) + if !ok { + m.status.Warning("No shareable link for this track", statusTTLShort) + return nil + } + if err := clipboard.Copy(link); err != nil { + m.status.Errorf(statusTTLShort, "Copy failed: %s", err) + return nil + } + m.status.Successf(statusTTLShort, "Link copied: %s", link) + return nil +} + func (m *Model) resetJumpInput() { m.jumpInput = "" m.jumpErr = "" From a9018190460c71808b592ce99a2df5efd4573f95 Mon Sep 17 00:00:00 2001 From: repparw Date: Wed, 9 Sep 2026 18:17:35 -0300 Subject: [PATCH 2/4] fix(share): address review findings on link fallback, clipboard timeout, id escaping - Print the link before reporting a --copy failure, and include it in the TUI footer fallback, so headless and clipboard-less setups keep the usable result. - Bound clipboard backends with a 5s CommandContext deadline so a stalled backend cannot freeze the TUI update path. - Reject Spotify ids containing reserved URL chars and PathEscape the rest, with a regression case. Add a no-backend clipboard test. --- commands.go | 1 + internal/clipboard/clipboard.go | 22 ++++++++++++++++++---- internal/clipboard/clipboard_test.go | 15 +++++++++++++++ playlist/sharelink.go | 9 ++++++--- playlist/sharelink_test.go | 6 ++++++ ui/model/keys.go | 2 +- 6 files changed, 47 insertions(+), 8 deletions(-) create mode 100644 internal/clipboard/clipboard_test.go diff --git a/commands.go b/commands.go index c0b82884b..268ae5e5a 100644 --- a/commands.go +++ b/commands.go @@ -781,6 +781,7 @@ func shareCommand() *cli.Command { } if c.Bool("copy") { if err := clipboard.Copy(link); err != nil { + fmt.Println(link) return fmt.Errorf("copy to clipboard: %w", err) } } diff --git a/internal/clipboard/clipboard.go b/internal/clipboard/clipboard.go index 41d933ba0..4677ee779 100644 --- a/internal/clipboard/clipboard.go +++ b/internal/clipboard/clipboard.go @@ -5,27 +5,35 @@ package clipboard import ( + "context" "fmt" "os" "os/exec" "runtime" "strings" + "time" ) +// copyTimeout bounds a single backend invocation. Backends normally exit +// at once; the deadline only fires when one stalls (locked Wayland socket, +// wedged X server), which must not freeze the TUI update path. +const copyTimeout = 5 * time.Second + // Copy writes text to the system clipboard. It returns an error when no // supported backend is installed; callers should fall back to showing the // text (footer message, stdout) in that case. func Copy(text string) error { candidates, err := backends() if err != nil { - return err + return fmt.Errorf("resolve clipboard backends: %w", err) } var lastErr error for _, c := range candidates { if _, err := exec.LookPath(c.name); err != nil { continue } - cmd := exec.Command(c.name, c.args...) + ctx, cancel := context.WithTimeout(context.Background(), copyTimeout) + cmd := exec.CommandContext(ctx, c.name, c.args...) cmd.Stdin = strings.NewReader(text) // Clipboard backends daemonize (wl-copy and xclip fork to // keep serving the selection). Capturing their output would @@ -33,8 +41,14 @@ func Copy(text string) error { // discarded output instead of CombinedOutput. cmd.Stdout = nil cmd.Stderr = nil - if err := cmd.Run(); err != nil { - lastErr = fmt.Errorf("%s: %w", c.name, err) + runErr := cmd.Run() + cancel() + if ctx.Err() == context.DeadlineExceeded { + lastErr = fmt.Errorf("%s: timed out after %s", c.name, copyTimeout) + continue + } + if runErr != nil { + lastErr = fmt.Errorf("%s: %w", c.name, runErr) continue } return nil diff --git a/internal/clipboard/clipboard_test.go b/internal/clipboard/clipboard_test.go new file mode 100644 index 000000000..ec3b8d78f --- /dev/null +++ b/internal/clipboard/clipboard_test.go @@ -0,0 +1,15 @@ +package clipboard + +import ( + "os" + "testing" +) + +func TestCopyNoBackend(t *testing.T) { + oldPath := os.Getenv("PATH") + os.Setenv("PATH", "/nonexistent-dir-for-test") + defer os.Setenv("PATH", oldPath) + if err := Copy("x"); err == nil { + t.Fatal("expected error with no backends on PATH") + } +} diff --git a/playlist/sharelink.go b/playlist/sharelink.go index afc102991..b70d0e95a 100644 --- a/playlist/sharelink.go +++ b/playlist/sharelink.go @@ -1,6 +1,9 @@ package playlist -import "strings" +import ( + "net/url" + "strings" +) // ShareLink derives a shareable https URL for a track path. // @@ -19,12 +22,12 @@ func ShareLink(path string) (string, bool) { } if rest, ok := strings.CutPrefix(path, "spotify:"); ok { typ, id, ok := strings.Cut(rest, ":") - if !ok || typ == "" || id == "" || strings.Contains(id, ":") { + if !ok || typ == "" || id == "" || strings.ContainsAny(id, ":/?#") { return "", false } switch typ { case "track", "episode", "album", "playlist", "artist", "show": - return "https://open.spotify.com/" + typ + "/" + id, true + return "https://open.spotify.com/" + typ + "/" + url.PathEscape(id), true default: return "", false } diff --git a/playlist/sharelink_test.go b/playlist/sharelink_test.go index 991e7a6f9..cac0a387a 100644 --- a/playlist/sharelink_test.go +++ b/playlist/sharelink_test.go @@ -39,6 +39,12 @@ func TestShareLink(t *testing.T) { want: "", share: false, }, + { + name: "spotify id with reserved chars rejected", + path: "spotify:track:abc/def?x#y", + want: "", + share: false, + }, { name: "https stream", path: "http://radio.cliamp.stream/lofi/stream", diff --git a/ui/model/keys.go b/ui/model/keys.go index 38a4ed994..5b5485a5d 100644 --- a/ui/model/keys.go +++ b/ui/model/keys.go @@ -1142,7 +1142,7 @@ func (m *Model) shareTrack() tea.Cmd { return nil } if err := clipboard.Copy(link); err != nil { - m.status.Errorf(statusTTLShort, "Copy failed: %s", err) + m.status.Errorf(statusTTLShort, "Copy failed, link: %s (%s)", link, err) return nil } m.status.Successf(statusTTLShort, "Link copied: %s", link) From 9b81926b96ca3ea5a24946db5e6d32462d02a125 Mon Sep 17 00:00:00 2001 From: repparw Date: Wed, 9 Sep 2026 18:42:28 -0300 Subject: [PATCH 3/4] fix(share): run clipboard copy off the TUI update path shareTrack returned only after clipboard.Copy finished, so a stalled backend froze input and rendering for up to 5s per backend. Return a tea.Cmd that copies in the background and report through shareCopiedMsg instead, following the ytdlSavedMsg pattern. Success and failure footer text unchanged. Adds Update-level tests for both outcomes. --- ui/model/commands.go | 15 +++++++++++++++ ui/model/keys.go | 9 ++------- ui/model/share_test.go | 41 +++++++++++++++++++++++++++++++++++++++++ ui/model/update.go | 8 ++++++++ 4 files changed, 66 insertions(+), 7 deletions(-) create mode 100644 ui/model/share_test.go diff --git a/ui/model/commands.go b/ui/model/commands.go index ae80f6176..9c819dd1b 100644 --- a/ui/model/commands.go +++ b/ui/model/commands.go @@ -8,6 +8,7 @@ import ( tea "charm.land/bubbletea/v2" "github.com/bjarneo/cliamp/history" + "github.com/bjarneo/cliamp/internal/clipboard" "github.com/bjarneo/cliamp/internal/playback" "github.com/bjarneo/cliamp/lyrics" "github.com/bjarneo/cliamp/player" @@ -136,6 +137,20 @@ type ytdlSavedMsg struct { err error } +// shareCopiedMsg carries the result of an async clipboard copy back to the +// update loop, so a stalled backend never blocks input handling. The link +// rides along so a failed copy still shows the usable result. +type shareCopiedMsg struct { + link string + err error +} + +func shareCopyCmd(link string) tea.Cmd { + return func() tea.Msg { + return shareCopiedMsg{link: link, err: clipboard.Copy(link)} + } +} + // — Navidrome browser message types — // navArtistsLoadedMsg carries the full artist list from a provider browser. diff --git a/ui/model/keys.go b/ui/model/keys.go index 5b5485a5d..712e88b47 100644 --- a/ui/model/keys.go +++ b/ui/model/keys.go @@ -14,7 +14,6 @@ import ( "github.com/bjarneo/cliamp/favorites" "github.com/bjarneo/cliamp/history" - "github.com/bjarneo/cliamp/internal/clipboard" "github.com/bjarneo/cliamp/internal/fileutil" "github.com/bjarneo/cliamp/playlist" "github.com/bjarneo/cliamp/provider" @@ -1141,12 +1140,8 @@ func (m *Model) shareTrack() tea.Cmd { m.status.Warning("No shareable link for this track", statusTTLShort) return nil } - if err := clipboard.Copy(link); err != nil { - m.status.Errorf(statusTTLShort, "Copy failed, link: %s (%s)", link, err) - return nil - } - m.status.Successf(statusTTLShort, "Link copied: %s", link) - return nil + m.status.Clear() + return shareCopyCmd(link) } func (m *Model) resetJumpInput() { diff --git a/ui/model/share_test.go b/ui/model/share_test.go new file mode 100644 index 000000000..405c281f3 --- /dev/null +++ b/ui/model/share_test.go @@ -0,0 +1,41 @@ +package model + +import ( + "errors" + "testing" +) + +func TestShareCopiedMsgSuccess(t *testing.T) { + m := Model{} + link := "https://open.spotify.com/track/abc123" + + nextModel, cmd := m.Update(shareCopiedMsg{link: link}) + if cmd != nil { + t.Fatalf("Update() cmd = %v, want nil", cmd) + } + next, ok := nextModel.(Model) + if !ok { + t.Fatalf("Update() model = %T, want ui.Model", nextModel) + } + if got := next.status.text; got != "Link copied: "+link { + t.Fatalf("status.text after shareCopiedMsg = %q, want %q", got, "Link copied: "+link) + } +} + +func TestShareCopiedMsgFailureKeepsLink(t *testing.T) { + m := Model{} + link := "https://open.spotify.com/track/abc123" + + nextModel, cmd := m.Update(shareCopiedMsg{link: link, err: errors.New("no clipboard backend found")}) + if cmd != nil { + t.Fatalf("Update() cmd = %v, want nil", cmd) + } + next, ok := nextModel.(Model) + if !ok { + t.Fatalf("Update() model = %T, want ui.Model", nextModel) + } + want := "Copy failed, link: " + link + " (no clipboard backend found)" + if got := next.status.text; got != want { + t.Fatalf("status.text after failed shareCopiedMsg = %q, want %q", got, want) + } +} diff --git a/ui/model/update.go b/ui/model/update.go index b91ca24c5..f58e87f39 100644 --- a/ui/model/update.go +++ b/ui/model/update.go @@ -772,6 +772,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil + case shareCopiedMsg: + if msg.err != nil { + m.status.Errorf(statusTTLShort, "Copy failed, link: %s (%s)", msg.link, msg.err) + } else { + m.status.Successf(statusTTLShort, "Link copied: %s", msg.link) + } + return m, nil + case ytdlResolvedMsg: m.buffering = false if msg.err != nil { From cf818e5beb44a0385b2635cde163b86b71895752 Mon Sep 17 00:00:00 2001 From: repparw Date: Wed, 9 Sep 2026 18:52:37 -0300 Subject: [PATCH 4/4] test(share): table-drive shareCopiedMsg outcomes --- ui/model/share_test.go | 61 +++++++++++++++++++++--------------------- 1 file changed, 31 insertions(+), 30 deletions(-) diff --git a/ui/model/share_test.go b/ui/model/share_test.go index 405c281f3..47557e09d 100644 --- a/ui/model/share_test.go +++ b/ui/model/share_test.go @@ -5,37 +5,38 @@ import ( "testing" ) -func TestShareCopiedMsgSuccess(t *testing.T) { - m := Model{} +func TestShareCopiedMsg(t *testing.T) { link := "https://open.spotify.com/track/abc123" - - nextModel, cmd := m.Update(shareCopiedMsg{link: link}) - if cmd != nil { - t.Fatalf("Update() cmd = %v, want nil", cmd) - } - next, ok := nextModel.(Model) - if !ok { - t.Fatalf("Update() model = %T, want ui.Model", nextModel) - } - if got := next.status.text; got != "Link copied: "+link { - t.Fatalf("status.text after shareCopiedMsg = %q, want %q", got, "Link copied: "+link) - } -} - -func TestShareCopiedMsgFailureKeepsLink(t *testing.T) { - m := Model{} - link := "https://open.spotify.com/track/abc123" - - nextModel, cmd := m.Update(shareCopiedMsg{link: link, err: errors.New("no clipboard backend found")}) - if cmd != nil { - t.Fatalf("Update() cmd = %v, want nil", cmd) - } - next, ok := nextModel.(Model) - if !ok { - t.Fatalf("Update() model = %T, want ui.Model", nextModel) + tests := []struct { + name string + msg shareCopiedMsg + want string + }{ + { + name: "success", + msg: shareCopiedMsg{link: link}, + want: "Link copied: " + link, + }, + { + name: "failure keeps link", + msg: shareCopiedMsg{link: link, err: errors.New("no clipboard backend found")}, + want: "Copy failed, link: " + link + " (no clipboard backend found)", + }, } - want := "Copy failed, link: " + link + " (no clipboard backend found)" - if got := next.status.text; got != want { - t.Fatalf("status.text after failed shareCopiedMsg = %q, want %q", got, want) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := Model{} + nextModel, cmd := m.Update(tt.msg) + if cmd != nil { + t.Fatalf("Update() cmd = %v, want nil", cmd) + } + next, ok := nextModel.(Model) + if !ok { + t.Fatalf("Update() model = %T, want ui.Model", nextModel) + } + if got := next.status.text; got != tt.want { + t.Fatalf("status.text = %q, want %q", got, tt.want) + } + }) } }