@troyio/vite-file-router 0.4.0 → 0.4.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/README.md +64 -12
- package/devtools/app.js +961 -143
- package/devtools/index.html +147 -33
- package/devtools/style.css +1136 -231
- package/dist/devtools-app.d.ts +59 -0
- package/dist/devtools.d.ts +50 -0
- package/dist/generator.d.ts +8 -1
- package/dist/index.d.ts +9 -3
- package/dist/index.js +413 -23
- package/package.json +1 -1
package/devtools/app.js
CHANGED
|
@@ -1,143 +1,961 @@
|
|
|
1
|
-
const
|
|
2
|
-
const
|
|
3
|
-
const
|
|
4
|
-
const
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
function
|
|
93
|
-
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
1
|
+
const DATA_URL = "/__vite-file-router-data";
|
|
2
|
+
const EVENTS_URL = "/__vite-file-router-events";
|
|
3
|
+
const MATCH_URL = "/__vite-file-router-match";
|
|
4
|
+
const OPEN_URL = "/__open-in-editor";
|
|
5
|
+
const TESTER_STORAGE_KEY = "vite-file-router:tester-url";
|
|
6
|
+
|
|
7
|
+
const $ = (selector) => document.querySelector(selector);
|
|
8
|
+
const treeElement = $("#tree");
|
|
9
|
+
const statsElement = $("#stats");
|
|
10
|
+
const errorElement = $("#error");
|
|
11
|
+
const conflictsElement = $("#conflicts");
|
|
12
|
+
const filterElement = $("#filter");
|
|
13
|
+
const toggleElement = $("#toggle");
|
|
14
|
+
const refreshElement = $("#refresh");
|
|
15
|
+
const liveElement = $("#live");
|
|
16
|
+
const liveLabelElement = $("#live-label");
|
|
17
|
+
const testerForm = $("#tester");
|
|
18
|
+
const testerInput = $("#tester-input");
|
|
19
|
+
const testerResult = $("#tester-result");
|
|
20
|
+
const detailsElement = $("#details");
|
|
21
|
+
const currentElement = $("#current");
|
|
22
|
+
const currentSubtitle = $("#current-subtitle");
|
|
23
|
+
const tabPicker = $("#tab-picker");
|
|
24
|
+
const inspectTab = $("#tab-inspect");
|
|
25
|
+
const timelineTab = $("#tab-timeline");
|
|
26
|
+
const inspectView = $("#view-inspect");
|
|
27
|
+
const timelineView = $("#view-timeline");
|
|
28
|
+
const timelineCount = $("#timeline-count");
|
|
29
|
+
const timelineElement = $("#timeline");
|
|
30
|
+
const timelineFilter = $("#timeline-filter");
|
|
31
|
+
const timelineTypes = $("#timeline-types");
|
|
32
|
+
const timelineClear = $("#timeline-clear");
|
|
33
|
+
|
|
34
|
+
const MAX_TIMELINE = 200;
|
|
35
|
+
const SLOW_CALL_MS = 500;
|
|
36
|
+
|
|
37
|
+
const FILE_KINDS = [
|
|
38
|
+
["page", "Page", "page"],
|
|
39
|
+
["layout", "Layout", "layout"],
|
|
40
|
+
["loading", "Loading", "loading"],
|
|
41
|
+
["error", "Error boundary", "error"],
|
|
42
|
+
["notFound", "Not found", "404"],
|
|
43
|
+
["loader", "Loader", "loader"],
|
|
44
|
+
["action", "Action", "action"],
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
const state = {
|
|
48
|
+
data: undefined,
|
|
49
|
+
snapshot: undefined,
|
|
50
|
+
nodes: new Map(),
|
|
51
|
+
collapsed: new Set(),
|
|
52
|
+
selected: "0",
|
|
53
|
+
match: undefined,
|
|
54
|
+
matchedKeys: new Set(),
|
|
55
|
+
// App tabs reported by the dev-only client in `virtual:routes`.
|
|
56
|
+
tabs: [],
|
|
57
|
+
followedTab: undefined,
|
|
58
|
+
currentKeys: new Set(),
|
|
59
|
+
// Loader, action, and revalidation calls, oldest first.
|
|
60
|
+
activity: [],
|
|
61
|
+
clearedThrough: 0,
|
|
62
|
+
unseen: 0,
|
|
63
|
+
timelineType: "all",
|
|
64
|
+
openCalls: new Set(),
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Helpers
|
|
69
|
+
|
|
70
|
+
function el(tag, attributes = {}, ...children) {
|
|
71
|
+
const element = document.createElement(tag);
|
|
72
|
+
for (const [name, value] of Object.entries(attributes)) {
|
|
73
|
+
if (value === undefined || value === false) continue;
|
|
74
|
+
if (name === "class") element.className = value;
|
|
75
|
+
else if (name.startsWith("on")) element.addEventListener(name.slice(2), value);
|
|
76
|
+
else element.setAttribute(name, value === true ? "" : value);
|
|
77
|
+
}
|
|
78
|
+
element.append(...children.flat().filter((child) => child !== undefined && child !== null));
|
|
79
|
+
return element;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function icon(name) {
|
|
83
|
+
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
84
|
+
svg.setAttribute("aria-hidden", "true");
|
|
85
|
+
const use = document.createElementNS("http://www.w3.org/2000/svg", "use");
|
|
86
|
+
use.setAttribute("href", `#icon-${name}`);
|
|
87
|
+
svg.append(use);
|
|
88
|
+
return svg;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let toastTimer;
|
|
92
|
+
function toast(message) {
|
|
93
|
+
document.querySelector(".toast")?.remove();
|
|
94
|
+
const element = el("div", { class: "toast", role: "status" }, message);
|
|
95
|
+
document.body.append(element);
|
|
96
|
+
clearTimeout(toastTimer);
|
|
97
|
+
toastTimer = setTimeout(() => element.remove(), 2200);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function readStorage(key) {
|
|
101
|
+
try {
|
|
102
|
+
return localStorage.getItem(key) ?? "";
|
|
103
|
+
} catch {
|
|
104
|
+
return "";
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function writeStorage(key, value) {
|
|
109
|
+
try {
|
|
110
|
+
localStorage.setItem(key, value);
|
|
111
|
+
} catch {
|
|
112
|
+
// Storage can be unavailable in sandboxed frames; the tester still works.
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function firstFile(node) {
|
|
117
|
+
const file = Object.values(node.files)[0];
|
|
118
|
+
if (file) return file;
|
|
119
|
+
for (const child of node.children) {
|
|
120
|
+
const descendant = firstFile(child);
|
|
121
|
+
if (descendant) return descendant;
|
|
122
|
+
}
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The manifest omits route-group names from URLs, so read the directory name
|
|
128
|
+
* from a file inside the group: it sits at the group's depth below the routes
|
|
129
|
+
* directory.
|
|
130
|
+
*/
|
|
131
|
+
function groupName(node) {
|
|
132
|
+
const file = firstFile(node);
|
|
133
|
+
const key = [...state.nodes].find(([, entry]) => entry.node === node)?.[0];
|
|
134
|
+
if (!file || !key || !state.data) return "(group)";
|
|
135
|
+
const depth = key.split(".").length - 1;
|
|
136
|
+
const base = state.data.directory.split("/").filter(Boolean).length;
|
|
137
|
+
return file.split("/")[base + depth - 1] ?? "(group)";
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function segmentLabel(node, key) {
|
|
141
|
+
if (key === "0") return [el("span", { class: "slash" }, "/")];
|
|
142
|
+
if (node.routeGroup) return [el("span", { class: "group" }, groupName(node))];
|
|
143
|
+
const segment = node.segment;
|
|
144
|
+
const className = segment.endsWith("?")
|
|
145
|
+
? "optional"
|
|
146
|
+
: segment.startsWith(":") || segment === "*"
|
|
147
|
+
? "param"
|
|
148
|
+
: undefined;
|
|
149
|
+
return [el("span", { class: "slash" }, "/"), el("span", { class: className }, segment)];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function sameNode(node, segment) {
|
|
153
|
+
return (
|
|
154
|
+
node.fullPath === segment.fullPath &&
|
|
155
|
+
node.routeGroup === segment.routeGroup &&
|
|
156
|
+
JSON.stringify(node.files) === JSON.stringify(segment.files)
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function examplePath(fullPath) {
|
|
161
|
+
return (
|
|
162
|
+
fullPath
|
|
163
|
+
.split("/")
|
|
164
|
+
.filter((segment) => !segment.endsWith("?"))
|
|
165
|
+
.map((segment) => (segment === "*" ? "any/path" : segment.startsWith(":") ? "1" : segment))
|
|
166
|
+
.join("/") || "/"
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// Editor integration
|
|
172
|
+
|
|
173
|
+
async function openInEditor(file, button) {
|
|
174
|
+
if (!state.data) return;
|
|
175
|
+
const absolute = `${state.data.projectRoot.replace(/[\\/]$/, "")}/${file}`;
|
|
176
|
+
try {
|
|
177
|
+
const response = await fetch(`${OPEN_URL}?file=${encodeURIComponent(absolute)}`);
|
|
178
|
+
if (!response.ok) throw new Error(`status ${response.status}`);
|
|
179
|
+
button?.classList.add("opened");
|
|
180
|
+
setTimeout(() => button?.classList.remove("opened"), 900);
|
|
181
|
+
toast(`Opened ${file.split("/").pop()} in your editor`);
|
|
182
|
+
} catch (error) {
|
|
183
|
+
toast(`Couldn't open the file: ${error.message}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function fileLink(file) {
|
|
188
|
+
const button = el(
|
|
189
|
+
"button",
|
|
190
|
+
{
|
|
191
|
+
class: "file-link",
|
|
192
|
+
type: "button",
|
|
193
|
+
title: `Open ${file} in your editor`,
|
|
194
|
+
onclick: () => openInEditor(file, button),
|
|
195
|
+
},
|
|
196
|
+
// `direction: rtl` keeps the filename visible when the path is truncated;
|
|
197
|
+
// the bidi isolate stops punctuation from being reordered.
|
|
198
|
+
el("span", {}, `${file}`),
|
|
199
|
+
icon("open"),
|
|
200
|
+
);
|
|
201
|
+
return button;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
// Tree
|
|
206
|
+
|
|
207
|
+
function indexNodes(node, key = "0", parent) {
|
|
208
|
+
state.nodes.set(key, { node, parent });
|
|
209
|
+
node.children.forEach((child, index) => indexNodes(child, `${key}.${index}`, key));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function searchText(node) {
|
|
213
|
+
return [
|
|
214
|
+
node.fullPath,
|
|
215
|
+
node.routeGroup ? groupName(node) : "",
|
|
216
|
+
...node.params,
|
|
217
|
+
...Object.entries(node.files).flat(),
|
|
218
|
+
]
|
|
219
|
+
.join(" ")
|
|
220
|
+
.toLowerCase();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function renderNode(node, key, query) {
|
|
224
|
+
const childItems = node.children
|
|
225
|
+
.map((child, index) => renderNode(child, `${key}.${index}`, query))
|
|
226
|
+
.filter(Boolean);
|
|
227
|
+
if (query && !searchText(node).includes(query) && childItems.length === 0) return null;
|
|
228
|
+
|
|
229
|
+
const hasChildren = node.children.length > 0;
|
|
230
|
+
const expanded = Boolean(query) || !state.collapsed.has(key);
|
|
231
|
+
const fileKinds = FILE_KINDS.filter(([kind]) => node.files[kind]);
|
|
232
|
+
|
|
233
|
+
const row = el(
|
|
234
|
+
"div",
|
|
235
|
+
{
|
|
236
|
+
class: `row${state.matchedKeys.has(key) ? " matched" : ""}${state.currentKeys.has(key) ? " current" : ""}`,
|
|
237
|
+
role: "treeitem",
|
|
238
|
+
tabindex: state.selected === key ? "0" : "-1",
|
|
239
|
+
"aria-selected": String(state.selected === key),
|
|
240
|
+
"aria-expanded": hasChildren ? String(expanded) : undefined,
|
|
241
|
+
"data-key": key,
|
|
242
|
+
onclick: () => select(key),
|
|
243
|
+
onkeydown: (event) => onRowKey(event, key, hasChildren, expanded),
|
|
244
|
+
},
|
|
245
|
+
hasChildren
|
|
246
|
+
? el(
|
|
247
|
+
"button",
|
|
248
|
+
{
|
|
249
|
+
class: "twisty",
|
|
250
|
+
type: "button",
|
|
251
|
+
tabindex: "-1",
|
|
252
|
+
"aria-label": expanded ? "Collapse" : "Expand",
|
|
253
|
+
"aria-expanded": String(expanded),
|
|
254
|
+
onclick: (event) => {
|
|
255
|
+
event.stopPropagation();
|
|
256
|
+
toggle(key);
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
icon("chevron"),
|
|
260
|
+
)
|
|
261
|
+
: el("span", { class: "twisty-spacer" }),
|
|
262
|
+
el("span", { class: "segment" }, segmentLabel(node, key)),
|
|
263
|
+
key !== "0" && !node.routeGroup && node.fullPath !== `/${node.segment}`
|
|
264
|
+
? el("span", { class: "full-path" }, node.fullPath)
|
|
265
|
+
: undefined,
|
|
266
|
+
el(
|
|
267
|
+
"span",
|
|
268
|
+
{ class: "row-files" },
|
|
269
|
+
node.routeGroup ? el("span", { class: "chip chip-group" }, "group") : undefined,
|
|
270
|
+
fileKinds.map(([kind, label, short]) =>
|
|
271
|
+
el("span", { class: `chip chip-${kind}`, title: `${label}: ${node.files[kind]}` }, short),
|
|
272
|
+
),
|
|
273
|
+
),
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
return el(
|
|
277
|
+
"li",
|
|
278
|
+
{ role: "none" },
|
|
279
|
+
row,
|
|
280
|
+
hasChildren && expanded ? el("ul", { role: "group" }, childItems) : undefined,
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function renderTree() {
|
|
285
|
+
const query = filterElement.value.trim().toLowerCase();
|
|
286
|
+
const root = state.data && renderNode(state.data.manifest, "0", query);
|
|
287
|
+
treeElement.replaceChildren(
|
|
288
|
+
root
|
|
289
|
+
? el("ul", { role: "none" }, root)
|
|
290
|
+
: el("p", { class: "empty" }, state.data ? "No routes match this filter." : "Loading routes…"),
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
const allExpanded = state.collapsed.size === 0;
|
|
294
|
+
toggleElement.replaceChildren(icon(allExpanded ? "collapse" : "expand"));
|
|
295
|
+
toggleElement.setAttribute("aria-label", allExpanded ? "Collapse all" : "Expand all");
|
|
296
|
+
toggleElement.title = allExpanded ? "Collapse all" : "Expand all";
|
|
297
|
+
toggleElement.disabled = Boolean(query);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function toggle(key) {
|
|
301
|
+
if (state.collapsed.has(key)) state.collapsed.delete(key);
|
|
302
|
+
else state.collapsed.add(key);
|
|
303
|
+
renderTree();
|
|
304
|
+
focusRow(key);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function select(key) {
|
|
308
|
+
state.selected = key;
|
|
309
|
+
renderTree();
|
|
310
|
+
renderDetails();
|
|
311
|
+
focusRow(key);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function focusRow(key) {
|
|
315
|
+
treeElement.querySelector(`[data-key="${CSS.escape(key)}"]`)?.focus();
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function onRowKey(event, key, hasChildren, expanded) {
|
|
319
|
+
const rows = [...treeElement.querySelectorAll(".row")];
|
|
320
|
+
const index = rows.findIndex((row) => row.dataset.key === key);
|
|
321
|
+
const move = (offset) => {
|
|
322
|
+
const target = rows[index + offset];
|
|
323
|
+
if (target) select(target.dataset.key);
|
|
324
|
+
};
|
|
325
|
+
if (event.key === "ArrowDown") move(1);
|
|
326
|
+
else if (event.key === "ArrowUp") move(-1);
|
|
327
|
+
else if (event.key === "ArrowRight" && hasChildren && !expanded) toggle(key);
|
|
328
|
+
else if (event.key === "ArrowLeft" && hasChildren && expanded) toggle(key);
|
|
329
|
+
else if (event.key === "ArrowLeft") {
|
|
330
|
+
const parent = state.nodes.get(key)?.parent;
|
|
331
|
+
if (parent) select(parent);
|
|
332
|
+
} else return;
|
|
333
|
+
event.preventDefault();
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
// Summary, conflicts, and details
|
|
338
|
+
|
|
339
|
+
function renderStats() {
|
|
340
|
+
const counts = { pages: 0, dynamic: 0, layouts: 0, loaders: 0 };
|
|
341
|
+
for (const { node } of state.nodes.values()) {
|
|
342
|
+
if (node.files.page) counts.pages++;
|
|
343
|
+
if (node.files.page && /[:*]/.test(node.fullPath)) counts.dynamic++;
|
|
344
|
+
if (node.files.layout) counts.layouts++;
|
|
345
|
+
if (node.files.loader) counts.loaders++;
|
|
346
|
+
}
|
|
347
|
+
statsElement.replaceChildren(
|
|
348
|
+
...[
|
|
349
|
+
[counts.pages, counts.pages === 1 ? "page" : "pages"],
|
|
350
|
+
[counts.dynamic, "dynamic"],
|
|
351
|
+
[counts.layouts, counts.layouts === 1 ? "layout" : "layouts"],
|
|
352
|
+
[counts.loaders, counts.loaders === 1 ? "loader" : "loaders"],
|
|
353
|
+
].map(([value, label]) => el("div", { class: "stat" }, el("dt", {}, label), el("dd", {}, String(value)))),
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function renderConflicts() {
|
|
358
|
+
const conflicts = state.data?.conflicts ?? [];
|
|
359
|
+
conflictsElement.hidden = conflicts.length === 0;
|
|
360
|
+
if (!conflicts.length) return;
|
|
361
|
+
conflictsElement.replaceChildren(
|
|
362
|
+
el("h2", {}, conflicts.length === 1 ? "1 route conflict" : `${conflicts.length} route conflicts`),
|
|
363
|
+
el("p", {}, "These pages resolve to the same URL pattern. Rename or move one of them in each group."),
|
|
364
|
+
...conflicts.map(({ routePath, files }) =>
|
|
365
|
+
el(
|
|
366
|
+
"div",
|
|
367
|
+
{ class: "conflict" },
|
|
368
|
+
el("code", {}, routePath),
|
|
369
|
+
el("div", { class: "facts" }, files.map((file) => fileLink(file))),
|
|
370
|
+
),
|
|
371
|
+
),
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function fact(label, ...content) {
|
|
376
|
+
return el("div", {}, el("dt", {}, label), el("dd", {}, ...content));
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function renderDetails() {
|
|
380
|
+
const entry = state.nodes.get(state.selected) ?? state.nodes.get("0");
|
|
381
|
+
if (!entry) {
|
|
382
|
+
detailsElement.replaceChildren(el("p", { class: "empty" }, "Select a route to see its files."));
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
const { node } = entry;
|
|
386
|
+
const files = FILE_KINDS.filter(([kind]) => node.files[kind]);
|
|
387
|
+
const params = [];
|
|
388
|
+
for (let key = state.selected; key; key = state.nodes.get(key)?.parent) {
|
|
389
|
+
const current = state.nodes.get(key)?.node;
|
|
390
|
+
current?.params.forEach((param) =>
|
|
391
|
+
params.unshift({ param, optional: current.optionalParams?.includes(param) }),
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
detailsElement.replaceChildren(
|
|
396
|
+
el(
|
|
397
|
+
"div",
|
|
398
|
+
{ class: "details-body" },
|
|
399
|
+
el(
|
|
400
|
+
"div",
|
|
401
|
+
{ class: "details-path" },
|
|
402
|
+
node.routeGroup ? `${node.fullPath} · ${groupName(node)}` : node.fullPath,
|
|
403
|
+
),
|
|
404
|
+
el(
|
|
405
|
+
"dl",
|
|
406
|
+
{ class: "facts" },
|
|
407
|
+
node.routeGroup
|
|
408
|
+
? fact("Route group", el("span", { class: "muted" }, "Organizes files without adding a URL segment."))
|
|
409
|
+
: undefined,
|
|
410
|
+
params.length
|
|
411
|
+
? fact(
|
|
412
|
+
"Params",
|
|
413
|
+
el(
|
|
414
|
+
"div",
|
|
415
|
+
{ class: "params" },
|
|
416
|
+
params.map(({ param, optional }) =>
|
|
417
|
+
el("span", { class: "param-pill" }, el("span", {}, optional ? `${param}?` : param)),
|
|
418
|
+
),
|
|
419
|
+
),
|
|
420
|
+
)
|
|
421
|
+
: undefined,
|
|
422
|
+
files.length
|
|
423
|
+
? files.map(([kind, label]) => fact(label, fileLink(node.files[kind])))
|
|
424
|
+
: fact("Files", el("span", { class: "muted" }, "No route files in this directory.")),
|
|
425
|
+
node.files.page
|
|
426
|
+
? fact(
|
|
427
|
+
"Try it",
|
|
428
|
+
el(
|
|
429
|
+
"button",
|
|
430
|
+
{
|
|
431
|
+
class: "link-button",
|
|
432
|
+
type: "button",
|
|
433
|
+
onclick: () => {
|
|
434
|
+
testerInput.value = examplePath(node.fullPath);
|
|
435
|
+
runMatch();
|
|
436
|
+
},
|
|
437
|
+
},
|
|
438
|
+
`Test ${examplePath(node.fullPath)}`,
|
|
439
|
+
),
|
|
440
|
+
)
|
|
441
|
+
: undefined,
|
|
442
|
+
),
|
|
443
|
+
),
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// ---------------------------------------------------------------------------
|
|
448
|
+
// URL tester
|
|
449
|
+
|
|
450
|
+
function keysForChain(chain) {
|
|
451
|
+
const keys = new Set();
|
|
452
|
+
if (!state.data || !chain.length) return keys;
|
|
453
|
+
let key = "0";
|
|
454
|
+
let node = state.data.manifest;
|
|
455
|
+
if (!sameNode(node, chain[0])) return keys;
|
|
456
|
+
keys.add(key);
|
|
457
|
+
for (const segment of chain.slice(1)) {
|
|
458
|
+
const index = node.children.findIndex((child) => sameNode(child, segment));
|
|
459
|
+
if (index === -1) break;
|
|
460
|
+
key = `${key}.${index}`;
|
|
461
|
+
node = node.children[index];
|
|
462
|
+
keys.add(key);
|
|
463
|
+
}
|
|
464
|
+
return keys;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function renderMatch() {
|
|
468
|
+
const result = state.match;
|
|
469
|
+
if (!result) {
|
|
470
|
+
testerResult.replaceChildren();
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
if (result.failure) {
|
|
474
|
+
testerResult.replaceChildren(el("p", { class: "muted" }, result.failure));
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
if (!result.matched) {
|
|
478
|
+
testerResult.replaceChildren(
|
|
479
|
+
el(
|
|
480
|
+
"div",
|
|
481
|
+
{ class: "result-status" },
|
|
482
|
+
el("span", { class: "chip chip-error" }, "No match"),
|
|
483
|
+
el("span", { class: "result-pathname" }, result.pathname),
|
|
484
|
+
),
|
|
485
|
+
el("p", { class: "muted" }, "No route handles this URL, and there's no not-found page above it."),
|
|
486
|
+
);
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const params = Object.entries(result.params);
|
|
491
|
+
const keys = [...keysForChain(result.chain)];
|
|
492
|
+
testerResult.replaceChildren(
|
|
493
|
+
el(
|
|
494
|
+
"div",
|
|
495
|
+
{ class: "result-status" },
|
|
496
|
+
result.notFound
|
|
497
|
+
? el("span", { class: "chip chip-notFound" }, "Not-found page")
|
|
498
|
+
: el("span", { class: "chip chip-loader" }, "Matched"),
|
|
499
|
+
el("span", { class: "result-pathname" }, result.pathname),
|
|
500
|
+
),
|
|
501
|
+
el(
|
|
502
|
+
"div",
|
|
503
|
+
{ class: "crumbs", "aria-label": "Matched routes" },
|
|
504
|
+
result.chain.flatMap((segment, index) => {
|
|
505
|
+
const key = keys[index];
|
|
506
|
+
const label =
|
|
507
|
+
index === 0
|
|
508
|
+
? "/"
|
|
509
|
+
: segment.routeGroup
|
|
510
|
+
? groupName(state.nodes.get(key)?.node ?? segment)
|
|
511
|
+
: segment.segment;
|
|
512
|
+
return [
|
|
513
|
+
index > 0 ? el("span", { class: "crumb-sep", "aria-hidden": "true" }, "›") : undefined,
|
|
514
|
+
el(
|
|
515
|
+
"button",
|
|
516
|
+
{ class: "crumb", type: "button", onclick: () => key && select(key) },
|
|
517
|
+
label,
|
|
518
|
+
),
|
|
519
|
+
];
|
|
520
|
+
}),
|
|
521
|
+
),
|
|
522
|
+
el(
|
|
523
|
+
"dl",
|
|
524
|
+
{ class: "facts" },
|
|
525
|
+
params.length
|
|
526
|
+
? fact(
|
|
527
|
+
"Params",
|
|
528
|
+
el(
|
|
529
|
+
"div",
|
|
530
|
+
{ class: "params" },
|
|
531
|
+
params.map(([name, value]) =>
|
|
532
|
+
el(
|
|
533
|
+
"span",
|
|
534
|
+
{ class: "param-pill" },
|
|
535
|
+
el("span", {}, name),
|
|
536
|
+
value === undefined
|
|
537
|
+
? el("span", { class: "missing" }, "undefined")
|
|
538
|
+
: el("span", {}, value),
|
|
539
|
+
),
|
|
540
|
+
),
|
|
541
|
+
),
|
|
542
|
+
)
|
|
543
|
+
: undefined,
|
|
544
|
+
result.page ? fact("Page", fileLink(result.page)) : undefined,
|
|
545
|
+
result.notFound ? fact("Not-found page", fileLink(result.notFound)) : undefined,
|
|
546
|
+
result.layouts.length ? fact("Layouts, outermost first", result.layouts.map(fileLink)) : undefined,
|
|
547
|
+
result.loaders.length ? fact("Loaders", result.loaders.map(fileLink)) : undefined,
|
|
548
|
+
result.loading ? fact("Loading fallback", fileLink(result.loading)) : undefined,
|
|
549
|
+
result.error ? fact("Error boundary", fileLink(result.error)) : undefined,
|
|
550
|
+
),
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
async function runMatch() {
|
|
555
|
+
const url = testerInput.value.trim();
|
|
556
|
+
writeStorage(TESTER_STORAGE_KEY, url);
|
|
557
|
+
if (!url) {
|
|
558
|
+
state.match = undefined;
|
|
559
|
+
state.matchedKeys = new Set();
|
|
560
|
+
renderMatch();
|
|
561
|
+
renderTree();
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
const target = url.startsWith("/") || /^[a-z]+:/i.test(url) ? url : `/${url}`;
|
|
565
|
+
try {
|
|
566
|
+
const response = await fetch(`${MATCH_URL}?url=${encodeURIComponent(target)}`, { cache: "no-store" });
|
|
567
|
+
if (!response.ok) throw new Error(`Request failed (${response.status})`);
|
|
568
|
+
state.match = await response.json();
|
|
569
|
+
state.matchedKeys = state.match.matched ? keysForChain(state.match.chain) : new Set();
|
|
570
|
+
for (const key of state.matchedKeys) {
|
|
571
|
+
for (let parent = state.nodes.get(key)?.parent; parent; parent = state.nodes.get(parent)?.parent) {
|
|
572
|
+
state.collapsed.delete(parent);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
} catch (error) {
|
|
576
|
+
state.match = { failure: `Couldn't match the URL: ${error.message}` };
|
|
577
|
+
state.matchedKeys = new Set();
|
|
578
|
+
}
|
|
579
|
+
renderMatch();
|
|
580
|
+
renderTree();
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// ---------------------------------------------------------------------------
|
|
584
|
+
// Data and live updates
|
|
585
|
+
|
|
586
|
+
// ---------------------------------------------------------------------------
|
|
587
|
+
// Current page
|
|
588
|
+
|
|
589
|
+
function followedTab() {
|
|
590
|
+
return state.tabs.find((tab) => tab.id === state.followedTab) ?? state.tabs[0];
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function updateCurrentKeys() {
|
|
594
|
+
const tab = followedTab();
|
|
595
|
+
state.currentKeys =
|
|
596
|
+
tab?.match?.matched && state.data ? keysForChain(tab.match.chain) : new Set();
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function renderCurrent() {
|
|
600
|
+
const tab = followedTab();
|
|
601
|
+
tabPicker.hidden = state.tabs.length < 2;
|
|
602
|
+
tabPicker.replaceChildren(
|
|
603
|
+
...state.tabs.map((item, index) =>
|
|
604
|
+
el(
|
|
605
|
+
"button",
|
|
606
|
+
{
|
|
607
|
+
type: "button",
|
|
608
|
+
"aria-pressed": String(item === tab),
|
|
609
|
+
title: item.url,
|
|
610
|
+
onclick: () => {
|
|
611
|
+
state.followedTab = item.id;
|
|
612
|
+
updateCurrentKeys();
|
|
613
|
+
renderCurrent();
|
|
614
|
+
renderTree();
|
|
615
|
+
},
|
|
616
|
+
},
|
|
617
|
+
item.title || `Tab ${index + 1}`,
|
|
618
|
+
),
|
|
619
|
+
),
|
|
620
|
+
);
|
|
621
|
+
|
|
622
|
+
if (!tab) {
|
|
623
|
+
currentSubtitle.textContent = "Follows the route your app is showing.";
|
|
624
|
+
currentElement.replaceChildren(
|
|
625
|
+
el("p", { class: "current-empty" }, "Open your app in a browser tab to follow its current route."),
|
|
626
|
+
);
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
currentSubtitle.textContent =
|
|
631
|
+
state.tabs.length > 1
|
|
632
|
+
? `${state.tabs.length} app tabs are open. Pick one to follow.`
|
|
633
|
+
: "Updates as your app navigates.";
|
|
634
|
+
const { match } = tab;
|
|
635
|
+
const params = match.matched ? Object.entries(match.params) : [];
|
|
636
|
+
currentElement.replaceChildren(
|
|
637
|
+
el(
|
|
638
|
+
"div",
|
|
639
|
+
{ class: "current-url" },
|
|
640
|
+
el("span", { class: "current-dot", "aria-hidden": "true" }),
|
|
641
|
+
el("code", { title: tab.url }, tab.url),
|
|
642
|
+
),
|
|
643
|
+
match.matched
|
|
644
|
+
? el(
|
|
645
|
+
"dl",
|
|
646
|
+
{ class: "facts" },
|
|
647
|
+
params.length
|
|
648
|
+
? fact(
|
|
649
|
+
"Params",
|
|
650
|
+
el(
|
|
651
|
+
"div",
|
|
652
|
+
{ class: "params" },
|
|
653
|
+
params.map(([name, value]) =>
|
|
654
|
+
el(
|
|
655
|
+
"span",
|
|
656
|
+
{ class: "param-pill" },
|
|
657
|
+
el("span", {}, name),
|
|
658
|
+
value === undefined
|
|
659
|
+
? el("span", { class: "missing" }, "undefined")
|
|
660
|
+
: el("span", {}, value),
|
|
661
|
+
),
|
|
662
|
+
),
|
|
663
|
+
),
|
|
664
|
+
)
|
|
665
|
+
: undefined,
|
|
666
|
+
match.page ? fact("Page", fileLink(match.page)) : undefined,
|
|
667
|
+
match.notFound ? fact("Not-found page", fileLink(match.notFound)) : undefined,
|
|
668
|
+
)
|
|
669
|
+
: el("p", { class: "muted" }, "No route matches this URL."),
|
|
670
|
+
);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// ---------------------------------------------------------------------------
|
|
674
|
+
// Loader and action timeline
|
|
675
|
+
|
|
676
|
+
function nodeForRouteId(routeId) {
|
|
677
|
+
const directory = routeId.replace(/^file-route:/, "");
|
|
678
|
+
for (const [key, { node }] of state.nodes) {
|
|
679
|
+
const file = node.files.loader ?? node.files.action ?? Object.values(node.files)[0];
|
|
680
|
+
if (file && file.slice(0, file.lastIndexOf("/")) === directory) return { key, node };
|
|
681
|
+
}
|
|
682
|
+
return undefined;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function routeLabel(routeId) {
|
|
686
|
+
const found = nodeForRouteId(routeId);
|
|
687
|
+
if (!found) return routeId.replace(/^file-route:/, "");
|
|
688
|
+
return found.node.routeGroup ? `${found.node.fullPath} (${groupName(found.node)})` : found.node.fullPath;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function shortUrl(url) {
|
|
692
|
+
if (!url) return "";
|
|
693
|
+
try {
|
|
694
|
+
const parsed = new URL(url);
|
|
695
|
+
return parsed.pathname + parsed.search;
|
|
696
|
+
} catch {
|
|
697
|
+
return url;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function formatTime(timestamp) {
|
|
702
|
+
const date = new Date(timestamp);
|
|
703
|
+
return `${date.toLocaleTimeString([], { hour12: false })}.${String(date.getMilliseconds()).padStart(3, "0")}`;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function formatDuration(duration) {
|
|
707
|
+
if (duration === undefined) return "";
|
|
708
|
+
return duration < 10 ? `${duration.toFixed(1)} ms` : `${Math.round(duration)} ms`;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function outcomeChip(call) {
|
|
712
|
+
if (call.type === "revalidate") {
|
|
713
|
+
return call.decision
|
|
714
|
+
? el("span", { class: "chip chip-loader" }, "revalidate")
|
|
715
|
+
: el("span", { class: "chip chip-skipped" }, "skipped");
|
|
716
|
+
}
|
|
717
|
+
switch (call.outcome) {
|
|
718
|
+
case "redirect":
|
|
719
|
+
return el("span", { class: "chip chip-loading" }, `${call.status} redirect`);
|
|
720
|
+
case "error":
|
|
721
|
+
return el("span", { class: "chip chip-error" }, call.status ? `${call.status} thrown` : "error");
|
|
722
|
+
case "response":
|
|
723
|
+
return el("span", { class: "chip chip-layout" }, `${call.status} response`);
|
|
724
|
+
default:
|
|
725
|
+
return el("span", { class: "chip chip-loader" }, "data");
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function callText(call) {
|
|
730
|
+
return [
|
|
731
|
+
call.type,
|
|
732
|
+
routeLabel(call.routeId),
|
|
733
|
+
call.routeId,
|
|
734
|
+
call.url,
|
|
735
|
+
call.method,
|
|
736
|
+
call.outcome,
|
|
737
|
+
call.status,
|
|
738
|
+
call.location,
|
|
739
|
+
call.message,
|
|
740
|
+
call.type === "revalidate" ? (call.decision ? "revalidate" : "skipped") : "",
|
|
741
|
+
]
|
|
742
|
+
.filter((value) => value !== undefined)
|
|
743
|
+
.join(" ")
|
|
744
|
+
.toLowerCase();
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function renderCall(call) {
|
|
748
|
+
const found = nodeForRouteId(call.routeId);
|
|
749
|
+
const routeFile =
|
|
750
|
+
call.type === "action" ? found?.node.files.action : found?.node.files.loader;
|
|
751
|
+
const details = el(
|
|
752
|
+
"details",
|
|
753
|
+
{ class: "call", open: state.openCalls.has(call.id) || undefined },
|
|
754
|
+
el(
|
|
755
|
+
"summary",
|
|
756
|
+
{ class: "call-summary" },
|
|
757
|
+
el("span", { class: `chip chip-${call.type === "action" ? "action" : call.type === "revalidate" ? "group" : "page"}` }, call.type),
|
|
758
|
+
el("span", { class: "call-route", title: call.routeId }, routeLabel(call.routeId)),
|
|
759
|
+
call.type === "revalidate"
|
|
760
|
+
? outcomeChip(call)
|
|
761
|
+
: el(
|
|
762
|
+
"span",
|
|
763
|
+
{ class: "call-duration-outcome" },
|
|
764
|
+
outcomeChip(call),
|
|
765
|
+
" ",
|
|
766
|
+
el(
|
|
767
|
+
"span",
|
|
768
|
+
{ class: `call-duration${call.duration >= SLOW_CALL_MS ? " slow" : ""}` },
|
|
769
|
+
formatDuration(call.duration),
|
|
770
|
+
),
|
|
771
|
+
),
|
|
772
|
+
el(
|
|
773
|
+
"span",
|
|
774
|
+
{ class: "call-meta" },
|
|
775
|
+
el("span", {}, `${call.method && call.method !== "GET" ? `${call.method} ` : ""}${shortUrl(call.url)}`),
|
|
776
|
+
el("time", { datetime: new Date(call.startedAt).toISOString() }, formatTime(call.startedAt)),
|
|
777
|
+
),
|
|
778
|
+
),
|
|
779
|
+
el(
|
|
780
|
+
"div",
|
|
781
|
+
{ class: "call-body" },
|
|
782
|
+
el(
|
|
783
|
+
"dl",
|
|
784
|
+
{ class: "facts" },
|
|
785
|
+
routeFile ? fact(call.type === "action" ? "Action" : "Loader", fileLink(routeFile)) : undefined,
|
|
786
|
+
call.url ? fact("Request", el("code", { class: "file-path" }, `${call.method ?? ""} ${call.url}`.trim())) : undefined,
|
|
787
|
+
call.location ? fact("Redirects to", el("code", { class: "file-path" }, call.location)) : undefined,
|
|
788
|
+
call.message ? fact("Error", el("span", {}, call.message)) : undefined,
|
|
789
|
+
call.type === "revalidate"
|
|
790
|
+
? fact(
|
|
791
|
+
"Decision",
|
|
792
|
+
el(
|
|
793
|
+
"span",
|
|
794
|
+
{},
|
|
795
|
+
call.decision
|
|
796
|
+
? "shouldRevalidate returned true, so the loader runs."
|
|
797
|
+
: "shouldRevalidate returned false, so the loader was skipped.",
|
|
798
|
+
call.defaultShouldRevalidate === undefined
|
|
799
|
+
? ""
|
|
800
|
+
: ` React Router's default was to ${call.defaultShouldRevalidate ? "revalidate" : "skip"}.`,
|
|
801
|
+
),
|
|
802
|
+
)
|
|
803
|
+
: undefined,
|
|
804
|
+
call.preview ? fact("Returned data", el("pre", { class: "call-preview" }, call.preview)) : undefined,
|
|
805
|
+
call.tabId && state.tabs.length > 1
|
|
806
|
+
? fact("Tab", el("span", { class: "muted" }, state.tabs.find((tab) => tab.id === call.tabId)?.title || call.tabId))
|
|
807
|
+
: undefined,
|
|
808
|
+
),
|
|
809
|
+
),
|
|
810
|
+
);
|
|
811
|
+
details.addEventListener("toggle", () => {
|
|
812
|
+
if (details.open) state.openCalls.add(call.id);
|
|
813
|
+
else state.openCalls.delete(call.id);
|
|
814
|
+
});
|
|
815
|
+
return el("li", {}, details);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function visibleCalls() {
|
|
819
|
+
const query = timelineFilter.value.trim().toLowerCase();
|
|
820
|
+
return state.activity
|
|
821
|
+
.filter((call) => call.id > state.clearedThrough)
|
|
822
|
+
.filter((call) => state.timelineType === "all" || call.type === state.timelineType)
|
|
823
|
+
.filter((call) => !query || callText(call).includes(query))
|
|
824
|
+
.reverse();
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function renderTimeline() {
|
|
828
|
+
for (const button of timelineTypes.querySelectorAll("button")) {
|
|
829
|
+
button.setAttribute("aria-pressed", String(button.dataset.type === state.timelineType));
|
|
830
|
+
}
|
|
831
|
+
const hasCalls = state.activity.some((call) => call.id > state.clearedThrough);
|
|
832
|
+
timelineElement.dataset.empty = hasCalls
|
|
833
|
+
? "No calls match this filter."
|
|
834
|
+
: "No loader or action calls yet. Navigate in your app to record some.";
|
|
835
|
+
timelineElement.replaceChildren(...visibleCalls().map(renderCall));
|
|
836
|
+
timelineCount.hidden = state.unseen === 0;
|
|
837
|
+
timelineCount.textContent = state.unseen > 99 ? "99+" : String(state.unseen);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function addActivity(calls) {
|
|
841
|
+
state.activity.push(...calls);
|
|
842
|
+
if (state.activity.length > MAX_TIMELINE) {
|
|
843
|
+
state.activity.splice(0, state.activity.length - MAX_TIMELINE);
|
|
844
|
+
}
|
|
845
|
+
if (timelineView.hidden) state.unseen += calls.length;
|
|
846
|
+
renderTimeline();
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function showView(name) {
|
|
850
|
+
const timeline = name === "timeline";
|
|
851
|
+
inspectView.hidden = timeline;
|
|
852
|
+
timelineView.hidden = !timeline;
|
|
853
|
+
inspectTab.setAttribute("aria-selected", String(!timeline));
|
|
854
|
+
timelineTab.setAttribute("aria-selected", String(timeline));
|
|
855
|
+
if (timeline) state.unseen = 0;
|
|
856
|
+
renderTimeline();
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function applyData(data) {
|
|
860
|
+
const snapshot = JSON.stringify(data);
|
|
861
|
+
errorElement.hidden = true;
|
|
862
|
+
if (snapshot === state.snapshot) return;
|
|
863
|
+
state.data = data;
|
|
864
|
+
state.snapshot = snapshot;
|
|
865
|
+
state.nodes = new Map();
|
|
866
|
+
indexNodes(data.manifest);
|
|
867
|
+
if (!state.nodes.has(state.selected)) state.selected = "0";
|
|
868
|
+
renderStats();
|
|
869
|
+
renderConflicts();
|
|
870
|
+
updateCurrentKeys();
|
|
871
|
+
renderTree();
|
|
872
|
+
renderDetails();
|
|
873
|
+
renderCurrent();
|
|
874
|
+
renderTimeline();
|
|
875
|
+
if (testerInput.value.trim()) runMatch();
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
async function refresh() {
|
|
879
|
+
refreshElement.classList.remove("spinning");
|
|
880
|
+
void refreshElement.offsetWidth;
|
|
881
|
+
refreshElement.classList.add("spinning");
|
|
882
|
+
try {
|
|
883
|
+
const response = await fetch(DATA_URL, { cache: "no-store" });
|
|
884
|
+
if (!response.ok) throw new Error(`Request failed (${response.status})`);
|
|
885
|
+
applyData(await response.json());
|
|
886
|
+
} catch (error) {
|
|
887
|
+
errorElement.textContent = `Unable to load routes: ${error.message}`;
|
|
888
|
+
errorElement.hidden = false;
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
function setLive(stateName, label) {
|
|
893
|
+
liveElement.dataset.state = stateName;
|
|
894
|
+
liveLabelElement.textContent = label;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function connect() {
|
|
898
|
+
if (!("EventSource" in window)) {
|
|
899
|
+
setLive("offline", "Manual refresh");
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
const events = new EventSource(EVENTS_URL);
|
|
903
|
+
events.addEventListener("open", () => setLive("live", "Live"));
|
|
904
|
+
events.addEventListener("apps", (event) => {
|
|
905
|
+
state.tabs = JSON.parse(event.data);
|
|
906
|
+
updateCurrentKeys();
|
|
907
|
+
renderCurrent();
|
|
908
|
+
renderTree();
|
|
909
|
+
});
|
|
910
|
+
events.addEventListener("activity-snapshot", (event) => {
|
|
911
|
+
// Sent on every (re)connect; keep only calls this panel hasn't seen.
|
|
912
|
+
const known = new Set(state.activity.map((call) => call.id));
|
|
913
|
+
const calls = JSON.parse(event.data).filter((call) => !known.has(call.id));
|
|
914
|
+
if (calls.length) addActivity(calls);
|
|
915
|
+
});
|
|
916
|
+
events.addEventListener("activity", (event) => addActivity([JSON.parse(event.data)]));
|
|
917
|
+
events.addEventListener("routes", (event) => {
|
|
918
|
+
setLive("live", "Live");
|
|
919
|
+
applyData(JSON.parse(event.data));
|
|
920
|
+
});
|
|
921
|
+
// EventSource reconnects on its own, including after a dev-server restart.
|
|
922
|
+
events.addEventListener("error", () => setLive("reconnecting", "Reconnecting"));
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
filterElement.addEventListener("input", renderTree);
|
|
926
|
+
inspectTab.addEventListener("click", () => showView("inspect"));
|
|
927
|
+
timelineTab.addEventListener("click", () => showView("timeline"));
|
|
928
|
+
timelineFilter.addEventListener("input", renderTimeline);
|
|
929
|
+
timelineTypes.addEventListener("click", (event) => {
|
|
930
|
+
const type = event.target.closest("button")?.dataset.type;
|
|
931
|
+
if (!type) return;
|
|
932
|
+
state.timelineType = type;
|
|
933
|
+
renderTimeline();
|
|
934
|
+
});
|
|
935
|
+
timelineClear.addEventListener("click", () => {
|
|
936
|
+
state.clearedThrough = state.activity.at(-1)?.id ?? state.clearedThrough;
|
|
937
|
+
state.openCalls.clear();
|
|
938
|
+
renderTimeline();
|
|
939
|
+
});
|
|
940
|
+
refreshElement.addEventListener("click", refresh);
|
|
941
|
+
toggleElement.addEventListener("click", () => {
|
|
942
|
+
if (state.collapsed.size === 0) {
|
|
943
|
+
for (const [key, { node }] of state.nodes) {
|
|
944
|
+
if (key !== "0" && node.children.length) state.collapsed.add(key);
|
|
945
|
+
}
|
|
946
|
+
} else {
|
|
947
|
+
state.collapsed.clear();
|
|
948
|
+
}
|
|
949
|
+
renderTree();
|
|
950
|
+
});
|
|
951
|
+
testerForm.addEventListener("submit", (event) => {
|
|
952
|
+
event.preventDefault();
|
|
953
|
+
runMatch();
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
testerInput.value = readStorage(TESTER_STORAGE_KEY);
|
|
957
|
+
renderTree();
|
|
958
|
+
renderCurrent();
|
|
959
|
+
renderTimeline();
|
|
960
|
+
refresh();
|
|
961
|
+
connect();
|