@@ -209,8 +213,8 @@ export function Accounts({ focus }: { focus?: string }) {
return (
-
-
+
+
>} />
{d.accounts.length > 0 &&
diff --git a/apps/desktop/src/screens/Dependencies.tsx b/apps/desktop/src/screens/Dependencies.tsx
index e7a04c8..9320e90 100644
--- a/apps/desktop/src/screens/Dependencies.tsx
+++ b/apps/desktop/src/screens/Dependencies.tsx
@@ -6,12 +6,15 @@ import { type AndroidSdkLookup, api, type DownloadInfo, errorText, type LocalToo
import { t } from '../i18n';
import { bytes } from '../lib/format';
import { shortPath } from '../paths';
-import { useData } from '../store';
+import { useData, useReadState } from '../store';
import { Icon, type IconName } from '../ui/icons';
import { Chip, Notice, Skeleton } from '../ui/kit';
import { PageHeader, Section, StatTile, StatusPill } from '../ui/ds';
import '../styles/deps.css';
+/** The local tools check (system.tools): one registry key. */
+const TOOLS_KEY = '*\u001fsystem.tools';
+
const TOOL_ICON: Record
= { ffmpeg: 'image', ffprobe: 'search', chrome: 'globe', xcode: 'terminal', simctl: 'phone', adb: 'phone', git: 'layers' };
/** Status chip of one tool. */
@@ -29,16 +32,18 @@ export function Dependencies() {
const [sdk, setSdk] = useState(d.sidecar?.tools?.androidSdk ?? null);
const [info, setInfo] = useState([]);
const [error, setError] = useState(null);
- const [busy, setBusy] = useState(false);
+ // local tools check: one registry key, so a second click (or a remount) joins the check in flight
+ const busy = useReadState(TOOLS_KEY).busy;
const [dl, setDl] = useState>({});
+ const reg = d.registry;
const refresh = useCallback(async () => {
- setBusy(true); setError(null);
+ setError(null);
try {
- const [s, i] = await Promise.all([api.call('system.tools'), api.toolsInfo()]);
+ const [s, i] = await reg.read({ key: TOOLS_KEY, lanes: ['local'], accounts: [], priority: 0, run: () => Promise.all([api.call('system.tools'), api.toolsInfo()]) }, { force: true });
setTools(s.tools); setInfo(i); setSdk(s.androidSdk ?? null);
- } catch (e) { setError(errorText(e)); } finally { setBusy(false); }
- }, []);
+ } catch (e) { setError(errorText(e)); }
+ }, [reg]);
useEffect(() => { refresh(); }, [refresh]);
useEffect(() => {
const un = onDownload(e => setDl(m => {
diff --git a/apps/desktop/src/screens/Portfolio.tsx b/apps/desktop/src/screens/Portfolio.tsx
index 12bc6bc..c49926d 100644
--- a/apps/desktop/src/screens/Portfolio.tsx
+++ b/apps/desktop/src/screens/Portfolio.tsx
@@ -4,8 +4,8 @@
// version, crash rate, catalog parity, config check), grouped by studio; then what changed and what to fix first.
import { plainError } from '../../../../cli/lib/google-errors';
import { BlockedProjectsNotice, blockedProjects } from '../ui/StoreError';
-import { type ReactNode, useEffect, useMemo, useState } from 'react';
-import { api, type AppStatus, newJob, type StoreRelease, type ToolResult } from '../api';
+import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
+import { api, type AppStatus, type StoreRelease, type ToolResult } from '../api';
import { type Key, t } from '../i18n';
import { ago, pct, share } from '../lib/format';
import { useShowIgnored } from '../lib/show-ignored';
@@ -15,7 +15,10 @@ import { type AppTab, useNav } from '../nav';
import { useContextMenu } from '../ui/ContextMenu';
import { appMenu } from '../ui/app-menu';
import type { IconName } from '../ui/icons';
-import { useData } from '../store';
+import { useData, useReadState } from '../store';
+import { ImportAppsButton } from '../ui/ReadButtons';
+import { readKey } from '../lib/freshness';
+import { PORTFOLIO_KEY } from '../lib/read-registry';
import { exportName, portfolioCsv } from '../lib/exports';
import { useExports } from '../ui/Exports';
import { Icon, StoreGlyph } from '../ui/icons';
@@ -302,20 +305,25 @@ export function Portfolio() {
const [showIgnored, setShowIgnored] = useShowIgnored();
const menu = useContextMenu();
const [round, setRound] = useState(0);
- const [rechecking, setRechecking] = useState(false);
const health = useCachedHealth(visible.map(a => a.target), round);
const blocked = useMemo(() => blockedProjects(visible.map(a => {
const h = health[a.target]?.h, l = d.liveFor(a);
return { app: a.name, errors: [h?.play && isError(h.play) ? h.play : null, l?.storeErrors.android] };
})), [visible, health, d]);
- // "Check again": read health of the blocked apps again (one at a time: the same Cloud project's quota), then the cache
- const recheck = async () => {
- setRechecking(true);
- const names = new Set(blocked.flatMap(b => b.apps));
- for (const a of visible.filter(x => names.has(x.name))) await api.call('health', a.target, { args: {} }, newJob('health')).catch(() => null);
- setRechecking(false);
- setRound(n => n + 1);
+ // "Check again": the health reads of the blocked apps (the same keys as their Health tab's refresh and Refresh all),
+ // plus the portfolio read when a blocked store came from it. Busy (and disabled) whenever any of these keys is in
+ // flight, whoever started it; a click joins what runs and the scheduler spaces the rest (one Cloud project's quota).
+ const blockedApps = useMemo(() => { const names = new Set(blocked.flatMap(b => b.apps)); return visible.filter(x => names.has(x.name)); }, [blocked, visible]);
+ const fromLive = blockedApps.some(a => !!d.liveFor(a)?.storeErrors.android);
+ const checkKeys = useMemo(() => [...blockedApps.map(a => readKey(a.target, 'health', {})), ...(fromLive ? [PORTFOLIO_KEY] : [])], [blockedApps, fromLive]);
+ const checkReads = useReadState({ keys: checkKeys });
+ const recheck = () => {
+ Promise.allSettled([...blockedApps.map(a => d.schedule(a.target, 'health', {}, 0)), ...(fromLive ? [d.refreshPortfolio(true)] : [])]).catch(() => {});
};
+ // whoever read them (this button, Refresh all, a Health tab), the notice looks at the saved health again after
+ const wasBusy = useRef(false);
+ useEffect(() => { if (wasBusy.current && !checkReads.busy) setRound(n => n + 1); wasBusy.current = checkReads.busy; }, [checkReads.busy]);
+ const allReads = useReadState({ tags: ['all'] });
const open = (a: UnifiedApp) => nav.go({ screen: 'app', appId: a.key, tab: 'releases' });
const all = useMemo(() => visible.map(app => {
@@ -411,14 +419,14 @@ export function Portfolio() {
{t('portfolio.subStudios', { count: visible.length, stores, studios: d.studios.length })}{d.live ? ` · ${t('pf.readAgo', { when: ago(d.live.at) })}` : ''}
- {neverImported === 0 && }
+ {neverImported === 0 && }
{d.portfolioError &&
{d.portfolioError}}
-
- {neverImported > 0 &&
d.importApps()} disabled={d.importBusy}>{t('action.importApps')}}>{t('portfolio.notImported', { count: neverImported })}}
- {!known && d.ready && d.portfolioBusy !== 'live' &&
{ d.readAll(true).catch(() => {}); }}>{t('action.readStores')}}>{t('portfolio.notReadNotice')}}
+
+ {neverImported > 0 &&
}>{t('portfolio.notImported', { count: neverImported })}}
+ {!known && d.ready && d.portfolioBusy !== 'live' &&
{ d.readAll(true).catch(() => {}); }} disabled={allReads.busy} aria-busy={allReads.busy}>{t('action.readStores')}}>{t('portfolio.notReadNotice')}}
diff --git a/apps/desktop/src/screens/Settings.tsx b/apps/desktop/src/screens/Settings.tsx
index cde32f6..599c9af 100644
--- a/apps/desktop/src/screens/Settings.tsx
+++ b/apps/desktop/src/screens/Settings.tsx
@@ -12,6 +12,7 @@ import { pairCandidates, type UnifiedApp, withPair, withUnpair } from '../lib/id
import { useNav } from '../nav';
import { usePrefs } from '../prefs-ctx';
import { useData } from '../store';
+import { ImportAppsButton, RefreshAllButton } from '../ui/ReadButtons';
import { Icon, type IconName, StoreGlyph } from '../ui/icons';
import { AppIcon, Chip, Kbd, MOD, Notice, Seg, StudioMark } from '../ui/kit';
@@ -153,8 +154,8 @@ export function Settings({ onTheme: _onTheme, section }: { onTheme: () => void;
{[1, 2, 3, 4, 6, 8].map(n => )}
{t('set.parallelHint')}
-
-
+
+
{d.live ? t('foot.lastRead', { when: ago(d.live.at) }) : t('foot.neverRead')}
diff --git a/apps/desktop/src/store.tsx b/apps/desktop/src/store.tsx
index d203870..ada04ce 100644
--- a/apps/desktop/src/store.tsx
+++ b/apps/desktop/src/store.tsx
@@ -2,7 +2,7 @@
// overrides, settings, the sidecar, the portfolio (offline check + the last live read from the cache), app icons, and
// background jobs shown as toasts with progress and cancel.
import { askPreview } from './ui/DryRunPreview';
-import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
+import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
import {
type Account, type AccountCheck, api, type AppInfo, type AppsListResult, type AppStatus, type CacheEntry, DEFAULT_SETTINGS, errorText, newJob, onDownload,
onProgress, onTrayAction, type PortfolioResult, type Project, reads, type Settings, type SidecarStatus, type Studio, type ToolResult,
@@ -17,6 +17,7 @@ import { normalizeAccounts, normalizeProjects, normalizeWorkspace } from './lib/
import { keepLines, progressOf } from './lib/progress';
import { isStale, lanesOf, looksThrottled, oneLine, readKey, readsFor } from './lib/freshness';
import { type Outcome, type Priority, ReadScheduler, type ReadTask, type SchedulerStats } from './lib/scheduler';
+import { accountCheckKey, affectedReads, appTags, IMPORT_ALL_KEY, importKey, methodOfKey, PORTFOLIO_CHECK_KEY, PORTFOLIO_KEY, READ_ALL_KEY, type ReadQuery, ReadRegistry, type ReadStatus } from './lib/read-registry';
import { fixHint } from './lib/views';
import type { Route } from './nav';
import { withLastGood } from '../../../cli/lib/train';
@@ -90,8 +91,11 @@ export interface Data {
icons: Record