The tray's right-click menu is drawn by the app: one tree from tray.rs (stable submenu keys), one frameless always-on-top menu-N window per panel rendered by tray-menu.html with the design system, placed inside the monitor's work area. A pure panel book keeps transient and pinned panels apart; pinned panels (pin button, Ctrl+click, middle click) are draggable, step aside from each other and are saved in state.json (menuPins) and restored at start. tray_update pushes the tree to the open panels, which update in place; closed panels are destroyed. The native menu, rendered from the same tree, stays as the fallback and on Linux. Only menu-* windows get the menu_* commands (menu.json). In-app context menus get the same behaviour through a pure menu stack (ui/menu-stack.ts): pin a menu, tear off a submenu, pinned panels survive the next menu, are draggable and re-read their entries from live sources so data changes update them in place. Also drops a stray diff3 marker line from CHANGELOG.md.
339 lines
26 KiB
TypeScript
339 lines
26 KiB
TypeScript
// Shell: sidebar (apps grouped by what they need: attention, in progress, live, not on a store yet; one status line
|
|
// each; a studio filter; the workspace as a compact footer row), top bar (breadcrumbs, last read, refresh),
|
|
// the screen, the command palette (Cmd/Ctrl+K), toasts and the first-run welcome guide.
|
|
import { DryRunPreview } from './ui/DryRunPreview';
|
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import { FIXTURES, onDeepLink, onDeepLinkFolder, onTrayRoute } from './api';
|
|
import { devSwitch } from './dev/switches';
|
|
import { type Key, t } from './i18n';
|
|
import { ago, num } from './lib/format';
|
|
import type { UnifiedApp } from './lib/identity';
|
|
import { NavCtx, parseRouteJson, parseStart, type Route, sameRoute, SECTION_ICON, SECTION_KEY, sectionOf, sectionsFor, subsOf, TAB_ICON, TAB_KEY, tabsFor, WORKSPACE, type WorkspaceScreen } from './nav';
|
|
import { usePrefs } from './prefs-ctx';
|
|
import { Accounts } from './screens/Accounts';
|
|
import { AppFolders } from './screens/AppFolders';
|
|
import { AppView } from './screens/AppView';
|
|
import { Dependencies } from './screens/Dependencies';
|
|
import { Ignored } from './screens/Ignored';
|
|
import { useShowIgnored } from './lib/show-ignored';
|
|
import { Onboarding } from './screens/Onboarding';
|
|
import { Portfolio } from './screens/Portfolio';
|
|
import { Settings } from './screens/Settings';
|
|
import { useData, useReadState } from './store';
|
|
import { Icon, type IconName, Mark } from './ui/icons';
|
|
import { AppIcon, Kbd, MOD, StudioMark } from './ui/kit';
|
|
import { type Command, Palette } from './ui/Palette';
|
|
import { ExportMenu, useExportItems, useRunExport } from './ui/Exports';
|
|
import { ErrorBoundary } from './ui/ErrorBoundary';
|
|
import { Toasts } from './ui/Toasts';
|
|
import { useContextMenu } from './ui/ContextMenu';
|
|
import { appMenu } from './ui/app-menu';
|
|
import { isEditable, isItem, type MenuInput } from './ui/menu-logic';
|
|
import { attention, isGameSummary, SIDE_GROUPS, type SideGroup, sideStatus } from './lib/model';
|
|
|
|
const WS_ICON: Record<WorkspaceScreen, IconName> = { apps: 'folder', accounts: 'building', ignored: 'eyeOff', dependencies: 'package', settings: 'settings' };
|
|
export const WS_KEY: Record<WorkspaceScreen, Key> = { apps: 'nav.apps', accounts: 'nav.accounts', ignored: 'nav.ignored', dependencies: 'nav.dependencies', settings: 'nav.settings' };
|
|
|
|
const SIDE_KEY = 'storebound.side.v1';
|
|
const STUDIO_KEY = 'storebound.side.studio.v1';
|
|
function loadCollapsed(): string[] {
|
|
try { const v = JSON.parse(localStorage.getItem(SIDE_KEY) ?? '[]') as unknown; return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []; } catch { return []; }
|
|
}
|
|
function loadStudio(): string | null { try { return localStorage.getItem(STUDIO_KEY); } catch { return null; } }
|
|
const GROUP_KEY: Record<SideGroup | 'ignored', Key> = { attention: 'side.gAttention', progress: 'side.gProgress', live: 'side.gLive', draft: 'side.gDraft', ignored: 'nav.ignored' };
|
|
|
|
export function App({ onTheme }: { onTheme: () => void }) {
|
|
const d = useData();
|
|
const { prefs, update } = usePrefs();
|
|
const [route, setRoute] = useState<Route>({ screen: 'portfolio' });
|
|
const [palette, setPalette] = useState(false);
|
|
const [welcome, setWelcome] = useState(false);
|
|
const [started, setStarted] = useState(false);
|
|
const [collapsed, setCollapsed] = useState<string[]>(loadCollapsed);
|
|
const [studioFilter, setStudioFilterState] = useState<string | null>(loadStudio);
|
|
const setStudioFilter = (v: string | null) => { setStudioFilterState(v); try { if (v) localStorage.setItem(STUDIO_KEY, v); else localStorage.removeItem(STUDIO_KEY); } catch { /* private mode */ } };
|
|
const exports = useExportItems();
|
|
const runExport = useRunExport();
|
|
|
|
const go = useCallback((r: Route) => { setRoute(r); document.getElementById('content')?.scrollTo({ top: 0 }); }, []);
|
|
const visible = useMemo(() => d.apps.filter(a => !a.ignored).sort((a, b) => a.name.localeCompare(b.name)), [d.apps]);
|
|
// Refresh all is busy while any read it covers is in flight (its own pass, a card's refresh, Check again, auto-read)
|
|
const allReads = useReadState({ tags: ['all'] });
|
|
const [showIgnored, setShowIgnored] = useShowIgnored();
|
|
const ignoredApps = useMemo(() => d.apps.filter(a => a.ignored).sort((a, b) => a.name.localeCompare(b.name)), [d.apps]);
|
|
const gameOf = useCallback((a: UnifiedApp) => isGameSummary(d.statusFor(a)?.summary), [d]);
|
|
const tabsOf = useCallback((a: UnifiedApp) => tabsFor(!!a.project, gameOf(a)), [gameOf]);
|
|
// the sidebar: apps by what they need (attention, in progress, live, not on a store yet), one status line each
|
|
const studioOn = studioFilter && (studioFilter === 'none' || d.studios.some(s => s.id === studioFilter)) ? studioFilter : null;
|
|
const inStudio = useCallback((a: UnifiedApp) => !studioOn || (studioOn === 'none' ? !a.studio : a.studio === studioOn), [studioOn]);
|
|
const sideRows = useMemo(() => visible.filter(inStudio).map(a => {
|
|
const e = d.statusFor(a), l = d.liveFor(a);
|
|
return { app: a, st: sideStatus({ att: attention(a.key, a.name, e, l), all: l?.release.all ?? [], read: !!l || e?.release.source === 'live', missing: !!a.project?.missing, storeOnly: !a.project }) };
|
|
}), [visible, d, inStudio]);
|
|
const groups: { id: SideGroup | 'ignored'; rows: typeof sideRows }[] = [
|
|
...SIDE_GROUPS.map(g => ({ id: g, rows: sideRows.filter(r => r.st.group === g) })).filter(g => g.rows.length),
|
|
...(showIgnored && ignoredApps.length ? [{ id: 'ignored' as const, rows: ignoredApps.filter(inStudio).map(a => ({ app: a, st: { group: 'draft' as SideGroup, text: t('side.ignored'), tone: 'neutral' as const } })) }] : []),
|
|
];
|
|
// Alt+Up / Alt+Down: the previous / next app in sidebar order (keeps the place when the other app has it)
|
|
const sideOrder = groups.flatMap(x => (collapsed.includes(`g:${x.id}`) ? [] : x.rows.map(r => r.app)));
|
|
const stepApp = useCallback((dir: 1 | -1) => {
|
|
if (!sideOrder.length) return;
|
|
const i = route.screen === 'app' ? sideOrder.findIndex(a => a.key === route.appId) : -1;
|
|
const next = sideOrder[i < 0 ? (dir === 1 ? 0 : sideOrder.length - 1) : (i + dir + sideOrder.length) % sideOrder.length]!;
|
|
go({ screen: 'app', appId: next.key, tab: route.screen === 'app' && tabsOf(next).includes(route.tab) ? route.tab : 'releases' });
|
|
}, [sideOrder, route, go, tabsOf]);
|
|
const toggleGroup = (id: string) => setCollapsed(c => {
|
|
const next = c.includes(id) ? c.filter(x => x !== id) : [...c, id];
|
|
try { localStorage.setItem(SIDE_KEY, JSON.stringify(next)); } catch { /* private mode */ }
|
|
return next;
|
|
});
|
|
|
|
// first screen: dev switch (VITE_START), else the welcome guide on a first run
|
|
useEffect(() => {
|
|
if (!d.ready || started) return;
|
|
setStarted(true);
|
|
const v = devSwitch('start');
|
|
if (v === 'welcome' || v.startsWith('welcome:')) { setWelcome(true); return; }
|
|
if (v === 'palette') setPalette(true);
|
|
const r = parseStart(v, d.apps.filter(a => !a.ignored).map(a => a.key));
|
|
if (r) { go(r); return; }
|
|
if (!d.projects.length && !d.accounts.length && !prefs.onboarded) setWelcome(true);
|
|
}, [d.ready, d.apps, d.projects, d.accounts, started, go, prefs.onboarded]);
|
|
|
|
useEffect(() => {
|
|
const un = onDeepLink(s => { const r = parseStart(s, []); if (r) go(r); });
|
|
// `storebound desktop` in an app folder: the host added (after its native dialog) or found that folder
|
|
const unFolder = onDeepLinkFolder(id => { d.reload().catch(() => {}).finally(() => go({ screen: 'app', appId: id, tab: 'releases' })); });
|
|
// the tray menu (an app, Settings, Check for updates) and the first show after a notification
|
|
const unTray = onTrayRoute(s => { const r = parseRouteJson(s); if (r) go(r); });
|
|
return () => { un.then(f => f()); unFolder.then(f => f()); unTray.then(f => f()); };
|
|
}, [go, d.reload]);
|
|
|
|
const app = route.screen === 'app' ? d.appByKey(route.appId) : undefined;
|
|
const game = app ? gameOf(app) : false;
|
|
const tabs = app ? tabsOf(app) : [];
|
|
const sections = app ? sectionsFor(!!app.project, game) : [];
|
|
|
|
// keyboard: Cmd/Ctrl+K palette, Cmd/Ctrl+1 portfolio, 2-6 workspace, 1-5 (no modifier) the app's sections,
|
|
// Cmd/Ctrl+Shift+[ ] the previous / next section
|
|
useEffect(() => {
|
|
const onKey = (e: KeyboardEvent) => {
|
|
const mod = e.metaKey || e.ctrlKey;
|
|
if (mod && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'k') { e.preventDefault(); setPalette(p => !p); return; }
|
|
if (palette || welcome) return;
|
|
if (e.altKey && !mod && !e.shiftKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown') && !isEditable(e.target as HTMLInputElement)) { e.preventDefault(); stepApp(e.key === 'ArrowDown' ? 1 : -1); return; }
|
|
if (mod && !e.shiftKey && !e.altKey && /^[1-6]$/.test(e.key)) {
|
|
e.preventDefault();
|
|
const n = Number(e.key);
|
|
go(n === 1 ? { screen: 'portfolio' } : { screen: WORKSPACE[n - 2]! });
|
|
return;
|
|
}
|
|
if (!mod && !e.shiftKey && !e.altKey && /^[1-9]$/.test(e.key) && route.screen === 'app' && app && !isEditable(e.target as HTMLInputElement) && !document.querySelector('.ds-menu, .ds-dialog')) {
|
|
const sec = sections[Number(e.key) - 1];
|
|
if (sec) { e.preventDefault(); go({ ...route, tab: subsOf(sec, !!app.project, game)[0]! }); }
|
|
return;
|
|
}
|
|
if (mod && e.shiftKey && (e.key === '[' || e.key === ']' || e.key === '{' || e.key === '}') && route.screen === 'app' && app && sections.length) {
|
|
e.preventDefault();
|
|
const i = sections.indexOf(sectionOf(route.tab)) + (e.key === ']' || e.key === '}' ? 1 : -1);
|
|
go({ ...route, tab: subsOf(sections[(i + sections.length) % sections.length]!, !!app.project, game)[0]! });
|
|
}
|
|
};
|
|
window.addEventListener('keydown', onKey);
|
|
return () => window.removeEventListener('keydown', onKey);
|
|
}, [palette, welcome, route, go, app, sections, game, stepApp]);
|
|
|
|
const closeWelcome = useCallback(() => {
|
|
setWelcome(false);
|
|
if (!prefs.onboarded) update({ ...prefs, onboarded: true });
|
|
}, [prefs, update]);
|
|
|
|
useEffect(() => { if (route.screen === 'app' && d.ready && d.apps.length && !app) go({ screen: 'portfolio' }); }, [route, app, d.ready, d.apps.length, go]);
|
|
|
|
const commands = useMemo<Command[]>(() => {
|
|
const c: Command[] = [];
|
|
const G = { apps: t('palette.gApps'), go: t('palette.gGo'), tabs: t('palette.gTabs'), act: t('palette.gActions'), exp: t('palette.gExport') };
|
|
const studioName = (a: UnifiedApp) => d.studios.find(s => s.id === a.studio)?.name;
|
|
for (const a of visible) c.push({ id: `app-${a.key}`, group: G.apps, title: a.name, hint: studioName(a) ?? t('palette.openApp'), keywords: `${a.ios?.app.bundleId ?? ''} ${a.android?.app.id ?? ''} ${a.project?.name ?? ''}`, app: { name: a.name, icon: d.iconFor(a) },
|
|
run: () => go({ screen: 'app', appId: a.key, tab: route.screen === 'app' && tabsOf(a).includes(route.tab) ? route.tab : 'releases' }) });
|
|
c.push({ id: 'go-next-app', group: G.go, title: t('palette.nextApp'), icon: 'arrowRight', hint: 'Alt ↓', keywords: 'next app down j', run: () => stepApp(1) });
|
|
c.push({ id: 'go-prev-app', group: G.go, title: t('palette.prevApp'), icon: 'arrowLeft', hint: 'Alt ↑', keywords: 'previous app up k', run: () => stepApp(-1) });
|
|
c.push({ id: 'go-portfolio', group: G.go, title: t('nav.portfolio'), icon: 'portfolio', hint: `${MOD} 1`, run: () => go({ screen: 'portfolio' }) });
|
|
WORKSPACE.forEach((s, i) => c.push({ id: `go-${s}`, group: G.go, title: t(WS_KEY[s]), icon: WS_ICON[s], hint: `${MOD} ${i + 2}`, run: () => go({ screen: s }) }));
|
|
for (const s of d.studios) c.push({ id: `studio-${s.id}`, group: G.go, title: s.name, icon: 'building', keywords: 'studio', hint: t('palette.studio'), run: () => go({ screen: 'accounts', section: s.id }) });
|
|
// every section of the open app (1-5) and every sub view in it
|
|
if (app) sections.forEach((sec, i) => {
|
|
const subs = subsOf(sec, !!app.project, game);
|
|
c.push({ id: `sec-${sec}`, group: G.tabs, title: `${app.name} · ${t(SECTION_KEY[sec])}`, icon: SECTION_ICON[sec], hint: String(i + 1), keywords: subs.map(x => t(TAB_KEY[x])).join(' '), run: () => go({ screen: 'app', appId: app.key, tab: subs[0]! }) });
|
|
if (subs.length > 1) for (const tab of subs) c.push({ id: `tab-${tab}`, group: G.tabs, title: `${app.name} · ${t(SECTION_KEY[sec])} · ${t(TAB_KEY[tab])}`, icon: TAB_ICON[tab], run: () => go({ screen: 'app', appId: app.key, tab }) });
|
|
});
|
|
// the app's own menu (consoles, folder, copy ids, reads, ignore, unpair) as commands: every right-click action is
|
|
// also one Ctrl+K away; opening a tab is already above
|
|
if (app) {
|
|
const skip = new Set([t('cm.open'), t('cm.openTab')]);
|
|
const add = (items: MenuInput, prefix: string) => items.forEach((e, i) => {
|
|
if (!e || !isItem(e) || skip.has(e.label) || e.disabled) return;
|
|
const title = prefix ? `${prefix} · ${e.label}` : e.label;
|
|
if (e.submenu) { add(e.submenu, title); return; }
|
|
if (e.onSelect) c.push({ id: `appcmd-${prefix}-${i}-${e.label}`, group: t('palette.gApp', { name: app.name }), title, icon: e.icon, hint: e.shortcut, keywords: app.name, run: e.onSelect });
|
|
});
|
|
add(appMenu(app, d, { route, go, openPalette: () => setPalette(true), openWelcome: () => setWelcome(true) }, { showIgnored }), '');
|
|
}
|
|
for (const x of exports) c.push({ id: `export-${x.id}`, group: G.exp, title: t('palette.export', { what: x.label }), icon: x.icon ?? 'download', keywords: `export save download ${x.kind}`, hint: x.disabled || `.${x.kind}`, run: () => { runExport(x); } });
|
|
if (app?.project) {
|
|
const p = app.project.id;
|
|
c.push({ id: 'act-prices', group: G.act, title: t('palette.prices', { name: app.name }), icon: 'coins', keywords: 'prices csv sheet', run: () => go({ screen: 'app', appId: p, tab: 'prices' }) });
|
|
c.push({ id: 'act-art', group: G.act, title: t('palette.art', { name: app.name }), icon: 'grid', keywords: 'screenshots gallery renders', run: () => go({ screen: 'app', appId: p, tab: 'art' }) });
|
|
}
|
|
c.push({ id: 'act-fastlane', group: G.act, title: t('folders.fastlane'), icon: 'download', keywords: 'fastlane import deliver supply migrate', run: () => go({ screen: 'apps' }) });
|
|
c.push({ id: 'act-live', group: G.act, title: t('action.readStores'), icon: 'refresh', keywords: 'refresh sync', run: () => { d.refreshPortfolio(true); } });
|
|
c.push({ id: 'act-import', group: G.act, title: t('action.importApps'), icon: 'download', keywords: 'import sync accounts', run: () => { d.importApps(); } });
|
|
c.push({ id: 'act-check', group: G.act, title: t('action.checkConfigs'), icon: 'shield', run: () => { d.refreshPortfolio(false); } });
|
|
c.push({ id: 'act-add', group: G.act, title: t('action.addFolder'), icon: 'folderPlus', run: () => go({ screen: 'apps' }) });
|
|
c.push({ id: 'act-account', group: G.act, title: t('action.addAccount'), icon: 'key', run: () => go({ screen: 'accounts' }) });
|
|
c.push({ id: 'act-density', group: G.act, title: t('action.toggleDensity'), icon: 'grid', keywords: 'dense compact table density rows', run: () => update({ ...prefs, density: prefs.density === 'compact' ? 'comfortable' : 'compact' }) });
|
|
c.push({ id: 'act-theme', group: G.act, title: t('action.toggleTheme'), icon: 'moon', keywords: 'dark light', run: onTheme });
|
|
c.push({ id: 'act-welcome', group: G.act, title: t('action.welcome'), icon: 'sparkle', keywords: 'onboarding start guide', run: () => setWelcome(true) });
|
|
return c;
|
|
}, [d, visible, app, sections, game, route, go, onTheme, exports, runExport, showIgnored, stepApp, prefs, update, tabsOf]);
|
|
|
|
const crumbs: { label: string; to?: Route }[] = route.screen === 'portfolio' ? [{ label: t('nav.portfolio') }]
|
|
: route.screen === 'app' ? [{ label: t('nav.portfolio'), to: { screen: 'portfolio' } }, { label: app?.name ?? '', to: app ? { screen: 'app', appId: app.key, tab: 'releases' } : undefined }, { label: t(SECTION_KEY[sectionOf(route.tab)]), to: app ? { screen: 'app', appId: app.key, tab: subsOf(sectionOf(route.tab), !!app.project, game)[0] ?? route.tab } : undefined }, ...(app && subsOf(sectionOf(route.tab), !!app.project, game).length > 1 ? [{ label: t(TAB_KEY[route.tab]) }] : [])]
|
|
: [{ label: t('nav.workspace') }, { label: t(WS_KEY[route.screen]) }];
|
|
const lastRead = d.live?.at ?? null;
|
|
// the app on screen is read first by the background reader
|
|
const { setFocus } = d;
|
|
useEffect(() => { setFocus(app?.key ?? null); }, [app?.key, setFocus]);
|
|
const nav = useMemo(() => ({ route, go, openPalette: () => setPalette(true), openWelcome: () => setWelcome(true) }), [route, go]);
|
|
const current = (r: Route) => (sameRoute(r, route) || (r.screen === 'app' && route.screen === 'app' && r.appId === route.appId) || (r.screen !== 'app' && r.screen !== 'portfolio' && r.screen === route.screen) ? 'page' : undefined);
|
|
const ignoredCount = d.apps.filter(a => a.ignored).length + d.overrides.ignored.filter(i => i.startsWith('account:')).length;
|
|
|
|
const menu = useContextMenu();
|
|
const appItem = (a: UnifiedApp, st: { text: string; tone: string }) => (
|
|
<div role="listitem" key={a.key}>
|
|
<button {...menu(() => appMenu(a, d, nav, { showIgnored }), `app:${a.key}`)} className={`nav nav-app has-line${a.ignored ? ' is-ignored' : ''}`} aria-current={current({ screen: 'app', appId: a.key, tab: 'releases' })}
|
|
onClick={() => go({ screen: 'app', appId: a.key, tab: route.screen === 'app' && tabsOf(a).includes(route.tab) ? route.tab : 'releases' })}
|
|
title={`${a.name} · ${st.text}`}>
|
|
<span className="ds-side-ic"><AppIcon name={a.name} src={d.iconFor(a)} />{(st.tone === 'bad' || st.tone === 'warn') && <span className={`ds-sdot ${st.tone}`} role="img" aria-label={st.tone === 'bad' ? t('pf.blocker') : t('pf.warning')} />}</span>
|
|
<span className="grow side-t"><span className="side-n">{a.name}</span><span className={`side-s ${st.tone}`} data-testid="side-status">{st.text}</span></span>
|
|
{!a.ignored && a.ignoredStores.length > 0 && <span className="so-dot" title={t('ignored.sideHint', { stores: a.ignoredStores.map(k => (k === 'ios' ? 'App Store' : 'Google Play')).join(', ') })}><Icon name="eyeOff" /></span>}
|
|
{!a.project && <span className="so-dot" title={t('apps.storeOnlyHint')}><Icon name="cloud" /></span>}
|
|
</button>
|
|
</div>
|
|
);
|
|
const WS_ROW: (WorkspaceScreen | 'portfolio')[] = ['portfolio', ...WORKSPACE];
|
|
|
|
return (
|
|
<NavCtx.Provider value={nav}>
|
|
<a className="skip" href="#content">{t('a11y.skip')}</a>
|
|
<div className="shell">
|
|
<nav className="side" aria-label={t('nav.sections')}>
|
|
<div className="side-top">
|
|
<div className="brand"><Mark className="brand-mark" /><span>storebound</span>{d.info?.debug && <small>DEV</small>}</div>
|
|
<button className="search-btn" onClick={() => setPalette(true)} aria-keyshortcuts="Meta+K Control+K">
|
|
<Icon name="search" /><span className="lbl">{t('palette.button')}</span><Kbd keys={[MOD, 'K']} />
|
|
</button>
|
|
</div>
|
|
<div className="side-scroll">
|
|
<div className="side-label" id="apps-label"><span>{t('nav.appsLabel')} <span className="count">{visible.length}</span></span>
|
|
<button onClick={() => go({ screen: 'apps' })} aria-label={t('action.addFolder')} title={t('action.addFolder')}><Icon name="plus" /></button>
|
|
</div>
|
|
{d.studios.length > 1 && (
|
|
<div className="side-studios" role="group" aria-label={t('side.studioFilter')}>
|
|
<button aria-pressed={!studioOn} onClick={() => setStudioFilter(null)}>{t('side.allStudios')}</button>
|
|
{d.studios.map(s => <button key={s.id} aria-pressed={studioOn === s.id} onClick={() => setStudioFilter(studioOn === s.id ? null : s.id)}><StudioMark studio={s} size="sm" />{s.name}</button>)}
|
|
</div>
|
|
)}
|
|
{groups.map(g => {
|
|
const gid = `g:${g.id}`;
|
|
const open = !collapsed.includes(gid);
|
|
return (
|
|
<div className="side-group" key={gid} data-group={g.id}>
|
|
<button className="side-group-head" aria-expanded={open} aria-controls={`sg-${g.id}`} onClick={() => toggleGroup(gid)}>
|
|
<Icon name="chevronRight" className="chev" /><span className="grow">{t(GROUP_KEY[g.id])}</span><span className="count">{g.rows.length}</span>
|
|
</button>
|
|
{open && <div role="list" id={`sg-${g.id}`} aria-label={t(GROUP_KEY[g.id])}>{g.rows.map(r => appItem(r.app, r.st))}</div>}
|
|
</div>
|
|
);
|
|
})}
|
|
{!visible.length && !showIgnored && <p className="side-empty">{t('nav.noApps')}</p>}
|
|
{ignoredApps.length > 0 && <button className="link side-ignored-toggle" aria-pressed={showIgnored} onClick={() => setShowIgnored(!showIgnored)} data-testid="toggle-ignored">{showIgnored ? t('ignored.hide', { count: ignoredApps.length }) : t('ignored.show', { count: ignoredApps.length })}</button>}
|
|
</div>
|
|
<div className="side-ws" role="group" aria-label={t('nav.workspace')}>
|
|
{WS_ROW.map((s, i) => {
|
|
const label = s === 'portfolio' ? t('nav.portfolio') : t(WS_KEY[s]);
|
|
const n = s === 'accounts' ? d.accounts.length : s === 'ignored' ? ignoredCount : 0;
|
|
return (
|
|
<button key={s} className="nav-ws" aria-current={current({ screen: s })} onClick={() => go({ screen: s })} title={`${label} (${MOD} ${i + 1})`} aria-label={label} data-screen={s}>
|
|
<Icon name={s === 'portfolio' ? 'portfolio' : WS_ICON[s]} weight={current({ screen: s }) ? 'fill' : 'regular'} />
|
|
{n > 0 && <span className="ws-n">{n}</span>}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
<div className="side-foot" aria-live="polite">
|
|
<div className="row"><i className={`dot ${d.sidecar?.running ? 'ok' : d.sidecarError ? 'bad' : ''}`} />
|
|
<span>{d.sidecar?.running ? t('foot.cli', { version: d.sidecar.version }) : d.sidecarError ? t('foot.cliDown') : t('foot.cliStarting')}</span></div>
|
|
<div className="row"><Icon name="clock" className="sr-icon" /><span>{lastRead ? t('foot.lastRead', { when: ago(lastRead) }) : t('foot.neverRead')}</span></div>
|
|
<ReaderLine />
|
|
</div>
|
|
</nav>
|
|
<div className="main">
|
|
<header className="topbar">
|
|
<div className="crumbs" aria-label={t('a11y.breadcrumbs')}>
|
|
{crumbs.map((c, i) => (
|
|
<span key={i} className="row" style={{ gap: 6, minWidth: 0 }}>
|
|
{i > 0 && <Icon name="chevronRight" />}
|
|
{c.to && i < crumbs.length - 1 ? <button onClick={() => go(c.to!)}>{c.label}</button> : <span className={i === crumbs.length - 1 ? 'here' : ''}>{c.label}</span>}
|
|
</span>
|
|
))}
|
|
</div>
|
|
<span className="spacer" />
|
|
{FIXTURES && <span className="dev-pill" title={t('dev.fixturesHint')}>{t('dev.fixtures')}</span>}
|
|
<span className="stamp"><Icon name="clock" />{lastRead ? t('top.storesRead', { when: ago(lastRead) }) : t('top.storesNotRead')}</span>
|
|
<button className="btn btn-sm" onClick={() => { d.readAll(true).catch(() => {}); }} disabled={!visible.length || allReads.busy} aria-busy={allReads.busy} title={allReads.busy ? t('read.busyShared') : t('action.refreshAllHint')} data-testid="refresh-all">
|
|
<Icon name="refresh" className={allReads.busy ? 'spin' : undefined} />{t('action.refreshAll')}
|
|
</button>
|
|
<ExportMenu />
|
|
</header>
|
|
<main className="content" id="content" tabIndex={-1}>
|
|
{/* one boundary per screen (keyed, so moving to another screen starts fresh): a failing screen keeps the shell */}
|
|
<ErrorBoundary key={route.screen === 'app' ? `app:${route.appId}` : route.screen} where={`screen ${route.screen}`}>
|
|
{route.screen === 'portfolio' && <Portfolio />}
|
|
{route.screen === 'app' && app && <AppView key={app.key} app={app} tab={tabs.includes(route.tab) ? route.tab : 'releases'} />}
|
|
{route.screen === 'apps' && <AppFolders />}
|
|
{route.screen === 'accounts' && <Accounts focus={route.section} />}
|
|
{route.screen === 'ignored' && <Ignored />}
|
|
{route.screen === 'dependencies' && <Dependencies />}
|
|
{route.screen === 'settings' && <Settings onTheme={onTheme} section={route.section} />}
|
|
</ErrorBoundary>
|
|
</main>
|
|
</div>
|
|
</div>
|
|
{palette && <ErrorBoundary where="palette"><Palette commands={commands} onClose={() => setPalette(false)} /></ErrorBoundary>}
|
|
{welcome && <ErrorBoundary where="welcome"><Onboarding onClose={closeWelcome} /></ErrorBoundary>}
|
|
<ErrorBoundary where="toasts"><Toasts /></ErrorBoundary>
|
|
<ErrorBoundary where="preview"><DryRunPreview /></ErrorBoundary>
|
|
</NavCtx.Provider>
|
|
);
|
|
}
|
|
|
|
/** The background reader in the sidebar foot: progress (count + thin bar) and the App Store hourly limit on their own
|
|
* short rows, so nothing is cut off; the long wording stays in the tooltip. */
|
|
function ReaderLine() {
|
|
const d = useData();
|
|
const r = d.reader;
|
|
const q = Object.values(r.quota).sort((a, b) => a.remaining - b.remaining)[0];
|
|
const busy = r.total > 0 && (r.running || r.queued);
|
|
const head = busy ? t('reader.progress', { done: r.done, total: r.total }) : !d.reader.autoRead ? t('reader.off') : d.ready && d.apps.length ? t('reader.upToDate') : '';
|
|
const paused = Object.keys(r.paused).length > 0;
|
|
if (!head && !q && !paused) return null;
|
|
return (
|
|
<div className="reader-line" data-testid="reader-line" aria-live="polite">
|
|
{head && <div className="row" title={head}><Icon name="refresh" className={`sr-icon${r.running ? ' spin' : ''}`} /><span>{head}</span></div>}
|
|
{busy && <div className="reader-bar" role="progressbar" aria-valuemin={0} aria-valuemax={r.total} aria-valuenow={r.done} aria-label={head}><i style={{ width: `${Math.round((r.done / Math.max(1, r.total)) * 100)}%` }} /></div>}
|
|
{paused && <div className="row" title={t('reader.backoff')}><Icon name="pause" className="sr-icon" /><span>{t('reader.backoffShort')}</span></div>}
|
|
{q && <div className="row" title={t('reader.appleQuota', { left: num(q.remaining), limit: num(q.limit) })}><Icon name="gauge" className="sr-icon" /><span>{t('reader.appleQuotaShort', { left: num(q.remaining), limit: num(q.limit) })}</span></div>}
|
|
</div>
|
|
);
|
|
}
|