feat(expo-router): add Expo Router support for Screens and navigations and introduce Steps API
- Adds Expo Router integration with a new Screens view and a Steps API to surface screens and their transitions. - Extends codegraph extraction/resolution to handle namespace objects, React hook bindings for handlers, and Swift RN bridge evidence; introduces per-site guard arguments and trigger metadata, enabling richer flow analysis across JS ↔ native boundaries. - Introduces UI and data-model changes to represent conditions as words (WHEN/AND/OR/NOT), display per-site call arguments, and show what fires a site (triggers). Adds new utilities (ui/conditions.ts) and updates ScreensView and StepsView to render scenarios with multiple sites and “ways” counts. - Implements site readers for WHEN/ARGS/TRIGGER, and wiring to expose steps via API endpoints (including /api/steps); enhances tests to cover namespace resolution, useCallback-driven handlers, and inline RN event listeners. - Updates styling and templates to reflect the new wording, scenario rows, and per-site details, including NOT instead of leading negation strings and multi-way links. - Documents and reflects changes in changelog and design docs to describe Expo Router integration and the Steps surface.
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Conditions, as a reader says them.
|
||||
*
|
||||
* A `when` arrives from the graph as code joined by our own operators: the
|
||||
* guards along a chain joined with ` && `, a negated one wrapped as `!(…)`,
|
||||
* and — on a link with several call sites — the sites' conditions joined
|
||||
* with ` || `. The code inside one guard stays code (`isUploadInProgress ||
|
||||
* elapsed < 5000` is what the source says); the joins are ours, and ours read
|
||||
* as words — WHEN, AND, OR, NOT — set in capitals and a little bolder.
|
||||
*
|
||||
* A link with several sites is several scenarios, not one long condition:
|
||||
* four early returns that each go home are four rows, and the clauses every
|
||||
* row shares — the same first guard on all four — are said once above them.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The top-level terms of `text` around `sep`, respecting brackets and
|
||||
* strings. `splitTop('a && (b || c)', ' && ')` → `['a', '(b || c)']`.
|
||||
*/
|
||||
export function splitTop(text: string, sep: ' && ' | ' || '): string[] {
|
||||
const out: string[] = [];
|
||||
let depth = 0;
|
||||
let quote: string | null = null;
|
||||
let start = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i]!;
|
||||
if (quote !== null) {
|
||||
if (ch === '\\') i++;
|
||||
else if (ch === quote) quote = null;
|
||||
continue;
|
||||
}
|
||||
if (ch === "'" || ch === '"' || ch === '`') {
|
||||
quote = ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === '(' || ch === '[' || ch === '{') {
|
||||
depth++;
|
||||
continue;
|
||||
}
|
||||
if (ch === ')' || ch === ']' || ch === '}') {
|
||||
depth = Math.max(0, depth - 1);
|
||||
continue;
|
||||
}
|
||||
if (depth === 0 && text.startsWith(sep, i)) {
|
||||
out.push(text.slice(start, i).trim());
|
||||
start = i + sep.length;
|
||||
i += sep.length - 1;
|
||||
}
|
||||
}
|
||||
out.push(text.slice(start).trim());
|
||||
return out.filter((c) => c.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* The top-level `&&` terms of a condition, in the order they were tested —
|
||||
* the outermost guard first, the one decided at the call last. A condition
|
||||
* joined by a top-level `||` (several scenarios merged) has no single
|
||||
* innermost term and comes back whole.
|
||||
*/
|
||||
export function clauses(when: string): string[] {
|
||||
if (splitTop(when, ' || ').length > 1) return [when.trim()];
|
||||
return splitTop(when, ' && ');
|
||||
}
|
||||
|
||||
/**
|
||||
* One word of a condition: a keyword we add (WHEN, AND, OR, NOT — set in
|
||||
* capitals and a little bolder, so the joins read at a glance and the code
|
||||
* between them reads as code), or a run of the code itself.
|
||||
*/
|
||||
export type WordToken = { kw: true; text: 'WHEN' | 'AND' | 'OR' | 'NOT' } | { kw: false; text: string };
|
||||
|
||||
const KW = (text: 'WHEN' | 'AND' | 'OR' | 'NOT'): WordToken => ({ kw: true, text });
|
||||
const CODE = (text: string): WordToken => ({ kw: false, text });
|
||||
|
||||
/** `!(a || b)` → NOT `(a || b)`; `!busy` → NOT `busy`; code otherwise untouched. */
|
||||
export function clauseTokens(clause: string): WordToken[] {
|
||||
const text = clause.trim();
|
||||
if (text.startsWith('!(') && closesAtEnd(text, 1)) return [KW('NOT'), CODE(text.slice(1))];
|
||||
if (/^![A-Za-z_$][\w$.?]*$/.test(text)) return [KW('NOT'), CODE(text.slice(1))];
|
||||
return [CODE(text)];
|
||||
}
|
||||
|
||||
/** {@link clauseTokens} as one string — for a pill, which has no markup. */
|
||||
export function clauseWords(clause: string): string {
|
||||
return joinTokens(clauseTokens(clause));
|
||||
}
|
||||
|
||||
/** Tokens joined by a keyword: `a AND b AND c`. */
|
||||
function joinWith(groups: readonly WordToken[][], kw: 'AND' | 'OR'): WordToken[] {
|
||||
const out: WordToken[] = [];
|
||||
groups.forEach((g, i) => {
|
||||
if (i > 0) out.push(KW(kw));
|
||||
out.push(...g);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
export function joinTokens(tokens: readonly WordToken[]): string {
|
||||
return tokens.map((t) => t.text).join(' ');
|
||||
}
|
||||
|
||||
/** Whether the bracket opened at `open` closes on the last character. */
|
||||
function closesAtEnd(text: string, open: number): boolean {
|
||||
let depth = 0;
|
||||
let quote: string | null = null;
|
||||
for (let i = open; i < text.length; i++) {
|
||||
const ch = text[i]!;
|
||||
if (quote !== null) {
|
||||
if (ch === '\\') i++;
|
||||
else if (ch === quote) quote = null;
|
||||
continue;
|
||||
}
|
||||
if (ch === "'" || ch === '"' || ch === '`') quote = ch;
|
||||
else if (ch === '(') depth++;
|
||||
else if (ch === ')') {
|
||||
depth--;
|
||||
if (depth === 0) return i === text.length - 1;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** The same clause tested twice along a chain (two early returns with one condition) is said once. */
|
||||
function distinct(list: readonly string[]): string[] {
|
||||
return list.filter((c, i) => list.indexOf(c) === i);
|
||||
}
|
||||
|
||||
/** A whole `when` as tokens: scenarios joined by OR, each its guards joined by AND. Empty when unconditional. */
|
||||
export function whenTokens(when: string): WordToken[] {
|
||||
if (!when) return [];
|
||||
return joinWith(
|
||||
splitTop(when, ' || ').map((scenario) => joinWith(distinct(splitTop(scenario, ' && ')).map(clauseTokens), 'AND')),
|
||||
'OR'
|
||||
);
|
||||
}
|
||||
|
||||
/** {@link whenTokens} led by WHEN, or `always` when there is nothing to say. */
|
||||
export function conditionTokens(when: string): WordToken[] {
|
||||
const tokens = whenTokens(when);
|
||||
return tokens.length === 0 ? [CODE('always')] : [KW('WHEN'), ...tokens];
|
||||
}
|
||||
|
||||
/** A whole `when` as one string — for a pill, a tooltip title, a test. */
|
||||
export function whenWords(when: string): string {
|
||||
return joinTokens(whenTokens(when));
|
||||
}
|
||||
|
||||
/** The clauses every scenario shares, led by WHEN. */
|
||||
export function commonTokens(common: readonly string[]): WordToken[] {
|
||||
return common.length === 0 ? [] : [KW('WHEN'), ...joinWith(common.map(clauseTokens), 'AND')];
|
||||
}
|
||||
|
||||
export interface ScenarioRows<T> {
|
||||
/** The clauses every site shares, in chain order — said once. */
|
||||
common: string[];
|
||||
/** One row per site with what remains after the shared clauses; `rest` empty = always, given the shared ones. */
|
||||
rows: Array<{ site: T; rest: string[] }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A link's sites as scenarios. One site: its whole condition is `common` and
|
||||
* the one row has nothing left to say. Several: the longest common prefix of
|
||||
* their clause lists is `common`, and each row keeps its own tail.
|
||||
*/
|
||||
export function scenarios<T extends { when: string }>(sites: readonly T[]): ScenarioRows<T> {
|
||||
const lists = sites.map((site) => ({ site, all: site.when ? distinct(clauses(site.when)) : [] }));
|
||||
if (lists.length === 0) return { common: [], rows: [] };
|
||||
let common = lists[0]!.all.slice();
|
||||
for (const { all } of lists.slice(1)) {
|
||||
let i = 0;
|
||||
while (i < common.length && i < all.length && common[i] === all[i]) i++;
|
||||
common = common.slice(0, i);
|
||||
}
|
||||
return {
|
||||
common,
|
||||
rows: lists.map(({ site, all }) => ({ site, rest: all.slice(common.length) })),
|
||||
};
|
||||
}
|
||||
|
||||
/** The words a scenario row prints under a shared prefix: `AND x AND y` (`WHEN x` with no prefix), or `always`. */
|
||||
export function restTokens(rest: readonly string[], hasCommon: boolean): WordToken[] {
|
||||
if (rest.length === 0) return [CODE('always')];
|
||||
return [KW(hasCommon ? 'AND' : 'WHEN'), ...joinWith(rest.map(clauseTokens), 'AND')];
|
||||
}
|
||||
|
||||
export function restWords(rest: readonly string[], hasCommon: boolean): string {
|
||||
return joinTokens(restTokens(rest, hasCommon));
|
||||
}
|
||||
+13
-50
@@ -29,6 +29,7 @@
|
||||
*/
|
||||
|
||||
import type { WireMapLink, WireMapModule, WireScreen, WireScreenLink, WireScreensPayload } from './wire';
|
||||
import { clauseWords, clauses } from './conditions';
|
||||
import {
|
||||
buildMapLayout,
|
||||
linkId,
|
||||
@@ -72,9 +73,10 @@ const BAND_MARGIN = 2;
|
||||
/**
|
||||
* The longest label a pill prints before an ellipsis; the tooltip and the
|
||||
* panel have the rest. Sized so the innermost clause of a typical guard
|
||||
* (`guide.dontShowAgain.captureGuide`, 32 characters) fits whole.
|
||||
* (`guide.dontShowAgain.captureGuide`, 32 characters) fits whole even as
|
||||
* `…not guide.dontShowAgain.captureGuide` — the negation is a word now.
|
||||
*/
|
||||
export const EDGE_LABEL_MAX = 36;
|
||||
export const EDGE_LABEL_MAX = 40;
|
||||
|
||||
/* ---------------------------------------------------------------- model -- */
|
||||
|
||||
@@ -246,48 +248,7 @@ export function entryLayering(
|
||||
|
||||
/* --------------------------------------------------------------- labels -- */
|
||||
|
||||
/**
|
||||
* The top-level `&&` terms of a condition, in the order they were tested —
|
||||
* the outermost guard first, the one decided at the navigation call last.
|
||||
* Brackets and strings are respected; a condition joined by a top-level `||`
|
||||
* (two transitions between one pair that merged) has no innermost term and
|
||||
* comes back whole.
|
||||
*/
|
||||
export function clauses(when: string): string[] {
|
||||
const out: string[] = [];
|
||||
let depth = 0;
|
||||
let quote: string | null = null;
|
||||
let start = 0;
|
||||
for (let i = 0; i < when.length; i++) {
|
||||
const ch = when[i]!;
|
||||
if (quote !== null) {
|
||||
if (ch === '\\') i++;
|
||||
else if (ch === quote) quote = null;
|
||||
continue;
|
||||
}
|
||||
if (ch === "'" || ch === '"' || ch === '`') {
|
||||
quote = ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === '(' || ch === '[' || ch === '{') {
|
||||
depth++;
|
||||
continue;
|
||||
}
|
||||
if (ch === ')' || ch === ']' || ch === '}') {
|
||||
depth = Math.max(0, depth - 1);
|
||||
continue;
|
||||
}
|
||||
if (depth !== 0) continue;
|
||||
if (when.startsWith(' || ', i)) return [when.trim()];
|
||||
if (when.startsWith(' && ', i)) {
|
||||
out.push(when.slice(start, i).trim());
|
||||
start = i + 4;
|
||||
i += 3;
|
||||
}
|
||||
}
|
||||
out.push(when.slice(start).trim());
|
||||
return out.filter((c) => c.length > 0);
|
||||
}
|
||||
export { clauses } from './conditions';
|
||||
|
||||
/**
|
||||
* What the connector says. Empty when unconditional and single.
|
||||
@@ -299,17 +260,19 @@ export function clauses(when: string): string[] {
|
||||
* …` on both arms of a fork); the last clause is the one that tells the two
|
||||
* apart, and the full text is a hover away.
|
||||
*/
|
||||
export function edgeLabel(links: ReadonlyArray<{ when: string }>): string {
|
||||
if (links.length === 1) {
|
||||
const when = links[0]!.when;
|
||||
export function edgeLabel(links: ReadonlyArray<{ when: string; sites?: ReadonlyArray<{ when: string }> }>): string {
|
||||
// A link with several call sites is several scenarios: count them as ways.
|
||||
const ways = links.flatMap((l) => (l.sites && l.sites.length > 1 ? l.sites.map((s) => s.when) : [l.when]));
|
||||
if (ways.length === 1) {
|
||||
const when = ways[0]!;
|
||||
if (!when) return '';
|
||||
const parts = clauses(when);
|
||||
const last = parts[parts.length - 1] ?? when;
|
||||
const last = clauseWords(parts[parts.length - 1] ?? when);
|
||||
const text = parts.length > 1 ? `…${last}` : last;
|
||||
return text.length > EDGE_LABEL_MAX ? `${text.slice(0, EDGE_LABEL_MAX - 1)}…` : text;
|
||||
}
|
||||
const conditional = links.filter((l) => l.when).length;
|
||||
return conditional > 0 ? `${links.length} ways · ${conditional} conditional` : `${links.length} ways`;
|
||||
const conditional = ways.filter((w) => w).length;
|
||||
return conditional > 0 ? `${ways.length} ways · ${conditional} conditional` : `${ways.length} ways`;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- build -- */
|
||||
|
||||
@@ -669,6 +669,7 @@ export interface WireScreenSite {
|
||||
line: number;
|
||||
href: string;
|
||||
method: string;
|
||||
/** The conditions THIS site runs under (the whole chain's plus its own); '' when unconditional. */
|
||||
when: string;
|
||||
}
|
||||
|
||||
@@ -705,6 +706,10 @@ export interface WireStepSite {
|
||||
line: number;
|
||||
/** `push /capture`, `calls`, `client.post` — what the site does, in a word or two. */
|
||||
text: string;
|
||||
/** What the site passes, abbreviated (`'userEmail', values.email`); '' for none; absent when unreadable. */
|
||||
args?: string;
|
||||
/** The conditions THIS site runs under (the whole chain's); '' when unconditional. */
|
||||
when: string;
|
||||
}
|
||||
|
||||
export interface WireStep {
|
||||
|
||||
Reference in New Issue
Block a user