// 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) || ' '}
)}
);
}