refactor(utils): split componentHelpers grab-bag to owning layers

Per-file routing of the 9-file utils/componentHelpers grab-bag, each driven by
its verified consumer set:
- contextMenu{Actions,Helpers} → features/contextMenu/utils (sole consumer)
- queuePanelHelpers → features/queue/utils (sole consumer)
- nowPlayingHelpers → features/nowPlaying/utils; isRealArtistImage split out to
  cover/ (consumed by cover/artistHero, a lower layer)
- appUpdaterHelpers(+test) + listReorder(+test) → lib/util (config/ + lib/hooks
  consume them; pure)
- playlistDetailHelpers → lib/format (consumed by 3 features; pure)
- appShellHelpers → app/ (app-shell consumers only)
- userMgmtHelpers left in place: consumed by the a11y-HELD SongInfoModal, so a
  move would rewrite a do-not-touch file (same block as licensesData).

Pure moves; tests pass unmodified.
This commit is contained in:
Psychotoxical
2026-06-30 21:05:16 +02:00
parent f43dcc0e76
commit fbca1831a1
46 changed files with 56 additions and 56 deletions
+1 -1
View File
@@ -70,7 +70,7 @@ import { usePerfProbeFlags } from '@/lib/perf/perfFlags';
import {
persistSidebarCollapsed,
readInitialSidebarCollapsed,
} from '../utils/componentHelpers/appShellHelpers';
} from '@/app/appShellHelpers';
/**
* The main webview's persistent layout: titlebar (Linux + macOS) + sidebar +
+1 -1
View File
@@ -1,7 +1,7 @@
import React from 'react';
import type { TFunction } from 'i18next';
import { PanelRight, PanelRightClose } from 'lucide-react';
import { shouldSuppressQueueResizerMouseDown } from '@/utils/componentHelpers/appShellHelpers';
import { shouldSuppressQueueResizerMouseDown } from '@/app/appShellHelpers';
interface Props {
isQueueVisible: boolean;
@@ -1,4 +1,4 @@
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../../constants/appScroll';
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '@/constants/appScroll';
export const SIDEBAR_COLLAPSED_STORAGE_KEY = 'psysonic_sidebar_collapsed';
+1 -1
View File
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import { CONTRIBUTORS } from './settingsCredits';
import { isNewer } from '../utils/componentHelpers/appUpdaterHelpers';
import { isNewer } from '@/lib/util/appUpdaterHelpers';
describe('CONTRIBUTORS ordering', () => {
it('is sorted ascending by the `since` app version', () => {
+1 -1
View File
@@ -1,4 +1,4 @@
import { isNewer } from '../utils/componentHelpers/appUpdaterHelpers';
import { isNewer } from '@/lib/util/appUpdaterHelpers';
// Credits list rendered on the Settings → System tab. Update via PR when adding a contributor.
const CONTRIBUTOR_ENTRIES = [
+1 -1
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState, type CSSProperties, type ImgHTMLAttributes } from 'react';
import type { SubsonicArtistInfo } from '@/lib/api/subsonicTypes';
import { isRealArtistImage } from '../utils/componentHelpers/nowPlayingHelpers';
import { isRealArtistImage } from './isRealArtistImage';
import { CoverArtImage } from './CoverArtImage';
import type { CoverArtRef, CoverSurfaceKind } from './types';
+10
View File
@@ -0,0 +1,10 @@
/**
* Filter out the well-known Last.fm "no image" placeholder that Subsonic
* backends aggregate into `largeImageUrl`/`mediumImageUrl` when no real
* artist image exists. The placeholder MD5 is fixed and documented.
*/
export function isRealArtistImage(url?: string): boolean {
if (!url) return false;
if (url.includes('2a96cbd8b46e442fc41c2b86b821562f')) return false;
return true;
}
@@ -8,7 +8,7 @@ import { showToast } from '@/lib/dom/toast';
import {
confirmAddAllDuplicates,
isSmartPlaylistName,
} from '@/utils/componentHelpers/contextMenuHelpers';
} from '@/features/contextMenu/utils/contextMenuHelpers';
interface Props {
songIds: string[];
@@ -12,7 +12,7 @@ import {
downloadAlbum as downloadAlbumAction,
startInstantMix as startInstantMixAction,
startRadio as startRadioAction,
} from '@/utils/componentHelpers/contextMenuActions';
} from '@/features/contextMenu/utils/contextMenuActions';
import { useContextMenuKeyboardNav } from '@/features/contextMenu/hooks/useContextMenuKeyboardNav';
import { useContextMenuRating } from '@/features/contextMenu/hooks/useContextMenuRating';
import { usePlaybackLibraryNavigate } from '@/features/playback/hooks/usePlaybackLibraryNavigate';
@@ -8,7 +8,7 @@ import { showToast } from '@/lib/dom/toast';
import {
confirmAddAllDuplicates,
isSmartPlaylistName,
} from '@/utils/componentHelpers/contextMenuHelpers';
} from '@/features/contextMenu/utils/contextMenuHelpers';
interface Props {
albumIds: string[];
@@ -9,7 +9,7 @@ import { showToast } from '@/lib/dom/toast';
import {
confirmAddAllDuplicates,
isSmartPlaylistName,
} from '@/utils/componentHelpers/contextMenuHelpers';
} from '@/features/contextMenu/utils/contextMenuHelpers';
interface Props {
artistIds: string[];
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { ListMusic, Plus } from 'lucide-react';
import { usePlaylistStore } from '@/features/playlist';
import { showToast } from '@/lib/dom/toast';
import { isSmartPlaylistName } from '@/utils/componentHelpers/contextMenuHelpers';
import { isSmartPlaylistName } from '@/features/contextMenu/utils/contextMenuHelpers';
interface SingleProps {
playlist: { id: string; name: string };
@@ -1,17 +1,17 @@
import { join } from '@tauri-apps/api/path';
import { invoke } from '@tauri-apps/api/core';
import { getSimilarSongs2, fetchSimilarTracksRouted, getTopSongs } from '@/lib/api/subsonicArtists';
import { filterSongsForLuckyMixRatings, getMixMinRatingsConfigFromAuth } from '../mix/mixRatingFilter';
import { filterSongsForLuckyMixRatings, getMixMinRatingsConfigFromAuth } from '@/utils/mix/mixRatingFilter';
import { buildDownloadUrl } from '@/lib/api/subsonicStreamUrl';
import { useAuthStore } from '../../store/authStore';
import { useAuthStore } from '@/store/authStore';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import type { Track } from '@/lib/media/trackTypes';
import { resolveQueueTrack } from '@/features/playback/store/queueTrackView';
import { useZipDownloadStore } from '@/features/offline';
import { useDownloadModalStore } from '@/features/offline';
import type { EntityShareKind } from '../share/shareLink';
import { copyEntityShareLink } from '../share/copyEntityShareLink';
import { sanitizeFilename, shuffleArray } from './contextMenuHelpers';
import type { EntityShareKind } from '@/utils/share/shareLink';
import { copyEntityShareLink } from '@/utils/share/copyEntityShareLink';
import { sanitizeFilename, shuffleArray } from '@/features/contextMenu/utils/contextMenuHelpers';
import { songToTrack } from '@/lib/media/songToTrack';
import { showToast } from '@/lib/dom/toast';
@@ -1,4 +1,4 @@
import { useConfirmModalStore } from '../../store/confirmModalStore';
import { useConfirmModalStore } from '@/store/confirmModalStore';
/** Psysonic smart playlists (Navidrome); not valid targets for manual add-to-playlist. */
export const SMART_PLAYLIST_PREFIX = 'psy-smart-';
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { AudioLines, ChevronRight, Play, Square, X } from 'lucide-react';
import type { ColDef } from '@/hooks/useTracklistColumns';
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
import { codecLabel } from '@/utils/componentHelpers/playlistDetailHelpers';
import { codecLabel } from '@/lib/format/playlistDetailHelpers';
import { formatLastSeen } from '@/utils/componentHelpers/userMgmtHelpers';
import i18n from '@/lib/i18n';
import { formatTrackTime } from '@/lib/format/formatDuration';
@@ -2,7 +2,7 @@ import React, { memo, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Disc3, ExternalLink, Star } from 'lucide-react';
import type { SubsonicAlbum, SubsonicSong } from '@/lib/api/subsonicTypes';
import { formatTotalDuration } from '@/utils/componentHelpers/nowPlayingHelpers';
import { formatTotalDuration } from '@/features/nowPlaying/utils/nowPlayingHelpers';
import { formatTrackTime } from '@/lib/format/formatDuration';
interface AlbumCardProps {
@@ -2,7 +2,8 @@ import React, { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
import { useTranslation } from 'react-i18next';
import { ExternalLink } from 'lucide-react';
import type { SubsonicArtistInfo } from '@/lib/api/subsonicTypes';
import { isRealArtistImage, sanitizeHtml } from '@/utils/componentHelpers/nowPlayingHelpers';
import { isRealArtistImage } from '@/cover/isRealArtistImage';
import { sanitizeHtml } from '@/features/nowPlaying/utils/nowPlayingHelpers';
import CachedImage from '@/ui/CachedImage';
export interface ArtistCardTab {
@@ -1,6 +1,6 @@
import React, { memo } from 'react';
import { useTranslation } from 'react-i18next';
import type { ContributorRow } from '@/utils/componentHelpers/nowPlayingHelpers';
import type { ContributorRow } from '@/features/nowPlaying/utils/nowPlayingHelpers';
interface CreditsCardProps { rows: ContributorRow[]; }
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { Calendar, Info } from 'lucide-react';
import { open as shellOpen } from '@tauri-apps/plugin-shell';
import type { BandsintownEvent } from '@/api/bandsintown';
import { isoToParts } from '@/utils/componentHelpers/nowPlayingHelpers';
import { isoToParts } from '@/features/nowPlaying/utils/nowPlayingHelpers';
interface TourCardProps {
artistName: string;
+1 -1
View File
@@ -24,7 +24,7 @@ import {
import {
buildContributorRows,
} from '@/utils/componentHelpers/nowPlayingHelpers';
} from '@/features/nowPlaying/utils/nowPlayingHelpers';
import NpCardWrap from '@/features/nowPlaying/components/NpCardWrap';
import NpColumnEl from '@/features/nowPlaying/components/NpColumnEl';
import RadioView from '@/features/nowPlaying/components/RadioView';
@@ -64,14 +64,3 @@ export function buildContributorRows(song: SubsonicSong | null | undefined, main
}
return out;
}
/**
* Filter out the well-known Last.fm "no image" placeholder that Subsonic
* backends aggregate into `largeImageUrl`/`mediumImageUrl` when no real
* artist image exists. The placeholder MD5 is fixed and documented.
*/
export function isRealArtistImage(url?: string): boolean {
if (!url) return false;
if (url.includes('2a96cbd8b46e442fc41c2b86b821562f')) return false;
return true;
}
+1 -1
View File
@@ -18,7 +18,7 @@ import {
} from '@/features/offline/utils/offlineLibraryHelpers';
import { librarySqlServerId } from '@/api/coverCache';
import { resolveIndexKey, serverIndexKeyForProfile } from '@/lib/server/serverIndexKey';
import { isSmartPlaylistName } from '@/utils/componentHelpers/playlistDetailHelpers';
import { isSmartPlaylistName } from '@/lib/format/playlistDetailHelpers';
import {
enqueueOfflinePin,
registerOfflinePinExecutor,
@@ -14,7 +14,7 @@ import {
syncAllPinnedPlaylists,
syncPinnedArtistIfNeeded,
} from '@/features/offline/utils/pinnedOfflineSync';
import { SMART_PREFIX } from '@/utils/componentHelpers/playlistDetailHelpers';
import { SMART_PREFIX } from '@/lib/format/playlistDetailHelpers';
const getPlaylistMock = vi.fn();
const getAlbumForServerMock = vi.fn();
@@ -9,7 +9,7 @@ import type { PinSource } from '@/store/localPlaybackStore';
import { useLocalPlaybackStore } from '@/store/localPlaybackStore';
import { useOfflineStore } from '@/features/offline/store/offlineStore';
import { usePlaylistStore } from '@/features/playlist';
import { isSmartPlaylistName } from '@/utils/componentHelpers/playlistDetailHelpers';
import { isSmartPlaylistName } from '@/lib/format/playlistDetailHelpers';
import { getMediaDir } from '@/lib/media/mediaDir';
import {
isActiveServerReachable,
@@ -13,7 +13,7 @@ import { useThemeStore } from '@/store/themeStore';
import { usePlaylistLayoutStore, type PlaylistLayoutItemId } from '@/features/playlist/store/playlistLayoutStore';
import {
displayPlaylistName, formatSize, isSmartPlaylistName, totalDurationLabel,
} from '@/utils/componentHelpers/playlistDetailHelpers';
} from '@/lib/format/playlistDetailHelpers';
import type { CoverArtId } from '@/cover/types';
import { AlbumCoverArtImage } from '@/cover/AlbumCoverArtImage';
import { PLAYLIST_MAIN_COVER_CSS_PX } from '@/features/playlist/hooks/usePlaylistCovers';
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { AudioLines, ChevronRight, Heart, Play, Square, Trash2 } from 'lucide-react';
import type { ColDef } from '@/hooks/useTracklistColumns';
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
import { codecLabel } from '@/utils/componentHelpers/playlistDetailHelpers';
import { codecLabel } from '@/lib/format/playlistDetailHelpers';
import { formatLastSeen } from '@/utils/componentHelpers/userMgmtHelpers';
import i18n from '@/lib/i18n';
import { formatTrackTime } from '@/lib/format/formatDuration';
@@ -12,7 +12,7 @@ import { useThemeStore } from '@/store/themeStore';
import { usePlaylistLayoutStore } from '@/features/playlist/store/playlistLayoutStore';
import { songToTrack } from '@/lib/media/songToTrack';
import { getQueueTracksView } from '@/features/playback/store/queueTrackView';
import { codecLabel } from '@/utils/componentHelpers/playlistDetailHelpers';
import { codecLabel } from '@/lib/format/playlistDetailHelpers';
import { formatLastSeen } from '@/utils/componentHelpers/userMgmtHelpers';
import { formatTrackTime } from '@/lib/format/formatDuration';
import i18n from '@/lib/i18n';
@@ -3,7 +3,7 @@ import { join } from '@tauri-apps/api/path';
import { buildDownloadUrl } from '@/lib/api/subsonicStreamUrl';
import type { SubsonicPlaylist } from '@/lib/api/subsonicTypes';
import { useZipDownloadStore } from '@/features/offline';
import { sanitizeFilename } from '@/utils/componentHelpers/playlistDetailHelpers';
import { sanitizeFilename } from '@/lib/format/playlistDetailHelpers';
export interface RunPlaylistZipDownloadDeps {
playlist: SubsonicPlaylist;
@@ -7,7 +7,7 @@ import type { PlaybackSourceKind } from '@/features/playback/utils/playback/reso
import {
formatQueueReplayGainParts,
renderStars,
} from '@/utils/componentHelpers/queuePanelHelpers';
} from '@/features/queue/utils/queuePanelHelpers';
import { loudnessGainPlaceholderUntilCacheDb } from '@/features/playback/utils/audio/loudnessPlaceholder';
import { effectiveLoudnessPreAnalysisAttenuationDb } from '@/lib/audio/loudnessPreAnalysisSlider';
import { formatQueueBpmTech, formatQueueMoodLabels } from '@/lib/library/trackEnrichment';
@@ -6,7 +6,7 @@ import { usePlayerStore } from '@/features/playback/store/playerStore';
import { useAuthStore } from '@/store/authStore';
import type { QueueItemRef } from '@/lib/media/trackTypes';
import type { QueueDisplayMode } from '@/store/authStoreTypes';
import type { DurationMode } from '@/utils/componentHelpers/queuePanelHelpers';
import type { DurationMode } from '@/features/queue/utils/queuePanelHelpers';
import { formatLongDuration } from '@/lib/format/formatDuration';
import { formatClockTime } from '@/lib/format/formatClockTime';
import { resolveQueueTrack } from '@/features/playback/store/queueTrackView';
@@ -2,7 +2,7 @@ import { Star } from 'lucide-react';
import type { TFunction } from 'i18next';
import type { Track } from '@/lib/media/trackTypes';
export type { DurationMode } from '../../store/authStoreTypes';
export type { DurationMode } from '@/store/authStoreTypes';
export function formatQueueReplayGainParts(track: Track, t: TFunction): string[] {
const parts: string[] = [];
@@ -2,7 +2,7 @@ import { useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useArtistLayoutStore, type ArtistSectionConfig, type ArtistSectionId } from '@/features/artist';
import { useListReorderDnd } from '@/lib/hooks/useListReorderDnd';
import { applyListReorderById, type ListReorderDropTarget } from '@/utils/componentHelpers/listReorder';
import { applyListReorderById, type ListReorderDropTarget } from '@/lib/util/listReorder';
import { ReorderGripHandle } from '@/features/settings/components/ReorderGripHandle';
const ARTIST_SECTION_LABEL_KEYS: Record<ArtistSectionId, string> = {
@@ -4,7 +4,7 @@ import { useShallow } from 'zustand/react/shallow';
import { useAuthStore } from '@/store/authStore';
import type { LyricsSourceId } from '@/store/authStoreTypes';
import { useListReorderDnd } from '@/lib/hooks/useListReorderDnd';
import { applyListReorderById, type ListReorderDropTarget } from '@/utils/componentHelpers/listReorder';
import { applyListReorderById, type ListReorderDropTarget } from '@/lib/util/listReorder';
import { ReorderGripHandle } from '@/features/settings/components/ReorderGripHandle';
import { SettingsToggle } from '@/features/settings/components/SettingsToggle';
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { Blend, Infinity as InfinityIcon, ListMusic, MoveRight, Share2, Shuffle, Trash2, Waves } from 'lucide-react';
import { useQueueToolbarStore, QueueToolbarButtonId } from '@/store/queueToolbarStore';
import { useListReorderDnd } from '@/lib/hooks/useListReorderDnd';
import { applyListReorderById, type ListReorderDropTarget } from '@/utils/componentHelpers/listReorder';
import { applyListReorderById, type ListReorderDropTarget } from '@/lib/util/listReorder';
import { ReorderGripHandle } from '@/features/settings/components/ReorderGripHandle';
const QUEUE_TOOLBAR_BUTTON_ICONS: Record<QueueToolbarButtonId, typeof Shuffle | null> = {
@@ -38,7 +38,7 @@ import { switchActiveServer } from '@/utils/server/switchActiveServer';
import { AddServerForm } from '@/features/settings/components/AddServerForm';
import { ServerCapabilityHeaderBadge } from '@/features/settings/components/ServerCapabilityHeaderBadge';
import { useListReorderDnd } from '@/lib/hooks/useListReorderDnd';
import { applyListReorderById, type ListReorderDropTarget } from '@/utils/componentHelpers/listReorder';
import { applyListReorderById, type ListReorderDropTarget } from '@/lib/util/listReorder';
import { ReorderGripHandle } from '@/features/settings/components/ReorderGripHandle';
import { tooltipAttrs } from '@/ui/tooltipAttrs';
@@ -6,7 +6,7 @@ import { useLuckyMixAvailable } from '@/features/randomMix';
import { ALL_NAV_ITEMS } from '@/config/navItems';
import { applySidebarReorderById } from '@/features/sidebar';
import { useListReorderDnd } from '@/lib/hooks/useListReorderDnd';
import type { ListReorderDropTarget } from '@/utils/componentHelpers/listReorder';
import type { ListReorderDropTarget } from '@/lib/util/listReorder';
import { ReorderGripHandle } from '@/features/settings/components/ReorderGripHandle';
import { SettingsGroup } from '@/features/settings/components/SettingsGroup';
import { SettingsToggle } from '@/features/settings/components/SettingsToggle';
@@ -16,7 +16,7 @@ import {
} from '@/lib/themes/themeRegistry';
import { installThemeFromRegistry } from '@/lib/themes/installThemeFromRegistry';
import { uninstallTheme } from '@/lib/themes/uninstallTheme';
import { isNewer } from '@/utils/componentHelpers/appUpdaterHelpers';
import { isNewer } from '@/lib/util/appUpdaterHelpers';
type ModeFilter = 'all' | 'dark' | 'light';
type SortMode = 'newest' | 'name';
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { isNewer } from '@/utils/componentHelpers/appUpdaterHelpers';
import { isNewer } from '@/lib/util/appUpdaterHelpers';
import { fetchRegistry, getCachedRegistry, type Registry, type RegistryTheme } from '@/lib/themes/themeRegistry';
import { useInstalledThemesStore } from '@/store/installedThemesStore';
@@ -1,6 +1,6 @@
import { ALL_NAV_ITEMS } from '@/config/navItems';
import { CONSERVED_SIDEBAR_NAV_IDS, type SidebarItemConfig } from '@/features/sidebar/store/sidebarStore';
import { applyListReorderById, type ListReorderDropTarget } from '@/utils/componentHelpers/listReorder';
import { applyListReorderById, type ListReorderDropTarget } from '@/lib/util/listReorder';
export type SidebarNavSection = 'library' | 'system';
+1 -1
View File
@@ -5,7 +5,7 @@ import { invoke } from '@tauri-apps/api/core';
import { useTranslation } from 'react-i18next';
import { version as currentVersion } from '../../../../package.json';
import { IS_LINUX, IS_MACOS, IS_WINDOWS } from '@/lib/util/platform';
import { SKIP_KEY, isNewer, isWithinModerationWindow, pickAsset, type ReleaseData, type DlState } from '@/utils/componentHelpers/appUpdaterHelpers';
import { SKIP_KEY, isNewer, isWithinModerationWindow, pickAsset, type ReleaseData, type DlState } from '@/lib/util/appUpdaterHelpers';
/** All update-modal state, the GitHub release probe and the download/relaunch
* handlers. The component owns only the early-return guard and the JSX. */
+1 -1
View File
@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useDragDrop } from '@/lib/dnd/DragDropContext';
import type { ListReorderDropTarget } from '@/utils/componentHelpers/listReorder';
import type { ListReorderDropTarget } from '@/lib/util/listReorder';
interface Options {
/** Payload discriminator the drag source emits, e.g. `'lyrics_source_reorder'`. */
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { isWithinModerationWindow, WINGET_MODERATION_DELAY_MS } from './appUpdaterHelpers';
import { isWithinModerationWindow, WINGET_MODERATION_DELAY_MS } from '@/lib/util/appUpdaterHelpers';
describe('isWithinModerationWindow', () => {
const published = '2026-06-27T00:00:00Z';
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { applyListReorderById } from './listReorder';
import { applyListReorderById } from '@/lib/util/listReorder';
type Item = { id: string; visible?: boolean };