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