Ctrl K
ComunidadGuías…

apps/control-center/src/ControlCenter.tsx

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { Component, ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
import {
  ActivityIndicator,
  Modal,
  Pressable,
  ScrollView,
  StyleSheet,
  Text,
  TextInput,
  View,
} from 'react-native';
import { BranchIcon } from './BranchIcon';
import { readConfig, readLoadedState, SurfConfig } from './config';
import { BranchPage, listBranches, surfTo } from './surf';
import { cardShadow, palette, radius, space, type } from './theme';
import { XpremMark } from './XpremMark';

/** "5 min ago" beats a timestamp for someone deciding what is fresh enough to test. */
function sinceLabel(iso: string): string {
  const published = Date.parse(iso);
  if (Number.isNaN(published)) {
    return '';
  }
  const minutes = Math.max(0, Math.round((Date.now() - published) / 60000));
  if (minutes < 1) return 'Updated just now';
  if (minutes < 60) return `Updated ${minutes} min ago`;
  const hours = Math.round(minutes / 60);
  if (hours < 24) return `Updated ${hours} hr ago`;
  const days = Math.round(hours / 24);
  return days === 1 ? 'Updated yesterday' : `Updated ${days} days ago`;
}

/**
 * One probe per JS session. Memoised at module scope rather than in the component:
 * a useEffect would run again if the tree remounted, and this must be exactly once
 * per app open. reloadAsync starts a new session, so a surf re-probes naturally.
 *
 * Whether a channel allows surfing is a setting that changes whenever an admin
 * says so, while a manifest is a snapshot frozen when its update was served — so
 * the answer cannot ride in the manifest, or enabling the feature would only reach
 * devices that happen to download something afterwards.
 */
let sessionProbe: Promise<BranchPage | null> | null = null;

function probeOnce(config: SurfConfig): Promise<BranchPage | null> {
  // Only an ANSWER is remembered — a list, or the 404 that means surfing is off.
  // A timeout is not an answer: caching one would disable the picker until the
  // app is killed, and the tester has no way to know a retry would work.
  sessionProbe ??= listBranches(config).catch(error => {
    sessionProbe = null;
    throw error;
  });
  return sessionProbe;
}

let openPanel: (() => void) | null = null;

/** Opens the panel from anywhere — the edge handle is the built-in trigger. */
export function openControlCenter() {
  openPanel?.();
}

// A QA tool has no business taking the host app down: whatever throws inside the
// panel unmounts the panel, never the app around it.
class ControlCenterBoundary extends Component<{ children: ReactNode }, { failed: boolean }> {
  state = { failed: false };

  static getDerivedStateFromError() {
    return { failed: true };
  }

  componentDidCatch(error: Error) {
    console.warn(`[xprem] The control center crashed and was unmounted: ${error.message}`);
  }

  render() {
    return this.state.failed ? null : this.props.children;
  }
}

export function ControlCenter() {
  return (
    <ControlCenterBoundary>
      <ControlCenterPanel />
    </ControlCenterBoundary>
  );
}

function ControlCenterPanel() {
  const config = useMemo(readConfig, []);
  const loaded = useMemo(readLoadedState, []);
  const [visible, setVisible] = useState(false);
  const [page, setPage] = useState<BranchPage | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [pending, setPending] = useState<string | null>(null);
  const [note, setNote] = useState<string | null>(null);
  const [query, setQuery] = useState('');
  const [expanding, setExpanding] = useState(false);
  const [expanded, setExpanded] = useState(false);

  const [allowed, setAllowed] = useState(false);
  const active = config !== null && allowed;
  const open = useCallback(() => setVisible(true), []);
  const close = useCallback(() => setVisible(false), []);

  // After first paint: a QA tool must not sit on the critical path of a launch.
  useEffect(() => {
    if (!config) return;
    let cancelled = false;
    const handle = requestIdleCallback(() => {
      probeOnce(config)
        .then(result => {
          if (cancelled || result === null) return;
          setAllowed(true);
          setPage(result);
        })
        .catch((cause: Error) => {
          // Not silent: the panel is unreachable for the rest of this session
          // unless the host app calls openControlCenter, and nothing else would
          // ever say why.
          console.warn(`[xprem] Could not reach the branch list: ${cause.message}`);
        });
    });
    return () => {
      cancelled = true;
      cancelIdleCallback(handle);
    };
  }, [config]);

  useEffect(() => {
    // Registered whether or not the probe has answered. Gating it on `active`
    // made the host app's own trigger a silent no-op for the whole of every
    // launch, and permanently so whenever the probe failed — which is exactly
    // when someone reaches for it. Opening early shows the panel's own loading
    // and error states, which is the point.
    openPanel = open;
    return () => {
      // Only if it is still ours: a second instance may have taken over, and
      // clearing unconditionally would deregister a panel that is still mounted.
      if (openPanel === open) {
        openPanel = null;
      }
    };
  }, [open]);

  useEffect(() => {
    if (!visible || !config) return;
    const controller = new AbortController();
    setError(null);
    setNote(null);
    setQuery('');
    setExpanding(false);
    setExpanded(false);
    listBranches(config, controller.signal)
      .then(result => {
        if (result === null) {
          // Surfing was turned off while this session was running. Stand down
          // rather than spin: null reads as "loading" everywhere below.
          setAllowed(false);
          setVisible(false);
          return;
        }
        setPage(result);
      })
      .catch((cause: Error) => {
        if (cause.name !== 'AbortError') setError(cause.message);
      });
    return () => controller.abort();
  }, [visible, config]);

  const showEverything = useCallback(async () => {
    if (!config || expanded || expanding) return;
    setExpanding(true);
    try {
      const result = await listBranches(config, undefined, true);
      if (result === null) {
        // Surfing was switched off between opening the panel and asking for the
        // rest. Same stand-down as every other read, or the tester is left
        // holding switches the server will no longer honour.
        setAllowed(false);
        setVisible(false);
        return;
      }
      setPage(result);
      // Set even when the wide answer is itself capped: this only means "already
      // asked", so a keystroke cannot start the same fetch again. Whether the
      // list is COMPLETE is a separate question, read off total below.
      setExpanded(true);
    } catch (cause) {
      setError((cause as Error).message);
    } finally {
      setExpanding(false);
    }
  }, [config, expanded, expanding]);

  const search = useCallback(
    (text: string) => {
      setQuery(text);
      // Searching a partial list would answer "no match" for a branch that is
      // merely further down — a wrong answer, not a short one. So the first
      // keystroke pulls the rest; showEverything is a no-op once it has.
      if (text.length > 0) void showEverything();
    },
    [showEverything]
  );

  const switchTo = useCallback(
    async (branch: string | null) => {
      if (!config) return;
      setPending(branch ?? '');
      setNote(null);
      try {
        const outcome = await surfTo(config, branch);
        if (outcome === 'nothing-to-load') {
          setNote(
            branch
              ? `Switched to ${branch}. It has nothing newer than what is already ` +
                `running, so the screen did not change — you will get its next publish.`
              : 'Back on this build\u2019s own branch. Nothing newer to load.'
          );
        }
      } catch (cause) {
        setError((cause as Error).message);
      } finally {
        setPending(null);
      }
    },
    [config]
  );

  if (!active || !config) {
    return null;
  }

  // What is RUNNING, which after a surf is the surfed branch — not the branch the
  // channel maps to. Excluded from the list below: there is nothing to switch to.
  const loadedBranch = loaded.branch;
  const rows = (page?.branches ?? []).filter(candidate => candidate.name !== loadedBranch);
  // The server sends a short page of the newest branches. Say how many are left
  // rather than letting the list pass for the whole truth.
  const withheld = page ? page.total - page.branches.length : 0;
  // Keyed on the total, not the page: the page is short by design, so counting
  // loaded rows would hide the search field exactly when it is most needed.
  const searchable = (page?.total ?? 0) > 10;
  const needle = query.trim().toLowerCase();
  const shown = needle ? rows.filter(r => r.name.toLowerCase().includes(needle)) : rows;

  if (!visible) {
    // Always present while surfing is allowed: the way in a tester nobody briefed
    // will actually find. An edge sliver rather than an invisible corner target,
    // so it never swallows the host app's own controls.
    return (
      <Pressable
        style={styles.handle}
        onPress={open}
        hitSlop={{ top: 12, bottom: 12, left: 16, right: 8 }}
        accessibilityRole="button"
        accessibilityLabel="Open the branch picker">
        <View style={styles.handleGrip} />
      </Pressable>
    );
  }

  return (
    // pageSheet is the native card the dev menu uses: full-height sheet, the app
    // pushed back behind it, swipe down to dismiss. No custom animation to own.
    <Modal visible animationType="slide" presentationStyle="pageSheet" onRequestClose={close}>
      <View style={styles.screen}>
        <View style={styles.header}>
          <View style={styles.heroLine}>
            <BranchIcon size={26} weight={2.2} />
            <Text style={[type.hero, styles.heroName]} numberOfLines={1}>
              {loadedBranch ?? 'Embedded build'}
            </Text>
            <Pressable
              style={({ pressed }) => [styles.close, pressed && styles.closePressed]}
              onPress={close}
              accessibilityRole="button"
              accessibilityLabel="Close">
              <Text style={styles.closeGlyph}>✕</Text>
            </Pressable>
          </View>

          <View style={styles.chipLine}>
            <Text style={type.label}>Running on:</Text>
            <View style={styles.chip}>
              <View style={styles.liveDot} />
              <Text style={type.chip}>Channel: {config.channel}</Text>
            </View>
            <View style={[styles.chip, styles.chipNeutral]}>
              <Text style={[type.chip, styles.chipNeutralInk]}>
                Runtime {config.runtimeVersion}
              </Text>
            </View>
          </View>
        </View>

        <ScrollView style={styles.body} contentContainerStyle={styles.bodyContent}>
          {loaded.refusedBranch && (
            <View style={styles.warnCard}>
              <Text style={styles.warnTitle}>{loaded.refusedBranch} was rolled back</Text>
              <Text style={styles.warnBody}>
                It crashed on launch, so this build came back here. Publishing a fix to
                that branch makes it available again.
              </Text>
            </View>
          )}

          <Text style={[type.section, styles.sectionTitle]}>Switch to</Text>

          {searchable && (
            <TextInput
              style={styles.search}
              value={query}
              onChangeText={search}
              placeholder="Search branches"
              placeholderTextColor={palette.muted}
              autoCapitalize="none"
              autoCorrect={false}
              clearButtonMode="while-editing"
              accessibilityLabel="Search branches"
            />
          )}

          {page === null && (
            <View style={[styles.card, styles.cardCentered]}>
              <ActivityIndicator color={palette.muted} />
            </View>
          )}

          {page !== null && shown.length === 0 && (
            <View style={[styles.card, styles.cardCentered]}>
              <Text style={type.meta}>
                {needle
                  ? `No branch matches “${query.trim()}”.`
                  : `Nothing else is published for runtime ${config.runtimeVersion}.`}
              </Text>
            </View>
          )}

          {shown.map(candidate => (
            <Pressable
              key={candidate.name}
              style={({ pressed }) => [styles.card, styles.row, pressed && styles.rowPressed]}
              disabled={pending !== null}
              onPress={() => void switchTo(candidate.name)}>
              <BranchIcon size={22} color={palette.muted} />
              <View style={styles.rowText}>
                <Text style={type.rowTitle} numberOfLines={1}>
                  {candidate.name}
                </Text>
                <Text style={type.meta}>{sinceLabel(candidate.lastUpdateAt)}</Text>
              </View>
              {pending === candidate.name ? (
                <ActivityIndicator size="small" color={palette.muted} />
              ) : (
                <Text style={styles.chevron}>›</Text>
              )}
            </Pressable>
          ))}

          {withheld > 0 && !expanded && (
            <Pressable
              style={({ pressed }) => [styles.card, styles.seeAll, pressed && styles.rowPressed]}
              disabled={expanding}
              onPress={() => void showEverything()}>
              {expanding ? (
                <ActivityIndicator size="small" color={palette.muted} />
              ) : (
                <Text style={styles.seeAllText}>
                  Showing the {page?.branches.length} newest · See all {page?.total}
                </Text>
              )}
            </Pressable>
          )}

          {withheld > 0 && expanded && (
            // The server will not send more than this. Saying so is the whole
            // point: a search over a list this size must not read as exhaustive.
            <View style={[styles.card, styles.seeAll]}>
              <Text style={type.meta}>
                Showing the {page?.branches.length} newest of {page?.total}. Older
                branches are not listed.
              </Text>
            </View>
          )}

          {note && <Text style={[type.meta, styles.footnote]}>{note}</Text>}
          {error && <Text style={[type.meta, styles.errorText]}>{error}</Text>}

          <Pressable
            style={({ pressed }) => [styles.pill, pressed && styles.pillPressed]}
            disabled={pending !== null}
            onPress={() => void switchTo(null)}>
            {pending === '' ? (
              <ActivityIndicator size="small" color={palette.pillInk} />
            ) : (
              <Text style={type.pill}>Return to this build&rsquo;s branch</Text>
            )}
          </Pressable>

          <View style={styles.brand}>
            <XpremMark size={16} />
            <Text style={type.meta}>xprem</Text>
          </View>
        </ScrollView>
      </View>
    </Modal>
  );
}

const styles = StyleSheet.create({
  handle: {
    position: 'absolute',
    right: 0,
    top: '45%',
    paddingVertical: space.md,
    paddingLeft: space.sm,
    paddingRight: 2,
  },
  handleGrip: {
    width: 4,
    height: 42,
    borderTopLeftRadius: 3,
    borderBottomLeftRadius: 3,
    backgroundColor: palette.ink,
    opacity: 0.35,
  },
  screen: { flex: 1, backgroundColor: palette.page },
  header: {
    backgroundColor: palette.surface,
    paddingHorizontal: space.lg,
    paddingTop: space.lg,
    paddingBottom: space.md,
    borderBottomWidth: StyleSheet.hairlineWidth,
    borderBottomColor: palette.border,
    gap: space.md,
  },
  heroLine: { flexDirection: 'row', alignItems: 'center', gap: 10 },
  heroName: { flex: 1 },
  close: {
    width: 32,
    height: 32,
    borderRadius: 16,
    backgroundColor: palette.page,
    alignItems: 'center',
    justifyContent: 'center',
  },
  closePressed: { backgroundColor: palette.surfacePressed },
  closeGlyph: { fontSize: 15, color: palette.muted },
  chipLine: { flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap', gap: space.sm },
  chip: {
    flexDirection: 'row',
    alignItems: 'center',
    gap: 6,
    backgroundColor: palette.chipBg,
    borderRadius: radius.chip,
    paddingHorizontal: 10,
    paddingVertical: 5,
  },
  chipNeutral: { backgroundColor: palette.page },
  chipNeutralInk: { color: palette.muted },
  liveDot: { width: 7, height: 7, borderRadius: 4, backgroundColor: palette.live },
  body: { flex: 1 },
  bodyContent: { padding: space.md, paddingBottom: space.xl, gap: space.sm },
  sectionTitle: { marginTop: space.sm, marginLeft: space.xs },
  search: {
    backgroundColor: palette.surface,
    borderRadius: radius.chip + 2,
    borderWidth: StyleSheet.hairlineWidth,
    borderColor: palette.border,
    minHeight: 42,
    paddingHorizontal: space.md,
    fontSize: 16,
    color: palette.ink,
  },
  card: {
    backgroundColor: palette.surface,
    borderRadius: radius.card,
    paddingHorizontal: space.md,
    paddingVertical: 14,
    ...cardShadow,
  },
  cardCentered: { alignItems: 'center', justifyContent: 'center', minHeight: 64 },
  row: { flexDirection: 'row', alignItems: 'center', gap: space.sm + 4 },
  rowPressed: { backgroundColor: palette.surfacePressed },
  seeAll: { alignItems: 'center', justifyContent: 'center', minHeight: 48 },
  seeAllText: { fontSize: 15, fontWeight: '600', color: palette.ink },
  rowText: { flex: 1, gap: 2 },
  chevron: { fontSize: 22, color: palette.muted, marginTop: -2 },
  warnCard: {
    backgroundColor: palette.warnBg,
    borderRadius: radius.card,
    padding: space.md,
    gap: 4,
  },
  warnTitle: { fontSize: 16, fontWeight: '600', color: palette.warnInk },
  warnBody: { fontSize: 14, lineHeight: 20, color: palette.warnInk },
  footnote: { marginLeft: space.xs },
  errorText: { marginLeft: space.xs, color: palette.danger },
  pill: {
    marginTop: space.md,
    minHeight: 52,
    borderRadius: radius.pill,
    backgroundColor: palette.pill,
    alignItems: 'center',
    justifyContent: 'center',
  },
  pillPressed: { opacity: 0.85 },
  brand: {
    flexDirection: 'row',
    alignItems: 'center',
    justifyContent: 'center',
    gap: 6,
    marginTop: space.lg,
    opacity: 0.5,
  },
});

export default ControlCenter;