@yannelli/paseo-linear-plugin 1.0.0 → 1.1.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/OVERVIEW.md +19 -1
- package/README.md +77 -6
- package/client/access.tsx +66 -0
- package/client/agent-options.ts +12 -2
- package/client/agent-preferences.ts +112 -0
- package/client/browser.tsx +12 -9
- package/client/graph-labels.ts +137 -0
- package/client/launch-choices.ts +18 -5
- package/client/launch-plan.ts +88 -39
- package/client/launch.tsx +9 -4
- package/client/live-agents.tsx +407 -0
- package/client/live-cursor.tsx +291 -0
- package/client/live-feed.tsx +151 -0
- package/client/live-graph.tsx +434 -0
- package/client/live-header.tsx +312 -0
- package/client/live-issues.tsx +345 -0
- package/client/live-map.tsx +345 -0
- package/client/live-panel.tsx +444 -0
- package/client/live-timeline.ts +341 -0
- package/client/pill.tsx +393 -0
- package/client/project-init.tsx +168 -0
- package/client/settings-access.tsx +56 -0
- package/client/settings-live.tsx +121 -0
- package/client/settings-projects.tsx +9 -0
- package/index.client.tsx +39 -1
- package/index.server.ts +24 -4
- package/package.json +1 -1
- package/server/agent-runs.ts +299 -0
- package/server/handlers.ts +12 -1
- package/server/init.ts +64 -0
- package/server/links.ts +153 -0
- package/server/live.ts +334 -0
- package/server/stores.ts +119 -0
- package/server/sync.ts +127 -0
- package/shared/activity.ts +308 -0
- package/shared/graph-geometry.ts +122 -0
- package/shared/graph-model.ts +319 -0
- package/shared/links.ts +252 -0
- package/shared/live.ts +200 -0
- package/shared/map-model.ts +192 -0
- package/shared/project-setup.ts +55 -0
- package/shared/prompts.ts +16 -1
- package/shared/settings.ts +57 -1
- package/shared/shell-activity.ts +309 -0
- package/shared/shell-lexer.ts +180 -0
- package/shared/subagents.ts +106 -0
- package/shared/todo-sync.ts +158 -0
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { Animated, type LayoutChangeEvent, Pressable, StyleSheet, Text, View } from "react-native";
|
|
3
|
+
import type { Placed, Point } from "../shared/graph-geometry";
|
|
4
|
+
import { focusNode, type Graph, type GraphEdge, type GraphNode, graphKey, layoutGraph } from "../shared/graph-model";
|
|
5
|
+
import type { IssueDetail, WorkflowState } from "../shared/linear";
|
|
6
|
+
import { captionWidth, graphLabels, HOVER_GAP, type Label, nodeSize } from "./graph-labels";
|
|
7
|
+
import type { CursorAgent } from "./live-agents";
|
|
8
|
+
import { AgentCursor, CAPTION_ROOM } from "./live-cursor";
|
|
9
|
+
import { type IssueProgress, MONO } from "./live-issues";
|
|
10
|
+
import { NATIVE_DRIVER, type OwnerColors } from "./live-timeline";
|
|
11
|
+
import { StateIcon, type Theme } from "./ui";
|
|
12
|
+
|
|
13
|
+
// Edges are thin Views rotated to their angle, since plugins have no SVG. Each layout starts
|
|
14
|
+
// from the last one, so nodes stay put as files arrive and an agent glides between them.
|
|
15
|
+
|
|
16
|
+
export interface GraphViewProps {
|
|
17
|
+
theme: Theme;
|
|
18
|
+
compact: boolean;
|
|
19
|
+
/** The issues column is collapsed, so the graph can use more height. */
|
|
20
|
+
full: boolean;
|
|
21
|
+
graph: Graph;
|
|
22
|
+
/** The links between files have loaded at least once. */
|
|
23
|
+
ready: boolean;
|
|
24
|
+
issue: IssueDetail;
|
|
25
|
+
progress: IssueProgress;
|
|
26
|
+
owners: OwnerColors;
|
|
27
|
+
agents: readonly CursorAgent[];
|
|
28
|
+
focus: string | null;
|
|
29
|
+
onFocus(key: string): void;
|
|
30
|
+
reduceMotion: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface EdgeStyle {
|
|
34
|
+
color: string;
|
|
35
|
+
thickness: number;
|
|
36
|
+
opacity: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const EdgeLine = memo(function EdgeLine(props: { from: Point; to: Point; style: EdgeStyle }) {
|
|
40
|
+
const { from, to, style } = props;
|
|
41
|
+
const length = Math.hypot(to.x - from.x, to.y - from.y);
|
|
42
|
+
const angle = Math.atan2(to.y - from.y, to.x - from.x);
|
|
43
|
+
const line = {
|
|
44
|
+
position: "absolute",
|
|
45
|
+
left: (from.x + to.x) / 2 - length / 2,
|
|
46
|
+
top: (from.y + to.y) / 2 - style.thickness / 2,
|
|
47
|
+
width: length,
|
|
48
|
+
height: style.thickness,
|
|
49
|
+
borderRadius: style.thickness / 2,
|
|
50
|
+
backgroundColor: style.color,
|
|
51
|
+
opacity: style.opacity,
|
|
52
|
+
transform: [{ rotate: `${angle}rad` }],
|
|
53
|
+
} as const;
|
|
54
|
+
return <View pointerEvents="none" style={line} />;
|
|
55
|
+
}, sameEdge);
|
|
56
|
+
|
|
57
|
+
function sameEdge(a: { from: Point; to: Point; style: EdgeStyle }, b: { from: Point; to: Point; style: EdgeStyle }) {
|
|
58
|
+
return (
|
|
59
|
+
a.from === b.from &&
|
|
60
|
+
a.to === b.to &&
|
|
61
|
+
a.style.color === b.style.color &&
|
|
62
|
+
a.style.thickness === b.style.thickness &&
|
|
63
|
+
a.style.opacity === b.style.opacity
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface NodeProps {
|
|
68
|
+
theme: Theme;
|
|
69
|
+
node: GraphNode;
|
|
70
|
+
at: Placed;
|
|
71
|
+
color: string;
|
|
72
|
+
state: WorkflowState | null;
|
|
73
|
+
dim: boolean;
|
|
74
|
+
selected: boolean;
|
|
75
|
+
/** An agent is working on it. */
|
|
76
|
+
current: boolean;
|
|
77
|
+
label: Label | null;
|
|
78
|
+
reduce: boolean;
|
|
79
|
+
onPress(id: string): void;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Nodes are rebuilt on every timeline update; only these fields change how one looks.
|
|
83
|
+
function sameNode(a: NodeProps, b: NodeProps): boolean {
|
|
84
|
+
const x = a.node;
|
|
85
|
+
const y = b.node;
|
|
86
|
+
return (
|
|
87
|
+
a.at === b.at &&
|
|
88
|
+
a.color === b.color &&
|
|
89
|
+
a.state === b.state &&
|
|
90
|
+
a.dim === b.dim &&
|
|
91
|
+
a.selected === b.selected &&
|
|
92
|
+
a.current === b.current &&
|
|
93
|
+
a.label?.left === b.label?.left &&
|
|
94
|
+
a.label?.top === b.label?.top &&
|
|
95
|
+
a.label?.lines[0] === b.label?.lines[0] &&
|
|
96
|
+
a.label?.lines[1] === b.label?.lines[1] &&
|
|
97
|
+
a.theme === b.theme &&
|
|
98
|
+
a.reduce === b.reduce &&
|
|
99
|
+
a.onPress === b.onPress &&
|
|
100
|
+
x.id === y.id &&
|
|
101
|
+
x.owner === y.owner &&
|
|
102
|
+
x.tile?.touch === y.tile?.touch
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const GraphNodeView = memo(function GraphNodeView(props: NodeProps) {
|
|
107
|
+
const { theme, node, at, color, label } = props;
|
|
108
|
+
const { colors } = theme;
|
|
109
|
+
const size = nodeSize(node);
|
|
110
|
+
const [enter] = useState(() => new Animated.Value(props.reduce ? 1 : 0));
|
|
111
|
+
const [beat] = useState(() => new Animated.Value(1));
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
if (props.reduce) return;
|
|
114
|
+
const animation = Animated.spring(enter, { toValue: 1, friction: 6, tension: 120, useNativeDriver: NATIVE_DRIVER });
|
|
115
|
+
animation.start();
|
|
116
|
+
return () => animation.stop();
|
|
117
|
+
}, [enter, props.reduce]);
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
if (!props.current || props.reduce) return;
|
|
120
|
+
const loop = Animated.loop(
|
|
121
|
+
Animated.sequence([
|
|
122
|
+
Animated.timing(beat, { toValue: 1.3, duration: 550, useNativeDriver: NATIVE_DRIVER }),
|
|
123
|
+
Animated.timing(beat, { toValue: 1, duration: 550, useNativeDriver: NATIVE_DRIVER }),
|
|
124
|
+
]),
|
|
125
|
+
);
|
|
126
|
+
loop.start();
|
|
127
|
+
return () => {
|
|
128
|
+
loop.stop();
|
|
129
|
+
beat.setValue(1);
|
|
130
|
+
};
|
|
131
|
+
}, [props.current, props.reduce, beat]);
|
|
132
|
+
const touched = node.kind === "file" && node.tile !== null && node.tile.touch !== "none";
|
|
133
|
+
const styles = useMemo(() => {
|
|
134
|
+
const unplanned = node.kind === "file" && touched && !node.tile?.onMap;
|
|
135
|
+
const tone = unplanned ? colors.statusWarning : color;
|
|
136
|
+
const align = label && at.side === "left" ? "right" : label && at.side === "right" ? "left" : "center";
|
|
137
|
+
return {
|
|
138
|
+
wrap: {
|
|
139
|
+
position: "absolute",
|
|
140
|
+
left: at.x - size / 2,
|
|
141
|
+
top: at.y - size / 2,
|
|
142
|
+
width: size,
|
|
143
|
+
height: size,
|
|
144
|
+
opacity: props.dim ? 0.22 : 1,
|
|
145
|
+
},
|
|
146
|
+
shape: {
|
|
147
|
+
width: size,
|
|
148
|
+
height: size,
|
|
149
|
+
borderRadius: size / 2,
|
|
150
|
+
alignItems: "center",
|
|
151
|
+
justifyContent: "center",
|
|
152
|
+
borderWidth: node.kind === "file" ? 1.5 : 2,
|
|
153
|
+
borderColor: props.selected ? colors.foreground : props.current ? colors.accent : tone,
|
|
154
|
+
backgroundColor: touched ? tone : node.kind === "file" ? "transparent" : colors.surface1,
|
|
155
|
+
transform: [{ scale: Animated.multiply(enter, beat) }],
|
|
156
|
+
opacity: enter,
|
|
157
|
+
},
|
|
158
|
+
tint: { ...StyleSheet.absoluteFillObject, borderRadius: size / 2, backgroundColor: tone, opacity: 0.2 },
|
|
159
|
+
label: label
|
|
160
|
+
? {
|
|
161
|
+
position: "absolute" as const,
|
|
162
|
+
left: label.left - (at.x - size / 2),
|
|
163
|
+
top: label.top - (at.y - size / 2),
|
|
164
|
+
width: label.width,
|
|
165
|
+
}
|
|
166
|
+
: {},
|
|
167
|
+
first: {
|
|
168
|
+
color: node.kind === "file" && !props.selected ? colors.foregroundMuted : colors.foreground,
|
|
169
|
+
fontFamily: MONO,
|
|
170
|
+
fontSize: node.kind === "issue" ? 12 : 10,
|
|
171
|
+
fontWeight: node.kind === "file" ? "400" : "700",
|
|
172
|
+
textAlign: align,
|
|
173
|
+
},
|
|
174
|
+
second: { color: colors.foregroundMuted, fontSize: 10, textAlign: align },
|
|
175
|
+
} as const;
|
|
176
|
+
}, [node, size, at, color, colors, touched, label, props.dim, props.selected, props.current, enter, beat]);
|
|
177
|
+
const press = useCallback(() => props.onPress(node.id), [props.onPress, node.id]);
|
|
178
|
+
return (
|
|
179
|
+
<View style={styles.wrap} pointerEvents="box-none">
|
|
180
|
+
<Pressable accessibilityRole="button" accessibilityLabel={node.path ?? node.label} hitSlop={8} onPress={press}>
|
|
181
|
+
<Animated.View style={styles.shape}>
|
|
182
|
+
{node.kind !== "file" ? <View style={styles.tint} /> : null}
|
|
183
|
+
{props.state ? <StateIcon state={props.state} size={node.kind === "issue" ? 18 : 14} /> : null}
|
|
184
|
+
</Animated.View>
|
|
185
|
+
</Pressable>
|
|
186
|
+
{label ? (
|
|
187
|
+
<View style={styles.label} pointerEvents="none">
|
|
188
|
+
<Text numberOfLines={1} style={styles.first}>
|
|
189
|
+
{label.lines[0]}
|
|
190
|
+
</Text>
|
|
191
|
+
{label.lines[1] ? (
|
|
192
|
+
<Text numberOfLines={1} style={styles.second}>
|
|
193
|
+
{label.lines[1]}
|
|
194
|
+
</Text>
|
|
195
|
+
) : null}
|
|
196
|
+
</View>
|
|
197
|
+
) : null}
|
|
198
|
+
</View>
|
|
199
|
+
);
|
|
200
|
+
}, sameNode);
|
|
201
|
+
|
|
202
|
+
function caption(node: GraphNode, issue: IssueDetail, graph: Graph): string {
|
|
203
|
+
if (node.kind === "issue") return `${issue.identifier} · ${issue.title}`;
|
|
204
|
+
if (node.kind === "subissue") {
|
|
205
|
+
const child = issue.children.find((entry) => entry.identifier.toUpperCase() === node.label);
|
|
206
|
+
const folded = node.folded > 0 ? ` · ${node.folded} more files not shown` : "";
|
|
207
|
+
return `${node.label} · ${child?.state.name ?? ""} · ${child?.title ?? ""}${folded}`;
|
|
208
|
+
}
|
|
209
|
+
const tile = node.tile;
|
|
210
|
+
const where = tile?.onMap ? `on the map for ${node.owner}` : "not on the map";
|
|
211
|
+
const touch = !tile || tile.touch === "none" ? "not touched yet" : tile.touch;
|
|
212
|
+
const lines = tile && (tile.added > 0 || tile.removed > 0) ? ` · +${tile.added} -${tile.removed}` : "";
|
|
213
|
+
const linked = graph.edges.filter((edge) => edge.kind !== "owns" && (edge.from === node.id || edge.to === node.id)).length;
|
|
214
|
+
const links = linked > 0 ? ` · ${linked} linked ${linked === 1 ? "file" : "files"}` : "";
|
|
215
|
+
return `${node.path} · ${touch} · ${where}${links}${lines}`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function LiveGraph(props: GraphViewProps) {
|
|
219
|
+
const { theme, graph, owners, focus, issue, progress, ready } = props;
|
|
220
|
+
const { colors } = theme;
|
|
221
|
+
const [width, setWidth] = useState(0);
|
|
222
|
+
const [selected, setSelected] = useState<string | null>(null);
|
|
223
|
+
const measure = useCallback((event: LayoutChangeEvent) => {
|
|
224
|
+
setWidth(Math.round(event.nativeEvent.layout.width));
|
|
225
|
+
}, []);
|
|
226
|
+
const count = graph.nodes.length;
|
|
227
|
+
const height = props.compact
|
|
228
|
+
? 440
|
|
229
|
+
: props.full
|
|
230
|
+
? Math.min(820, Math.max(560, 360 + count * 5))
|
|
231
|
+
: Math.min(680, Math.max(460, 300 + count * 4));
|
|
232
|
+
const shown = ready && width > 0;
|
|
233
|
+
const layoutKey = `${graphKey(graph)}@${width}x${height}`;
|
|
234
|
+
const previous = useRef<ReadonlyMap<string, Placed>>(new Map());
|
|
235
|
+
// The layout depends only on the nodes, edges, and size; graph changes on every timeline update.
|
|
236
|
+
const positions = useMemo(() => {
|
|
237
|
+
if (!shown) return new Map<string, Placed>();
|
|
238
|
+
const next = layoutGraph(graph, width, height, previous.current);
|
|
239
|
+
previous.current = next;
|
|
240
|
+
return next;
|
|
241
|
+
}, [layoutKey, shown]);
|
|
242
|
+
const byId = useMemo(() => new Map(graph.nodes.map((node) => [node.id, node])), [graph.nodes]);
|
|
243
|
+
const states = useMemo(() => {
|
|
244
|
+
const map = new Map<string, WorkflowState>([[graph.root, issue.state]]);
|
|
245
|
+
for (const child of issue.children) map.set(`sub:${child.identifier.toUpperCase()}`, child.state);
|
|
246
|
+
return map;
|
|
247
|
+
}, [graph.root, issue]);
|
|
248
|
+
const targets = useMemo(
|
|
249
|
+
() => props.agents.map((agent) => ({ agent, target: focusNode(graph, agent.focus) })),
|
|
250
|
+
[props.agents, graph],
|
|
251
|
+
);
|
|
252
|
+
const working = useMemo(
|
|
253
|
+
() => new Set(targets.filter(({ agent }) => agent.running).map(({ target }) => target)),
|
|
254
|
+
[targets],
|
|
255
|
+
);
|
|
256
|
+
// The edges at the node a running agent works on take its color.
|
|
257
|
+
const trail = useMemo(() => {
|
|
258
|
+
const lit = new Map<string, string>();
|
|
259
|
+
const colorOf = new Map(targets.filter(({ agent }) => agent.running).map(({ agent, target }) => [target, agent.color]));
|
|
260
|
+
for (const edge of graph.edges) {
|
|
261
|
+
const color = colorOf.get(edge.from) ?? colorOf.get(edge.to);
|
|
262
|
+
if (color) lit.set(edge.id, color);
|
|
263
|
+
}
|
|
264
|
+
return lit;
|
|
265
|
+
}, [targets, graph.edges]);
|
|
266
|
+
const nodeColor = useCallback(
|
|
267
|
+
(node: GraphNode) => (node.owner ? owners.get(node.owner.toUpperCase()) : undefined) ?? colors.foregroundMuted,
|
|
268
|
+
[owners, colors.foregroundMuted],
|
|
269
|
+
);
|
|
270
|
+
const dimmed = useCallback(
|
|
271
|
+
(node: GraphNode | undefined) =>
|
|
272
|
+
focus !== null && node !== undefined && node.kind === "file" && node.owner?.toUpperCase() !== focus,
|
|
273
|
+
[focus],
|
|
274
|
+
);
|
|
275
|
+
const edgeStyle = (edge: GraphEdge): EdgeStyle => {
|
|
276
|
+
const a = byId.get(edge.from);
|
|
277
|
+
const b = byId.get(edge.to);
|
|
278
|
+
const lit = trail.get(edge.id);
|
|
279
|
+
if (lit) return { color: lit, thickness: 2.5, opacity: 0.9 };
|
|
280
|
+
const faded = dimmed(a) || dimmed(b) ? 0.3 : 1;
|
|
281
|
+
if (edge.kind === "owns") {
|
|
282
|
+
const file = a?.kind === "file" ? a : b?.kind === "file" ? b : undefined;
|
|
283
|
+
const sub = a?.kind === "subissue" ? a : b;
|
|
284
|
+
if (file) return { color: nodeColor(file), thickness: 1, opacity: 0.28 * faded };
|
|
285
|
+
return { color: sub ? nodeColor(sub) : colors.border, thickness: 1.5, opacity: 0.55 };
|
|
286
|
+
}
|
|
287
|
+
const shared = a?.owner && a.owner === b?.owner ? nodeColor(a) : colors.foregroundMuted;
|
|
288
|
+
if (edge.kind === "import") return { color: shared, thickness: 1.2, opacity: 0.55 * faded };
|
|
289
|
+
if (edge.kind === "link") return { color: shared, thickness: 1, opacity: 0.4 * faded };
|
|
290
|
+
return { color: colors.foregroundMuted, thickness: 0.8, opacity: 0.25 * faded };
|
|
291
|
+
};
|
|
292
|
+
// Agents on the same node hover side by side.
|
|
293
|
+
const hovers = useMemo(() => {
|
|
294
|
+
const seen = new Map<string, number>();
|
|
295
|
+
return new Map(
|
|
296
|
+
targets.map(({ agent, target }) => {
|
|
297
|
+
const slot = seen.get(target) ?? 0;
|
|
298
|
+
seen.set(target, slot + 1);
|
|
299
|
+
const hover = (id: string): Point | null => {
|
|
300
|
+
const at = positions.get(id);
|
|
301
|
+
const node = byId.get(id);
|
|
302
|
+
if (!at || !node) return null;
|
|
303
|
+
return { x: at.x + slot * 30, y: at.y - nodeSize(node) / 2 - HOVER_GAP };
|
|
304
|
+
};
|
|
305
|
+
return [agent.id, hover] as const;
|
|
306
|
+
}),
|
|
307
|
+
);
|
|
308
|
+
}, [targets, positions, byId]);
|
|
309
|
+
const cursors = useMemo(
|
|
310
|
+
() =>
|
|
311
|
+
targets.flatMap(({ agent, target }) => {
|
|
312
|
+
const at = hovers.get(agent.id)?.(target);
|
|
313
|
+
if (!at) return [];
|
|
314
|
+
const text = agent.caption ?? (agent.kind === "child" ? agent.label : null);
|
|
315
|
+
return [{ at, caption: captionWidth(text), flip: at.x > width - CAPTION_ROOM }];
|
|
316
|
+
}),
|
|
317
|
+
[targets, hovers, width],
|
|
318
|
+
);
|
|
319
|
+
const labels = useMemo(
|
|
320
|
+
() => graphLabels({ graph, positions, issue, progress, selected, working, cursors, width, height }),
|
|
321
|
+
[graph.nodes, positions, issue, progress, selected, working, cursors, width, height],
|
|
322
|
+
);
|
|
323
|
+
const press = useCallback(
|
|
324
|
+
(id: string) => {
|
|
325
|
+
setSelected((current) => (current === id ? null : id));
|
|
326
|
+
const node = byId.get(id);
|
|
327
|
+
if (node?.kind === "subissue" || node?.kind === "issue") props.onFocus(node.label.toUpperCase());
|
|
328
|
+
},
|
|
329
|
+
[byId, props.onFocus],
|
|
330
|
+
);
|
|
331
|
+
const styles = useMemo(
|
|
332
|
+
() =>
|
|
333
|
+
({
|
|
334
|
+
canvas: { height, width: "100%", overflow: "hidden" },
|
|
335
|
+
waiting: { ...StyleSheet.absoluteFillObject, alignItems: "center", justifyContent: "center" },
|
|
336
|
+
waitingText: { color: colors.foregroundMuted, fontSize: 12 },
|
|
337
|
+
folder: { position: "absolute", color: colors.foregroundMuted, fontFamily: MONO, fontSize: 10, opacity: 0.75 },
|
|
338
|
+
caption: {
|
|
339
|
+
minHeight: 32,
|
|
340
|
+
marginTop: 8,
|
|
341
|
+
paddingHorizontal: 10,
|
|
342
|
+
paddingVertical: 8,
|
|
343
|
+
borderRadius: 8,
|
|
344
|
+
backgroundColor: colors.surface1,
|
|
345
|
+
},
|
|
346
|
+
captionText: { color: colors.foreground, fontSize: 12, fontFamily: MONO },
|
|
347
|
+
hint: { color: colors.foregroundMuted, fontSize: 12 },
|
|
348
|
+
}) as const,
|
|
349
|
+
[height, colors],
|
|
350
|
+
);
|
|
351
|
+
const selectedNode = selected ? byId.get(selected) : undefined;
|
|
352
|
+
const ordered = [...graph.edges].sort((a, b) => Number(trail.has(a.id)) - Number(trail.has(b.id)));
|
|
353
|
+
return (
|
|
354
|
+
<View>
|
|
355
|
+
<View style={styles.canvas} onLayout={measure}>
|
|
356
|
+
{!ready ? (
|
|
357
|
+
<View style={styles.waiting}>
|
|
358
|
+
<Text style={styles.waitingText}>Linking files…</Text>
|
|
359
|
+
</View>
|
|
360
|
+
) : null}
|
|
361
|
+
{shown
|
|
362
|
+
? ordered.map((edge) => {
|
|
363
|
+
const from = positions.get(edge.from);
|
|
364
|
+
const to = positions.get(edge.to);
|
|
365
|
+
if (!from || !to) return null;
|
|
366
|
+
return <EdgeLine key={edge.id} from={from} to={to} style={edgeStyle(edge)} />;
|
|
367
|
+
})
|
|
368
|
+
: null}
|
|
369
|
+
{shown
|
|
370
|
+
? labels.folders.map((folder) => (
|
|
371
|
+
<Text
|
|
372
|
+
key={folder.folder}
|
|
373
|
+
pointerEvents="none"
|
|
374
|
+
numberOfLines={1}
|
|
375
|
+
style={[styles.folder, { left: folder.left, top: folder.top, width: folder.width }]}
|
|
376
|
+
>
|
|
377
|
+
{folder.folder === "." ? "/" : `${folder.folder}/`}
|
|
378
|
+
</Text>
|
|
379
|
+
))
|
|
380
|
+
: null}
|
|
381
|
+
{shown
|
|
382
|
+
? graph.nodes.map((node) => {
|
|
383
|
+
const at = positions.get(node.id);
|
|
384
|
+
if (!at) return null;
|
|
385
|
+
return (
|
|
386
|
+
<GraphNodeView
|
|
387
|
+
key={node.id}
|
|
388
|
+
theme={theme}
|
|
389
|
+
node={node}
|
|
390
|
+
at={at}
|
|
391
|
+
color={nodeColor(node)}
|
|
392
|
+
state={states.get(node.id) ?? null}
|
|
393
|
+
dim={dimmed(node)}
|
|
394
|
+
selected={selected === node.id}
|
|
395
|
+
current={working.has(node.id)}
|
|
396
|
+
label={labels.nodes.get(node.id) ?? null}
|
|
397
|
+
reduce={props.reduceMotion}
|
|
398
|
+
onPress={press}
|
|
399
|
+
/>
|
|
400
|
+
);
|
|
401
|
+
})
|
|
402
|
+
: null}
|
|
403
|
+
{shown
|
|
404
|
+
? targets.map(({ agent, target }) => {
|
|
405
|
+
const hover = hovers.get(agent.id);
|
|
406
|
+
if (!hover) return null;
|
|
407
|
+
return (
|
|
408
|
+
<AgentCursor
|
|
409
|
+
key={agent.id}
|
|
410
|
+
theme={theme}
|
|
411
|
+
agent={agent}
|
|
412
|
+
graph={graph}
|
|
413
|
+
target={target}
|
|
414
|
+
layoutKey={layoutKey}
|
|
415
|
+
hover={hover}
|
|
416
|
+
width={width}
|
|
417
|
+
reduce={props.reduceMotion}
|
|
418
|
+
/>
|
|
419
|
+
);
|
|
420
|
+
})
|
|
421
|
+
: null}
|
|
422
|
+
</View>
|
|
423
|
+
<View style={styles.caption}>
|
|
424
|
+
{selectedNode ? (
|
|
425
|
+
<Text numberOfLines={2} style={styles.captionText}>
|
|
426
|
+
{caption(selectedNode, issue, graph)}
|
|
427
|
+
</Text>
|
|
428
|
+
) : (
|
|
429
|
+
<Text style={styles.hint}>Select a node to see its file or issue.</Text>
|
|
430
|
+
)}
|
|
431
|
+
</View>
|
|
432
|
+
</View>
|
|
433
|
+
);
|
|
434
|
+
}
|