// Workspace: app folders (one storebound.config.ts per app), which store accounts each app uses, and starting a new // folder from the templates (`storebound init` with a profile, confirmed in a native dialog). import { type MenuInput, useContextMenu } from '../ui/ContextMenu'; import { copyToast } from '../ui/app-menu'; import { failureText } from '../lib/results'; import { type ReactNode, useEffect, useRef, useState } from 'react'; import { api, errorText, newJob, onProgress, type Project, type ToolResult, type WriteOutcome } from '../api'; import { devSwitch } from '../dev/switches'; import { t } from '../i18n'; import { shortPath, tildeText } from '../paths'; import { useNav } from '../nav'; import { useData } from '../store'; import { Icon, StoreGlyph } from '../ui/icons'; import { useFolderActions } from '../ui/FolderMissing'; import { AppIcon, Empty, Notice, Seg, StoreBadge } from '../ui/kit'; import { PageHeader, Section, StatusPill, tintStyle, useTint } from '../ui/ds'; import '../styles/folders.css'; import { checkState } from './Portfolio'; type Profile = 'app' | 'game' | 'subscription-app'; interface Run { title: string; project: string; job: string; lines: string[]; result?: ToolResult; outcome?: WriteOutcome; error?: string } /** Folder card wrapper carrying the app's tint (hooks cannot run inside the list map). */ function Tinted({ src, name, children, menu }: { src: string | null | undefined; name: string; children: ReactNode; menu: () => MenuInput }) { const hue = useTint(src, name); const cm = useContextMenu(); return
{children}
; } function LocateButton({ project }: { project: Project }) { const { busy, locate } = useFolderActions(project); return ; } export function AppFolders() { const d = useData(); const nav = useNav(); const [error, setError] = useState(null); const [busy, setBusy] = useState(null); const [run, setRun] = useState(null); const [profile, setProfile] = useState('app'); const logRef = useRef(null); useEffect(() => { const un = onProgress(p => { if (p.line !== undefined) setRun(r => (r && p.job === r.job ? { ...r, lines: [...r.lines.slice(-400), p.line!] } : r)); }); return () => { un.then(f => f()); }; }, []); useEffect(() => { logRef.current?.scrollTo({ top: logRef.current.scrollHeight }); }, [run?.lines.length]); const act = async (key: string, fn: () => Promise) => { setBusy(key); setError(null); try { await fn(); } catch (e) { setError(errorText(e)); } finally { setBusy(null); } }; const add = () => act('add', async () => { const p = await api.addProject(); if (p) { await d.reload(); d.toast({ kind: 'ok', title: t('folders.added', { name: p.name }) }); } }); const init = () => act('init', async () => { const p = await api.initProject(profile); if (p) { await d.reload(); d.toast({ kind: 'ok', title: t('folders.started', { name: p.name }), detail: t('folders.startedDetail') }); } }); const fastlane = () => act('fastlane', async () => { const p = await api.importFastlane(profile); if (p) { await d.reload(); d.toast({ kind: 'ok', title: t('folders.imported', { name: p.name }), detail: t('folders.importedDetail') }); } }); const remove = (p: Project) => act(`rm-${p.id}`, async () => { await api.removeProject(p.id); await d.reload(); }); const setAcc = (p: Project, play: string | null, apple: string | null) => act(`acc-${p.id}`, async () => { await api.setProjectAccounts(p.id, play, apple); await d.reload(); }); const check = (p: Project) => act(`check-${p.id}`, async () => { const job = newJob('check'); setRun({ title: t('folders.checkTitle'), project: p.name, job, lines: [] }); try { const r = await api.call('check', p.id, undefined, job); setRun(x => x && { ...x, result: r }); } catch (e) { setRun(x => x && { ...x, error: errorText(e) }); } }); const gallery = (p: Project) => act(`gallery-${p.id}`, async () => { const job = newJob('gallery'); setRun({ title: t('folders.galleryTitle'), project: p.name, job, lines: [] }); try { const o = await api.writeWithConfirm('shots.gallery', p.id, {}, job); setRun(x => x && { ...x, outcome: o, result: o.result ?? o.dryRun }); } catch (e) { setRun(x => x && { ...x, error: errorText(e) }); } }); // Dev builds only: VITE_AUTORUN=gallery starts the confirm flow on the first app once (checks of the native dialog). const autorun = useRef(false); useEffect(() => { const first = d.projects[0]; if (devSwitch('autorun') !== 'gallery' || !first || autorun.current) return; autorun.current = true; gallery(first); }, [d.projects]); // eslint-disable-line react-hooks/exhaustive-deps const play = d.accounts.filter(a => a.kind === 'play'); const apple = d.accounts.filter(a => a.kind === 'apple'); const status = !run ? '' : run.error ? t('run.failed') : !run.result ? t('run.running') : run.outcome ? (run.outcome.confirmed ? t('run.applied') : run.outcome.stage === 'dialog' ? t('run.cancelled') : t('run.dryFailed')) : run.result.ok ? t('run.done') : failureText(run.result); return (
{t('action.addFolder')}} /> {error && {error}} {!d.projects.length ? (
}>{t('folders.emptyText')}
) : (
{d.projects.map(p => { const e = d.statusOf(p); const st = p.missing ? { text: t('folder.missingShort'), tone: 'bad' as const } : checkState(e); return ( [ !p.missing && { label: t('folders.open'), icon: 'arrowRight', onSelect: () => nav.go({ screen: 'app', appId: p.id, tab: 'releases' }) }, !p.missing && { label: t('folders.reveal'), icon: 'folder', onSelect: () => { api.reveal(p.id).catch(x => setError(errorText(x))); } }, 'sep', !p.missing && { label: t('folders.check'), icon: 'shield', disabled: busy ? t('cm.busy') : false, onSelect: () => check(p) }, !p.missing && { label: t('folders.gallery'), icon: 'image', disabled: busy ? t('cm.busy') : false, onSelect: () => gallery(p) }, 'sep', { label: t('cm.copy'), icon: 'copy', submenu: [ { label: t('cm.copyPath'), icon: 'folder', onSelect: () => copyToast(d, p.path) }, e?.app?.platforms.ios?.bundleId && { label: t('cm.copyBundleId'), icon: 'tag', shortcut: e.app.platforms.ios.bundleId, onSelect: () => copyToast(d, e.app!.platforms.ios!.bundleId) }, e?.app?.platforms.android?.packageName && { label: t('cm.copyPackage'), icon: 'tag', shortcut: e.app.platforms.android.packageName, onSelect: () => copyToast(d, e.app!.platforms.android!.packageName) }, ] }, 'sep', { label: t('folders.remove', { name: p.name }), icon: 'trash', danger: true, disabled: busy ? t('cm.busy') : false, onSelect: () => remove(p) }, ]}>
{e?.app?.name ?? p.name}{st.text} {shortPath(p.path, d.info?.home)}
{p.missing ? : <> }
); })}
)}
{t(`folders.profileHint.${profile}` as 'folders.profileHint.app')}

{t('folders.newNote')}

{run && (
{status}{run.result && <> · {run.result.command}}} actions={}>
{run.error && {run.error}}
{tildeText(run.result?.output ?? run.lines.join('\n'), d.info?.home) || ' '}
)}
); }