nebulosity 1.0.0 → 1.1.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/.next/BUILD_ID +1 -1
- package/.next/build-manifest.json +11 -11
- package/.next/diagnostics/route-bundle-stats.json +10 -10
- package/.next/fallback-build-manifest.json +3 -3
- package/.next/prerender-manifest.json +5 -5
- package/.next/server/app/_global-error/page/build-manifest.json +3 -3
- package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_global-error.html +1 -1
- package/.next/server/app/_global-error.rsc +1 -1
- package/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/_not-found/page/build-manifest.json +3 -3
- package/.next/server/app/_not-found/page/next-font-manifest.json +2 -3
- package/.next/server/app/_not-found/page.js +3 -2
- package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_not-found.html +1 -1
- package/.next/server/app/_not-found.rsc +27 -20
- package/.next/server/app/_not-found.segments/_full.segment.rsc +27 -20
- package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +34 -26
- package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/index.html +1 -1
- package/.next/server/app/index.rsc +29 -23
- package/.next/server/app/index.segments/__PAGE__.segment.rsc +33 -24
- package/.next/server/app/index.segments/_full.segment.rsc +29 -23
- package/.next/server/app/index.segments/_tree.segment.rsc +4 -5
- package/.next/server/app/page/build-manifest.json +3 -3
- package/.next/server/app/page/next-font-manifest.json +2 -3
- package/.next/server/app/page.js +4 -4
- package/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/server/chunks/[root-of-the-server]__06blyow8irfw3._.js +3 -3
- package/.next/server/chunks/[root-of-the-server]__0bmqko-d8fd5k._.js +3 -3
- package/.next/server/chunks/[root-of-the-server]__0cmmlmvta4i0g._.js +3 -3
- package/.next/server/chunks/[root-of-the-server]__0jgr1dt1j95ku._.js +3 -3
- package/.next/server/chunks/[root-of-the-server]__0m0n163465-zb._.js +3 -3
- package/.next/server/chunks/[root-of-the-server]__0p7epsgp2b5m3._.js +3 -3
- package/.next/server/chunks/[root-of-the-server]__1i5e5sqg45ens._.js +3 -3
- package/.next/server/chunks/[root-of-the-server]__1vjt0neva-819._.js +3 -3
- package/.next/server/chunks/_0uqus0ehnijt-._.js +1 -1
- package/.next/server/chunks/lib_background_start_ts_11u_1uu8b9yuy._.js +1 -1
- package/.next/server/chunks/ssr/[root-of-the-server]__0_3l5tdzqy-td._.js +3 -0
- package/.next/server/chunks/ssr/[root-of-the-server]__10ockmlli-lun._.js +3 -0
- package/.next/server/chunks/ssr/{[root-of-the-server]__0dyligeswbb47._.js → [root-of-the-server]__16-gclvix9mjd._.js} +1 -1
- package/.next/server/chunks/ssr/[root-of-the-server]__1auodc3c0rppy._.js +3 -0
- package/.next/server/chunks/ssr/[root-of-the-server]__1cf6qt9agg_9-._.js +3 -3
- package/.next/server/chunks/ssr/[root-of-the-server]__1i9r59yk4i69p._.js +3 -0
- package/.next/server/chunks/ssr/[root-of-the-server]__1l3qp8u9yc_ig._.js +3 -0
- package/.next/server/chunks/ssr/_1p-0wsx9qfm68._.js +13 -0
- package/.next/server/chunks/ssr/app_map_18iu6j19jsqmu._.js +3 -0
- package/.next/server/chunks/ssr/node_modules_next_dist_1r3iqtbm45lqp._.js +3 -0
- package/.next/server/chunks/ssr/node_modules_next_dist_client_components_builtin_global-error_0q-w892nagajq.js +3 -0
- package/.next/server/middleware-build-manifest.js +11 -11
- package/.next/server/next-font-manifest.js +1 -1
- package/.next/server/next-font-manifest.json +4 -6
- package/.next/server/pages/404.html +1 -1
- package/.next/server/pages/500.html +1 -1
- package/.next/server/preview-props.json +3 -3
- package/.next/server/server-reference-manifest.js +1 -1
- package/.next/server/server-reference-manifest.json +1 -1
- package/.next/static/chunks/{1rljem7o1jtdo.js → 18kfexnrqek07.js} +1 -1
- package/.next/static/chunks/1hwbgd4nvvhg8.js +1 -0
- package/.next/static/chunks/25r4lkkf1shfo.css +3 -0
- package/.next/static/chunks/35ubvk2ivdjgs.js +1 -0
- package/.next/static/chunks/3ieqvmcg3hofj.js +31 -0
- package/.next/static/chunks/3w00e1w2125pp.js +11 -0
- package/.next/static/media/733da6bd8dac84c8-s.3hlisc-pwq13z.woff2 +0 -0
- package/.next/static/media/784318cea1dfcfa4-s.0-oo9_1x_xmvg.woff2 +0 -0
- package/.next/static/media/7e114457660b62ed-s.36fqqi66-3tjb.woff2 +0 -0
- package/.next/static/media/85e86a88f68a624a-s.357dd_6abeqge.woff2 +0 -0
- package/.next/static/media/9a321a2c78eff0b5-s.p.1bkzbscqrt8rl.woff2 +0 -0
- package/.next/static/media/9fecbd3e5ea9bccb-s.3_z45zcoc-xoz.woff2 +0 -0
- package/.next/static/media/c64053b510252f55-s.1kd-r_s9smv5m.woff2 +0 -0
- package/.next/static/media/c6a5c2fdc12da45c-s.p.17-aodiw50953.woff2 +0 -0
- package/.next/static/media/f263782c6a4bddee-s.1gyfqm5yhhzs7.woff2 +0 -0
- package/.next/trace +1 -1
- package/.next/trace-build +1 -1
- package/README.md +13 -4
- package/app/globals.css +91 -54
- package/app/layout.tsx +20 -11
- package/app/map/BackButton.tsx +5 -5
- package/app/map/BoxActions.tsx +20 -12
- package/app/map/Breadcrumb.tsx +132 -46
- package/app/map/DetailPanel.tsx +408 -297
- package/app/map/Explorer.tsx +268 -0
- package/app/map/Header.tsx +105 -0
- package/app/map/HelpDialog.tsx +37 -34
- package/app/map/Legend.tsx +19 -75
- package/app/map/LineCard.tsx +187 -0
- package/app/map/MapBox.tsx +73 -38
- package/app/map/MapCanvas.tsx +344 -115
- package/app/map/MapToolbar.tsx +377 -0
- package/app/map/MapView.tsx +217 -213
- package/app/map/RepoMenu.tsx +13 -12
- package/app/map/RoutedEdge.tsx +48 -16
- package/app/map/SearchPalette.tsx +6 -6
- package/app/map/SourceViewer.tsx +19 -17
- package/app/map/StatusBar.tsx +166 -0
- package/app/map/embed.ts +22 -2
- package/app/map/emphasis.ts +94 -8
- package/app/map/explorerRows.ts +85 -0
- package/app/map/flowNodes.ts +12 -7
- package/app/map/format.ts +2 -2
- package/app/map/history.ts +4 -3
- package/app/map/inspector.ts +113 -0
- package/app/map/labels.ts +66 -7
- package/app/map/narrow.ts +165 -85
- package/app/map/opening.ts +16 -0
- package/app/map/portals.ts +2 -2
- package/app/map/routes.ts +1060 -55
- package/app/map/runs.ts +108 -0
- package/app/map/shell.ts +91 -0
- package/app/map/shortcuts.ts +2 -1
- package/app/map/summaries.ts +41 -5
- package/app/map/text.ts +9 -1
- package/app/map/theme.ts +197 -0
- package/app/map/useDismiss.ts +20 -6
- package/app/map/useEditorBridge.ts +12 -1
- package/app/map/useThemeKind.ts +28 -0
- package/app/map/useWindowSize.ts +37 -0
- package/app/map/viewport.ts +134 -16
- package/app/map/weights.ts +80 -0
- package/app/page.tsx +3 -5
- package/lib/background/child.ts +2 -0
- package/lib/background/service.ts +26 -10
- package/lib/contracts.ts +25 -2
- package/lib/db/migrations/0011_plain_summaries_prompt_version.sql +1 -0
- package/lib/db/migrations/meta/0011_snapshot.json +1498 -0
- package/lib/db/migrations/meta/_journal.json +7 -0
- package/lib/db/schema.ts +2 -1
- package/lib/db/store/map.ts +6 -0
- package/lib/db/store/summaries.ts +14 -8
- package/lib/engine/describe.ts +2 -0
- package/lib/index/summarize/budget.ts +5 -2
- package/lib/index/summarize/claude.ts +19 -4
- package/lib/index/summarize/compare.ts +2 -2
- package/lib/index/summarize/plan.ts +73 -13
- package/lib/index/summarize/preview.ts +12 -9
- package/lib/index/summarize/prompts.ts +122 -25
- package/lib/index/summarize/run.ts +48 -18
- package/lib/index/summarize/snapshot.ts +7 -2
- package/lib/index/summarize/status.ts +3 -2
- package/lib/tools/text.ts +3 -2
- package/mcp/server.ts +1 -1
- package/package.json +4 -2
- package/scripts/commands/summarize-command.ts +27 -9
- package/.next/server/chunks/ssr/[root-of-the-server]__0ekw8p2ramybv._.js +0 -3
- package/.next/server/chunks/ssr/[root-of-the-server]__0fwbsqt9e2tr-._.js +0 -3
- package/.next/server/chunks/ssr/[root-of-the-server]__17_rqmx62cz59._.js +0 -3
- package/.next/server/chunks/ssr/[root-of-the-server]__1zfp6sltse4hf._.js +0 -3
- package/.next/server/chunks/ssr/_1d2woi6toj_0_._.js +0 -3
- package/.next/server/chunks/ssr/_1wfc1zz26g5ji._.js +0 -9
- package/.next/server/chunks/ssr/app_0gw6epmxyea3q._.js +0 -3
- package/.next/server/chunks/ssr/node_modules_@swc_helpers_cjs__interop_require_default_cjs_1ztp13a5szri_._.js +0 -3
- package/.next/server/chunks/ssr/node_modules_next_dist_1c5t_vsn6u8fr._.js +0 -3
- package/.next/static/chunks/02x9pxyl2d8om.css +0 -3
- package/.next/static/chunks/0p-0bv2dzer18.js +0 -7
- package/.next/static/chunks/10u7p2gj02qay.js +0 -1
- package/.next/static/chunks/31qp8jfkw0x2h.js +0 -31
- package/.next/static/media/123d516c9baeb953-s.1qc6dx89jd2qv.woff2 +0 -0
- package/.next/static/media/41ba59f553543ecf-s.3cb5lpr2l6xud.woff2 +0 -0
- package/.next/static/media/44ed10da57ebfb0e-s.2y65deuc30pmy.woff2 +0 -0
- package/.next/static/media/48eea41d8317494f-s.p.0wiir8udbzjvx.woff2 +0 -0
- package/.next/static/media/49b1d53681a91a99-s.1yd4_i3e35931.woff2 +0 -0
- package/.next/static/media/67bba87670b402d1-s.1u30mmyq6lnsd.woff2 +0 -0
- package/.next/static/media/6fca7e74603dd412-s.02z5rrbg5nt92.woff2 +0 -0
- package/.next/static/media/73263b2957b73100-s.2qt3tdwt-y9x_.woff2 +0 -0
- package/.next/static/media/7ac0b1cd3e695dd0-s.1i-sca43-qwng.woff2 +0 -0
- package/.next/static/media/916cd8a3bde3edf8-s.p.0oace-s_gkfks.woff2 +0 -0
- package/.next/static/media/a45aa1001bf9176f-s.2vemzm0itqzfy.woff2 +0 -0
- package/.next/static/media/a74d2542cc4c775f-s.29yke99gs9qpn.woff2 +0 -0
- package/.next/static/media/ab664c0e419cc841-s.3cvcemwuzoywn.woff2 +0 -0
- package/.next/static/media/c1b11b726b36a5e5-s.2rixgazldzomk.woff2 +0 -0
- package/.next/static/media/d6c1ff7e86974212-s.p.40sczeszzbjw1.woff2 +0 -0
- package/.next/static/media/faab88e4be272d29-s.0rgnxg2b64rzs.woff2 +0 -0
- package/app/map/ActivityBar.tsx +0 -81
- package/app/map/StatusPill.tsx +0 -93
- package/app/map/TopBar.tsx +0 -84
- /package/.next/static/{prDi039MhjlFqDOOwZ4aR → of6twfEZ3j95xtfQB_NSL}/_buildManifest.js +0 -0
- /package/.next/static/{prDi039MhjlFqDOOwZ4aR → of6twfEZ3j95xtfQB_NSL}/_clientMiddlewareManifest.js +0 -0
- /package/.next/static/{prDi039MhjlFqDOOwZ4aR → of6twfEZ3j95xtfQB_NSL}/_ssgManifest.js +0 -0
package/app/map/DetailPanel.tsx
CHANGED
|
@@ -1,27 +1,43 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { memo, useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
|
4
|
-
import { IconArrowLeft, IconArrowRight,
|
|
3
|
+
import { memo, useCallback, useEffect, useId, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
|
|
4
|
+
import { IconArrowLeft, IconArrowRight, IconFile, IconFolder, IconX } from "@tabler/icons-react";
|
|
5
5
|
import type { AreaCount, DescribeResponse, LevelResponse, MapNode, SummarizeResponse, SymbolInfo } from "@/lib/contracts";
|
|
6
6
|
import { api, ApiError, demoGithub, type GitHubSource } from "./api";
|
|
7
|
-
import {
|
|
7
|
+
import { SIDE_COLOR } from "./emphasis";
|
|
8
|
+
import { formatCount } from "./format";
|
|
9
|
+
import {
|
|
10
|
+
barWidth,
|
|
11
|
+
childTitle,
|
|
12
|
+
declarationPlace,
|
|
13
|
+
defaultTab,
|
|
14
|
+
githubFolderLink,
|
|
15
|
+
importsTitle,
|
|
16
|
+
inspectorName,
|
|
17
|
+
inspectorTitle,
|
|
18
|
+
kindBeforeDescribe,
|
|
19
|
+
largestCount,
|
|
20
|
+
sizeLine,
|
|
21
|
+
type InspectorTab,
|
|
22
|
+
} from "./inspector";
|
|
8
23
|
import { noDeclarationsText } from "./labels";
|
|
9
24
|
import { editorLink, githubLink } from "./links";
|
|
10
25
|
import { DEMO } from "./mode";
|
|
11
|
-
import { PANEL_WIDTH } from "./narrow";
|
|
12
26
|
import { nameOf, shortPath } from "./paths";
|
|
13
27
|
import { measureAfterPaint } from "./perf";
|
|
14
28
|
import { linkedFrom, portalCaption, portalLinks } from "./portals";
|
|
15
|
-
import { failureFor, summaryLine, wantsSummary } from "./summaries";
|
|
29
|
+
import { askKey, failureFor, summaryLine, summaryNotes, wantsSummary } from "./summaries";
|
|
16
30
|
import SourceViewer from "./SourceViewer";
|
|
17
31
|
import { inlineCode, splitSummary } from "./text";
|
|
18
32
|
|
|
19
33
|
type DetailPanelProps = {
|
|
20
|
-
/** The selection: a file, a folder, the repo, or one of the level's portals. */
|
|
34
|
+
/** The selection: a file, a folder, the repo, or one of the level's portals. Or, with onScreenFolder, the folder on screen. */
|
|
21
35
|
path: string;
|
|
22
36
|
level: LevelResponse;
|
|
23
37
|
/** Moves when the index changes; the panel then refetches. */
|
|
24
38
|
indexVersion: number;
|
|
39
|
+
/** The summaries stage is off (no API key in the Map's process), so a summary an older prompt wrote isn't asked for (P11). */
|
|
40
|
+
summariesOff: boolean;
|
|
25
41
|
/** Go to the level holding a path and select it there. */
|
|
26
42
|
onFocus: (targetPath: string) => void;
|
|
27
43
|
onOpen: (folder: string) => void;
|
|
@@ -29,37 +45,53 @@ type DetailPanelProps = {
|
|
|
29
45
|
onTurnPage: (page: number) => void;
|
|
30
46
|
onClose: () => void;
|
|
31
47
|
/**
|
|
32
|
-
* Inside VS Code's panel only:
|
|
33
|
-
*
|
|
48
|
+
* Inside VS Code's panel only: "Open in VS Code", here and in the source viewer (at the line it shows), asks the
|
|
49
|
+
* editor over the bridge. A declaration shows in the source viewer, as in a browser tab (the owner, 2026-10-29).
|
|
34
50
|
*/
|
|
35
51
|
onOpenInEditor?: (path: string, line?: number) => void;
|
|
36
|
-
/**
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
covers: boolean;
|
|
52
|
+
/** Where MapView put it (app/map/shell.ts's InspectorPlace, without "none"): beside the canvas, under it, over it as a drawer, or over all of it. */
|
|
53
|
+
placement: "beside" | "below" | "drawer" | "cover";
|
|
54
|
+
/** Nothing is selected and the panel describes the folder on screen: then it has no close button. */
|
|
55
|
+
onScreenFolder?: boolean;
|
|
41
56
|
/** The Map's error line, shown as the panel's last row while the panel covers the canvas it would sit on. */
|
|
42
57
|
alert?: string;
|
|
43
58
|
};
|
|
44
59
|
|
|
60
|
+
/** Rows shown before "+N more": enough to act on without scrolling. */
|
|
61
|
+
const PREVIEW = 8;
|
|
62
|
+
|
|
63
|
+
/** The one primary button, Open folder (the mockup's .primary): the accent is for meaning, and this is the way in. */
|
|
64
|
+
const PRIMARY = "inline-flex h-7 shrink-0 items-center whitespace-nowrap rounded-[5px] bg-accent px-3 text-[13px] font-semibold text-on-accent hover:brightness-110";
|
|
65
|
+
/** Every other action, and the doorway's go-to-page buttons (the mockup's .secondary). */
|
|
66
|
+
const SECONDARY = "inline-flex h-7 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-[5px] border border-border px-3 text-[13px] text-text hover:border-text-3";
|
|
67
|
+
/** A control inside prose: Full summary, Show less, Try again (the mockup's .more). */
|
|
68
|
+
const TEXT_LINK = "text-text underline decoration-text-3 underline-offset-2 hover:decoration-text";
|
|
69
|
+
/** "+N more" and "Show fewer" under a list, quieter than the rows they unfold. */
|
|
70
|
+
const QUIET = "mt-1 rounded-[5px] px-2 py-1 text-[12.5px] text-text-2 hover:text-text";
|
|
71
|
+
|
|
45
72
|
/**
|
|
46
|
-
*
|
|
47
|
-
*
|
|
73
|
+
* The body under the name. One column that scrolls on its own, the header staying put. Under the canvas, in a panel at
|
|
74
|
+
* least 720 px wide, two columns side by side that each scroll (the summary and actions, then the tabs), as collapse
|
|
75
|
+
* option C's render at 820 × 900 has it. The queries go by the panel's own width (@container on the frame), not the
|
|
76
|
+
* window's, since the explorer and the canvas share the window with it.
|
|
77
|
+
*
|
|
78
|
+
* In a short window (under 480 px tall) the whole panel scrolls, its header too (SHORT_FRAME), so the body isn't left a
|
|
79
|
+
* few lines, or none under the Map's error row, beneath a header that never moves; the error row stays pinned to the
|
|
80
|
+
* bottom. Under the canvas the window is at least 640 px tall (shell.ts), so the two columns never meet this.
|
|
48
81
|
*/
|
|
82
|
+
const BODY = "min-h-0 flex-1 overflow-y-auto pb-4 [@media(height<480px)]:flex-none [@media(height<480px)]:overflow-visible";
|
|
49
83
|
const SHORT_FRAME = "[@media(height<480px)]:overflow-y-auto";
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const PREVIEW = 8;
|
|
54
|
-
|
|
55
|
-
const KIND_LABELS: Record<DescribeResponse["kind"], string> = { repo: "Repository", folder: "Folder", file: "File" };
|
|
84
|
+
// one row as tall as the body (grid-rows-1 is minmax(0, 1fr)), so each column scrolls instead of the body
|
|
85
|
+
const BODY_TWO = "@min-[720px]:grid @min-[720px]:grid-cols-2 @min-[720px]:grid-rows-1 @min-[720px]:pb-0";
|
|
86
|
+
const COLUMN_TWO = "@min-[720px]:min-h-0 @min-[720px]:overflow-y-auto @min-[720px]:pb-4";
|
|
56
87
|
|
|
57
88
|
/**
|
|
58
|
-
* The
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
89
|
+
* The inspector (the Workbench redesign, 2026-10-27; mockup3.html's .insp), top to bottom: the name in the code font
|
|
90
|
+
* and its size, the tagline and a summary that expands, the actions that fit the target, then tabs for what it uses,
|
|
91
|
+
* what uses it, and what's inside, each row a name, a count and a bar. A missing or out-of-date summary, or one an older
|
|
92
|
+
* prompt wrote, is asked for once ("summarize now", PIVOT_SPEC.md §5.3), and the panel says so while it's refreshing.
|
|
93
|
+
* A portal gets its own panel (PortalPanel). MapView places it (beside, under or over the canvas) and remounts it for
|
|
94
|
+
* each selection (key), so nothing leaks from one target to the next.
|
|
63
95
|
*/
|
|
64
96
|
function DetailPanel(props: DetailPanelProps){
|
|
65
97
|
const portal = props.level.nodes.find((n) => n.path === props.path && n.kind === "portal");
|
|
@@ -71,54 +103,61 @@ export default memo(DetailPanel);
|
|
|
71
103
|
|
|
72
104
|
type PanelFrameProps = {
|
|
73
105
|
path: string;
|
|
74
|
-
|
|
106
|
+
placement: DetailPanelProps["placement"];
|
|
107
|
+
onScreenFolder?: boolean;
|
|
75
108
|
alert?: string;
|
|
76
|
-
kindLabel: string;
|
|
77
109
|
name: string;
|
|
110
|
+
/** The name's hover title: the whole path, which the name alone doesn't show. It holds the name too. */
|
|
78
111
|
title: string;
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
header?: ReactNode;
|
|
112
|
+
/** Under the name: the sizes, or a doorway's caption. */
|
|
113
|
+
meta?: string;
|
|
82
114
|
onClose: () => void;
|
|
83
115
|
children: ReactNode;
|
|
84
116
|
};
|
|
85
117
|
|
|
86
118
|
/**
|
|
87
|
-
* The panel's frame
|
|
88
|
-
* the Map's error line
|
|
119
|
+
* The panel's frame. It fills whatever MapView put it in. The name and the close button stay put while the body under
|
|
120
|
+
* them scrolls, and the Map's error line is its last row while it covers the canvas (so the row never hides the end of
|
|
121
|
+
* the body). Under the canvas its border is on top, everywhere else on its left.
|
|
89
122
|
*/
|
|
90
|
-
function PanelFrame({ path,
|
|
123
|
+
function PanelFrame({ path, placement, onScreenFolder = false, alert, name, title, meta, onClose, children }: PanelFrameProps){
|
|
91
124
|
return (
|
|
92
|
-
<aside
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
<
|
|
125
|
+
<aside
|
|
126
|
+
data-panel={path}
|
|
127
|
+
data-placement={placement}
|
|
128
|
+
data-on-screen={onScreenFolder || undefined}
|
|
129
|
+
className={`@container pointer-events-auto flex h-full min-h-0 w-full min-w-0 flex-col border-divider bg-panel ${SHORT_FRAME} ${placement === "below" ? "border-t" : "border-l"}`}
|
|
130
|
+
>
|
|
131
|
+
<header className="flex shrink-0 items-start justify-between gap-2 px-4 pb-3 pt-4">
|
|
132
|
+
<div className="min-w-0">
|
|
133
|
+
<h2 className="truncate font-mono text-[15px] font-medium leading-5 text-text" title={title}>{name}</h2>
|
|
134
|
+
{meta && <p className="mt-0.5 text-[12.5px] leading-[18px] text-text-2">{meta}</p>}
|
|
135
|
+
</div>
|
|
136
|
+
{/* describing the folder on screen, it's part of the layout rather than something opened, so it stays */}
|
|
137
|
+
{!onScreenFolder && (
|
|
138
|
+
<button type="button" onClick={onClose} aria-label="Close the panel" className="-mr-1 shrink-0 rounded-[5px] p-1 text-text-2 hover:bg-node hover:text-text">
|
|
101
139
|
<IconX aria-hidden size={16} stroke={1.75} />
|
|
102
140
|
</button>
|
|
103
|
-
|
|
104
|
-
{header}
|
|
141
|
+
)}
|
|
105
142
|
</header>
|
|
106
143
|
{children}
|
|
107
|
-
{
|
|
108
|
-
{alert && <p role="alert" data-covers className="pointer-events-none sticky bottom-0 shrink-0 border-t border-hairline bg-panel px-4 py-2 text-center text-[13px] text-danger [overflow-wrap:anywhere]">{alert}</p>}
|
|
144
|
+
{alert && <p role="alert" data-covers className="pointer-events-none sticky bottom-0 shrink-0 border-t border-divider bg-panel px-4 py-2 text-center text-[13px] text-danger [overflow-wrap:anywhere]">{alert}</p>}
|
|
109
145
|
</aside>
|
|
110
146
|
);
|
|
111
147
|
}
|
|
112
148
|
|
|
113
149
|
/** A file, a folder or the repo: described by the server (GET /api/describe). */
|
|
114
|
-
function TargetPanel({ path, level, indexVersion, onFocus, onOpen, onClose, onOpenInEditor,
|
|
150
|
+
function TargetPanel({ path, level, indexVersion, summariesOff, onFocus, onOpen, onClose, onOpenInEditor, placement, onScreenFolder, alert }: DetailPanelProps){
|
|
115
151
|
const target = path;
|
|
116
152
|
const [data, setData] = useState<DescribeResponse | null>(null);
|
|
117
153
|
const [error, setError] = useState<ApiError | null>(null);
|
|
118
154
|
const [fresh, setFresh] = useState<SummarizeResponse | null>(null);
|
|
119
155
|
const [summaryError, setSummaryError] = useState<ApiError | null>(null);
|
|
120
156
|
const [refreshing, setRefreshing] = useState(false);
|
|
121
|
-
//
|
|
157
|
+
// the tab on screen: picked from the first answer (defaultTab), then kept while this panel is open, refetches too
|
|
158
|
+
const [tab, setTab] = useState<InspectorTab | null>(null);
|
|
159
|
+
// keys already asked for (askKey): "<path>@<index version>" or "<path>@older", or the bare path once the server
|
|
160
|
+
// answered NOT_READY
|
|
122
161
|
const asked = useRef(new Set<string>());
|
|
123
162
|
// the demo's source links go to GitHub at the commit the index was built from (ADR-049)
|
|
124
163
|
const [github, setGithub] = useState<GitHubSource | null>(null);
|
|
@@ -130,7 +169,7 @@ function TargetPanel({ path, level, indexVersion, onFocus, onOpen, onClose, onOp
|
|
|
130
169
|
useEffect(() => {
|
|
131
170
|
const controller = new AbortController();
|
|
132
171
|
api.describe(target, controller.signal).then(
|
|
133
|
-
(next) => { setData(next); setError(null); },
|
|
172
|
+
(next) => { setData(next); setError(null); setTab((current) => current ?? defaultTab(next)); },
|
|
134
173
|
(err: unknown) => { if (err instanceof ApiError) setError(err); },
|
|
135
174
|
);
|
|
136
175
|
return () => controller.abort();
|
|
@@ -154,17 +193,18 @@ function TargetPanel({ path, level, indexVersion, onFocus, onOpen, onClose, onOp
|
|
|
154
193
|
);
|
|
155
194
|
};
|
|
156
195
|
|
|
157
|
-
// "summarize now"
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
//
|
|
196
|
+
// "summarize now" for a summary that's missing, out of date or in the older style (wantsSummary), once per target
|
|
197
|
+
// and index version, or once per panel for the older style alone (askKey): the server joins a request already
|
|
198
|
+
// running (ADR-033). After a NOT_READY answer, once per target: the index moves with every child summary the worker
|
|
199
|
+
// writes, and each move would otherwise ask again for a folder that still isn't ready. Reselecting it asks afresh
|
|
200
|
+
// (a new panel). The static demo has no server to ask, so it never does (ADR-049).
|
|
161
201
|
useEffect(() => {
|
|
162
|
-
if (DEMO || data === null || !wantsSummary(data
|
|
163
|
-
const key =
|
|
202
|
+
if (DEMO || data === null || !wantsSummary(data, summariesOff)) return;
|
|
203
|
+
const key = askKey(data);
|
|
164
204
|
if (asked.current.has(data.path) || asked.current.has(key)) return;
|
|
165
205
|
asked.current.add(key);
|
|
166
206
|
summarize(data.path, () => asked.current.add(data.path));
|
|
167
|
-
}, [data]);
|
|
207
|
+
}, [data, summariesOff]);
|
|
168
208
|
|
|
169
209
|
const retry = (): void => {
|
|
170
210
|
if (data === null) return;
|
|
@@ -172,53 +212,56 @@ function TargetPanel({ path, level, indexVersion, onFocus, onOpen, onClose, onOp
|
|
|
172
212
|
summarize(data.path);
|
|
173
213
|
};
|
|
174
214
|
|
|
175
|
-
const
|
|
176
|
-
const kindLabel = data ? KIND_LABELS[data.kind] : "";
|
|
215
|
+
const kind = data?.kind ?? kindBeforeDescribe(target, level);
|
|
177
216
|
// the source viewer (owner review): closed, open on the whole file, or open on a declaration's lines
|
|
178
217
|
const [viewing, setViewing] = useState<{ lines?: { start: number; end: number } } | null>(null);
|
|
179
218
|
const closeViewer = useCallback(() => setViewing(null), []);
|
|
219
|
+
const two = placement === "below";
|
|
220
|
+
const failed = error && <p className="text-[13px] text-danger [overflow-wrap:anywhere]">{error.message}</p>;
|
|
180
221
|
|
|
181
222
|
return (
|
|
182
223
|
<PanelFrame
|
|
183
224
|
path={path}
|
|
225
|
+
placement={placement}
|
|
226
|
+
onScreenFolder={onScreenFolder}
|
|
184
227
|
alert={alert}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
title={target || name}
|
|
189
|
-
subtitle={target || "(repo root)"}
|
|
190
|
-
header={data && (
|
|
191
|
-
<Stats
|
|
192
|
-
data={data}
|
|
193
|
-
github={github}
|
|
194
|
-
onOpen={onOpen}
|
|
195
|
-
onViewSource={() => setViewing({})}
|
|
196
|
-
onOpenInEditor={onOpenInEditor && (() => onOpenInEditor(data.path))}
|
|
197
|
-
/>
|
|
198
|
-
)}
|
|
228
|
+
name={inspectorName(target, level.repo, kind)}
|
|
229
|
+
title={inspectorTitle(target, level.repo, kind)}
|
|
230
|
+
meta={data ? sizeLine(data) : undefined}
|
|
199
231
|
onClose={onClose}
|
|
200
232
|
>
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
233
|
+
{data
|
|
234
|
+
? (
|
|
235
|
+
<div className={`${BODY} ${two ? BODY_TWO : ""}`}>
|
|
236
|
+
<div className={`flex flex-col gap-3 px-4 ${two ? `${COLUMN_TWO} @min-[720px]:pr-3` : ""}`}>
|
|
237
|
+
{failed}
|
|
238
|
+
<Summary data={data} fresh={fresh} refreshing={refreshing} summaryError={summaryError} onRetry={retry} />
|
|
239
|
+
<Actions
|
|
240
|
+
data={data}
|
|
241
|
+
github={github}
|
|
242
|
+
onScreenFolder={onScreenFolder ?? false}
|
|
243
|
+
onOpen={onOpen}
|
|
244
|
+
onViewSource={() => setViewing({})}
|
|
245
|
+
onOpenInEditor={onOpenInEditor && (() => onOpenInEditor(data.path))}
|
|
246
|
+
/>
|
|
247
|
+
</div>
|
|
248
|
+
<div className={`px-4 pt-4 ${two ? `${COLUMN_TWO} @min-[720px]:pl-3 @min-[720px]:pt-1` : ""}`}>
|
|
249
|
+
<Relations
|
|
250
|
+
data={data}
|
|
251
|
+
tab={tab ?? defaultTab(data)}
|
|
252
|
+
onTab={setTab}
|
|
253
|
+
onFocus={onFocus}
|
|
254
|
+
onOpen={onOpen}
|
|
255
|
+
// inside VS Code's panel too, a declaration shows in View source, whose Open in VS Code
|
|
256
|
+
// opens the editor at its line: nothing but an Open moves the editor (the owner, 2026-10-29)
|
|
257
|
+
onViewLines={DEMO
|
|
258
|
+
? (start, end) => { if (github) window.open(githubLink(github, data.path, { start, end }), "_blank", "noreferrer"); }
|
|
259
|
+
: (start, end) => setViewing({ lines: { start, end } })}
|
|
260
|
+
/>
|
|
261
|
+
</div>
|
|
262
|
+
</div>
|
|
263
|
+
)
|
|
264
|
+
: <div className={`${BODY} px-4`}>{failed || <p className="text-[13px] text-text-2">Loading…</p>}</div>}
|
|
222
265
|
{viewing && data?.kind === "file" && (
|
|
223
266
|
<SourceViewer
|
|
224
267
|
path={data.path}
|
|
@@ -235,11 +278,12 @@ type Linked = { path: string; count: number };
|
|
|
235
278
|
|
|
236
279
|
/**
|
|
237
280
|
* A portal selected: the page (or pages) behind it, a way to go there, and which boxes on this page have lines to it,
|
|
238
|
-
* each a
|
|
281
|
+
* each a row that selects that box. Everything comes from the level on screen; there's nothing to fetch.
|
|
239
282
|
*/
|
|
240
|
-
function PortalPanel({ portal, path, level, onFocus, onTurnPage, onClose,
|
|
283
|
+
function PortalPanel({ portal, path, level, onFocus, onTurnPage, onClose, placement, onScreenFolder, alert }: DetailPanelProps & { portal: MapNode }){
|
|
241
284
|
const links = portalLinks(level.edges, portal.path);
|
|
242
285
|
const linked = linkedFrom(level, portal.path);
|
|
286
|
+
const largest = largestCount(linked);
|
|
243
287
|
const pages = portal.page !== undefined ? [portal.page] : portal.pages ?? [];
|
|
244
288
|
const byPath = new Map(level.nodes.map((n) => [n.path, n]));
|
|
245
289
|
|
|
@@ -250,27 +294,21 @@ function PortalPanel({ portal, path, level, onFocus, onTurnPage, onClose, covers
|
|
|
250
294
|
return (
|
|
251
295
|
<PanelFrame
|
|
252
296
|
path={path}
|
|
297
|
+
placement={placement}
|
|
298
|
+
onScreenFolder={onScreenFolder}
|
|
253
299
|
alert={alert}
|
|
254
|
-
covers={covers}
|
|
255
|
-
kindLabel={pages.length === 1 ? "Page" : "Pages"}
|
|
256
300
|
name={portal.name}
|
|
257
|
-
title={portal.name}
|
|
258
|
-
|
|
259
|
-
header={<p className="mt-3 text-[13px] text-text-2">{portalCaption(portal, links)} with this page</p>}
|
|
301
|
+
title={`${portal.name} of ${level.path || nameOf("", level.repo)}`}
|
|
302
|
+
meta={`${portalCaption(portal, links)} with this page`}
|
|
260
303
|
onClose={onClose}
|
|
261
304
|
>
|
|
262
|
-
<div className={
|
|
305
|
+
<div className={`${BODY} space-y-4 px-4`}>
|
|
263
306
|
<section aria-label="Go to">
|
|
264
|
-
{pages.length > 1 && <
|
|
307
|
+
{pages.length > 1 && <ListHeading title="Pages behind this doorway" count={pages.length} />}
|
|
265
308
|
<ul className="flex flex-wrap gap-2">
|
|
266
309
|
{pages.map((p) => (
|
|
267
310
|
<li key={p}>
|
|
268
|
-
<button
|
|
269
|
-
type="button"
|
|
270
|
-
data-go-page={p}
|
|
271
|
-
onClick={() => onTurnPage(p)}
|
|
272
|
-
className="inline-flex items-center gap-1.5 rounded-md border border-control/60 px-3 py-1.5 text-[13px] text-control hover:border-control"
|
|
273
|
-
>
|
|
311
|
+
<button type="button" data-go-page={p} onClick={() => onTurnPage(p)} className={SECONDARY}>
|
|
274
312
|
{p < level.page && <IconArrowLeft aria-hidden size={14} stroke={1.75} />}
|
|
275
313
|
{pages.length === 1 ? `Go to page ${p + 1}` : `Page ${p + 1}`}
|
|
276
314
|
{p > level.page && <IconArrowRight aria-hidden size={14} stroke={1.75} />}
|
|
@@ -280,36 +318,38 @@ function PortalPanel({ portal, path, level, onFocus, onTurnPage, onClose, covers
|
|
|
280
318
|
</ul>
|
|
281
319
|
</section>
|
|
282
320
|
|
|
283
|
-
<
|
|
284
|
-
<
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
321
|
+
<section aria-label="Linked from this page">
|
|
322
|
+
<ListHeading title="Linked from this page" count={linked.length} />
|
|
323
|
+
<Preview items={linked} empty="No lines from this page." render={({ path: boxPath, count }: Linked) => (
|
|
324
|
+
<BarRow
|
|
325
|
+
key={boxPath}
|
|
326
|
+
linked={boxPath}
|
|
327
|
+
name={byPath.get(boxPath)?.name ?? boxPath}
|
|
328
|
+
count={count}
|
|
329
|
+
largest={largest}
|
|
330
|
+
title={`${importsTitle(boxPath, count)} with ${portal.name}`}
|
|
289
331
|
onClick={() => onFocus(boxPath)}
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
<span className="min-w-0 flex-1 truncate">{byPath.get(boxPath)?.name ?? boxPath}</span>
|
|
294
|
-
<span className="shrink-0 text-[12px] text-text-2">{formatCount(count)}</span>
|
|
295
|
-
</button>
|
|
296
|
-
</li>
|
|
297
|
-
)} />
|
|
332
|
+
/>
|
|
333
|
+
)} />
|
|
334
|
+
</section>
|
|
298
335
|
</div>
|
|
299
336
|
</PanelFrame>
|
|
300
337
|
);
|
|
301
338
|
}
|
|
302
339
|
|
|
303
|
-
/** A
|
|
304
|
-
function
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
340
|
+
/** A list's heading in the doorway's panel, drawn like a tab row with nothing to switch: the title, its count, a divider. */
|
|
341
|
+
function ListHeading({ title, count }: { title: string; count: number }){
|
|
342
|
+
return (
|
|
343
|
+
<h3 className="mb-2 border-b border-divider pb-2 pt-1.5 text-[13px] font-normal text-text">
|
|
344
|
+
{title} <span className="text-text-2">{formatCount(count)}</span>
|
|
345
|
+
</h3>
|
|
346
|
+
);
|
|
308
347
|
}
|
|
309
348
|
|
|
310
|
-
type
|
|
349
|
+
type ActionsProps = {
|
|
311
350
|
data: DescribeResponse;
|
|
312
351
|
github: GitHubSource | null;
|
|
352
|
+
onScreenFolder: boolean;
|
|
313
353
|
onOpen: (folder: string) => void;
|
|
314
354
|
onViewSource: () => void;
|
|
315
355
|
/** Inside VS Code's panel only: "Open in VS Code" asks the editor over the bridge, since the panel can't follow a vscode:// link. */
|
|
@@ -317,208 +357,266 @@ type StatsProps = {
|
|
|
317
357
|
};
|
|
318
358
|
|
|
319
359
|
/**
|
|
320
|
-
*
|
|
321
|
-
* file
|
|
360
|
+
* What you can do with the target, as buttons. A folder gets Open folder, the one primary button, unless it's the folder
|
|
361
|
+
* already on screen. For a file the app offers its source and the editor; the demo has neither, so it links to GitHub
|
|
362
|
+
* at the indexed commit, a folder and the repo too.
|
|
322
363
|
*/
|
|
323
|
-
function
|
|
324
|
-
const
|
|
325
|
-
const
|
|
364
|
+
function Actions({ data, github, onScreenFolder, onOpen, onViewSource, onOpenInEditor }: ActionsProps){
|
|
365
|
+
const file = data.kind === "file";
|
|
366
|
+
const open = data.kind === "folder" && !onScreenFolder;
|
|
367
|
+
const onGithub = DEMO && github !== null;
|
|
368
|
+
if (!open && !onGithub && !(file && !DEMO)) return null;
|
|
369
|
+
|
|
326
370
|
return (
|
|
327
|
-
<div className="
|
|
328
|
-
<
|
|
329
|
-
{
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
)}
|
|
333
|
-
{data.kind === "file" && DEMO && github && (
|
|
334
|
-
<a href={githubLink(github, data.path)} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 text-control underline underline-offset-2 hover:text-text">
|
|
335
|
-
<IconCode aria-hidden size={14} stroke={1.75} />View on GitHub
|
|
371
|
+
<div className="flex flex-wrap gap-2">
|
|
372
|
+
{open && <button type="button" onClick={() => onOpen(data.path)} className={PRIMARY}>Open folder</button>}
|
|
373
|
+
{onGithub && (
|
|
374
|
+
<a href={file ? githubLink(github, data.path) : githubFolderLink(github, data.path)} target="_blank" rel="noreferrer" className={SECONDARY}>
|
|
375
|
+
View on GitHub
|
|
336
376
|
</a>
|
|
337
377
|
)}
|
|
338
|
-
{
|
|
378
|
+
{file && !DEMO && (
|
|
339
379
|
<>
|
|
340
|
-
<button type="button" onClick={onViewSource} className=
|
|
341
|
-
<IconCode aria-hidden size={14} stroke={1.75} />View source
|
|
342
|
-
</button>
|
|
380
|
+
<button type="button" onClick={onViewSource} className={SECONDARY}>View source</button>
|
|
343
381
|
{onOpenInEditor
|
|
344
382
|
// the webview's sandbox stops a vscode:// link inside the panel, so the editor is asked instead
|
|
345
|
-
? <button type="button" onClick={onOpenInEditor} className=
|
|
346
|
-
: <a href={editorLink(data)} className=
|
|
383
|
+
? <button type="button" onClick={onOpenInEditor} className={SECONDARY}>Open in VS Code</button>
|
|
384
|
+
: <a href={editorLink(data)} className={SECONDARY}>Open in VS Code</a>}
|
|
347
385
|
</>
|
|
348
386
|
)}
|
|
349
387
|
</div>
|
|
350
388
|
);
|
|
351
389
|
}
|
|
352
390
|
|
|
353
|
-
type
|
|
391
|
+
type SummaryProps = {
|
|
354
392
|
data: DescribeResponse;
|
|
355
393
|
fresh: SummarizeResponse | null;
|
|
356
394
|
refreshing: boolean;
|
|
357
395
|
summaryError: ApiError | null;
|
|
358
396
|
onRetry: () => void;
|
|
359
|
-
onFocus: (targetPath: string) => void;
|
|
360
|
-
onOpen: (folder: string) => void;
|
|
361
|
-
/** Opens the source viewer on a declaration's lines. */
|
|
362
|
-
onViewLines: (start: number, end: number) => void;
|
|
363
397
|
};
|
|
364
398
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
{/* the three-task test's tester took a while to find these and to match them to the arrows: say it */}
|
|
372
|
-
<Chips title="Depends on" note="What this box uses: its arrows point to these · by number of imports" items={data.uses} empty="Imports nothing outside itself." onFocus={onFocus} />
|
|
373
|
-
<Chips title="Used by" note="What uses this box: their arrows point here · by number of imports, test files included" items={data.usedBy} empty="Nothing outside it imports it." onFocus={onFocus} />
|
|
374
|
-
</section>
|
|
375
|
-
|
|
376
|
-
{data.children && (
|
|
377
|
-
<Rows title="Contents" items={data.children} render={(child: MapNode) => (
|
|
378
|
-
<ChildRow key={child.path} child={child} onFocus={onFocus} onOpen={onOpen} />
|
|
379
|
-
)} />
|
|
380
|
-
)}
|
|
381
|
-
|
|
382
|
-
{data.symbols && (
|
|
383
|
-
<Rows title="Declarations" items={data.symbols} empty={noDeclarationsText(data)} render={(symbol: SymbolInfo) => (
|
|
384
|
-
<li key={`${symbol.name}:${symbol.startLine}`}>
|
|
385
|
-
<button
|
|
386
|
-
type="button"
|
|
387
|
-
title={`Show ${symbol.name} in the source`}
|
|
388
|
-
onClick={() => onViewLines(symbol.startLine, symbol.endLine)}
|
|
389
|
-
className="flex w-full items-baseline justify-between gap-3 rounded-md px-2 py-1 text-left hover:bg-surface"
|
|
390
|
-
>
|
|
391
|
-
<span className={`truncate font-mono text-[13px] ${symbol.exported ? "text-text" : "text-text-2"}`}>{symbol.name}</span>
|
|
392
|
-
<span className="shrink-0 text-[12px] text-text-2">{symbol.kind} · line {symbol.startLine}</span>
|
|
393
|
-
</button>
|
|
394
|
-
</li>
|
|
395
|
-
)} />
|
|
396
|
-
)}
|
|
397
|
-
</>
|
|
398
|
-
);
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
type SummaryProps = Pick<DetailsProps, "data" | "fresh" | "refreshing" | "summaryError" | "onRetry">;
|
|
402
|
-
|
|
403
|
-
/** The tagline and the first sentence; "Read full summary" shows the rest and "Show less" folds it back. */
|
|
399
|
+
/**
|
|
400
|
+
* The tagline and the summary's lead, its first paragraph, or its first sentence when it's one block (splitSummary).
|
|
401
|
+
* "Full summary" at the end of it shows the rest as paragraphs, and "Show less" at the end of the last one folds it
|
|
402
|
+
* back. However many paragraphs, they wrap in the panel's width and scroll with its body, with no height of their own.
|
|
403
|
+
* Under them, quietly, whether it's refreshing, out of date, or failed (with Try again).
|
|
404
|
+
*/
|
|
404
405
|
function Summary({ data, fresh, refreshing, summaryError, onRetry }: SummaryProps){
|
|
405
406
|
const [expanded, setExpanded] = useState(false);
|
|
406
407
|
// a "summarize now" answer is newer than the description it was asked from
|
|
407
408
|
const shown = fresh ?? data;
|
|
408
409
|
const { lead, rest } = splitSummary(shown.summary ?? "");
|
|
409
410
|
const failure = failureFor(shown.summaryError, summaryError);
|
|
411
|
+
const placeholder = shown.tagline ? "" : summaryLine(shown.summaryStatus, summaryError);
|
|
412
|
+
const paragraphs = lead === "" ? [] : expanded ? [lead, ...rest.split(/\n\s*\n/)] : [lead];
|
|
413
|
+
const { notes, testsNote } = summaryNotes(shown, data.kind, refreshing);
|
|
414
|
+
const hasNotes = notes.length > 0 || testsNote || failure !== null;
|
|
410
415
|
|
|
411
416
|
return (
|
|
412
417
|
// a long identifier or path (inline code is one unbroken word) breaks anywhere rather than run past the panel's
|
|
413
418
|
// edge; wherever a line has a space to break at, it still breaks there
|
|
414
|
-
<section aria-label="Summary" className="[overflow-wrap:anywhere]">
|
|
415
|
-
{shown.tagline
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
419
|
+
<section aria-label="Summary" className="space-y-3 [overflow-wrap:anywhere]">
|
|
420
|
+
{shown.tagline && <p className="text-[13px] font-semibold leading-[18px] text-text"><Rich text={shown.tagline} /></p>}
|
|
421
|
+
{placeholder && <p className="text-[13px] leading-[1.55] text-text-2">{placeholder}</p>}
|
|
422
|
+
{paragraphs.length > 0 && (
|
|
423
|
+
<div className="space-y-2 text-[13px] leading-[1.55] text-text-2">
|
|
424
|
+
{paragraphs.map((paragraph, i) => (
|
|
425
|
+
<p key={i}>
|
|
426
|
+
<Rich text={paragraph} />
|
|
427
|
+
{/* it folds back, so a long summary doesn't push the tabs away for good (owner review) */}
|
|
428
|
+
{rest && i === paragraphs.length - 1 && (
|
|
429
|
+
<>
|
|
430
|
+
{" "}
|
|
431
|
+
<button type="button" aria-expanded={expanded} onClick={() => setExpanded((open) => !open)} className={TEXT_LINK}>
|
|
432
|
+
{expanded ? "Show less" : "Full summary"}
|
|
433
|
+
</button>
|
|
434
|
+
</>
|
|
435
|
+
)}
|
|
436
|
+
</p>
|
|
437
|
+
))}
|
|
438
|
+
</div>
|
|
439
|
+
)}
|
|
440
|
+
{hasNotes && (
|
|
441
|
+
<div className="space-y-1 text-[12.5px] leading-[18px] text-text-2">
|
|
442
|
+
{notes.map((note) => <p key={note}>{note}</p>)}
|
|
443
|
+
{testsNote && <p className="text-[12px] text-text-3">Import counts in this summary leave out test files.</p>}
|
|
444
|
+
{failure && (
|
|
445
|
+
<p className="text-danger">
|
|
446
|
+
{failure}{" "}
|
|
447
|
+
<button type="button" onClick={onRetry} className={TEXT_LINK}>Try again</button>
|
|
448
|
+
</p>
|
|
449
|
+
)}
|
|
450
|
+
</div>
|
|
430
451
|
)}
|
|
431
|
-
<div className="mt-2 space-y-1 text-[13px] text-text-2">
|
|
432
|
-
{refreshing && shown.tagline && <p>Refreshing…</p>}
|
|
433
|
-
{shown.summaryStatus === "stale" && !refreshing && <p>Out of date: this changed since it was summarized.</p>}
|
|
434
|
-
{/* the summary was written from an "Imported by" line that counts tests apart; "Used by" below counts them */}
|
|
435
|
-
{shown.summary && data.kind === "file" && <p className="text-[12px]">Import counts in this summary leave out test files.</p>}
|
|
436
|
-
{failure && (
|
|
437
|
-
<p className="text-danger">
|
|
438
|
-
{failure}{" "}
|
|
439
|
-
<button type="button" onClick={onRetry} className="text-control underline underline-offset-2 hover:text-text">Try again</button>
|
|
440
|
-
</p>
|
|
441
|
-
)}
|
|
442
|
-
</div>
|
|
443
452
|
</section>
|
|
444
453
|
);
|
|
445
454
|
}
|
|
446
455
|
|
|
447
456
|
/**
|
|
448
|
-
* Prose with its `identifiers` as inline code
|
|
449
|
-
*
|
|
450
|
-
*
|
|
457
|
+
* Prose with its `identifiers` as inline code: the code font in the text color, with no chip behind it (the approved
|
|
458
|
+
* mockup's `.summary code`, 2026-10-27). The brighter color and the code font are enough to spot a name in the gray
|
|
459
|
+
* prose. The chips behind them made the summary read busy beside the new rows and bars.
|
|
451
460
|
*/
|
|
452
461
|
function Rich({ text }: { text: string }){
|
|
453
462
|
return (
|
|
454
463
|
<>
|
|
455
464
|
{inlineCode(text).map((part, i) => part.code
|
|
456
|
-
? <code key={i} className="
|
|
465
|
+
? <code key={i} className="font-mono text-[12px] text-text">{part.text}</code>
|
|
457
466
|
: <span key={i}>{part.text}</span>)}
|
|
458
467
|
</>
|
|
459
468
|
);
|
|
460
469
|
}
|
|
461
470
|
|
|
462
|
-
|
|
471
|
+
/** Hover titles on the tabs: the old panel's notes, said once. The three-task test's tester matched them to the arrows slowly. */
|
|
472
|
+
const TAB_TITLES: Record<InspectorTab, string | undefined> = {
|
|
473
|
+
uses: "What this uses, by number of imports. Its arrows point to these.",
|
|
474
|
+
usedBy: "What uses this, by number of imports, test files included. Their arrows point here.",
|
|
475
|
+
inside: undefined,
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
type RelationsProps = {
|
|
479
|
+
data: DescribeResponse;
|
|
480
|
+
tab: InspectorTab;
|
|
481
|
+
onTab: (tab: InspectorTab) => void;
|
|
482
|
+
onFocus: (targetPath: string) => void;
|
|
483
|
+
onOpen: (folder: string) => void;
|
|
484
|
+
/** Opens the source viewer on a declaration's lines (or the editor, or GitHub). */
|
|
485
|
+
onViewLines: (start: number, end: number) => void;
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* The tabs (the mockup's .tabs): Uses, Used by, and Contents for a folder or Declarations for a file, each with its
|
|
490
|
+
* count. A real tab list: the arrow keys, Home and End move between the tabs, and only the one on screen is in the Tab
|
|
491
|
+
* order. They wrap onto a second line in a very narrow panel rather than run past its edge.
|
|
492
|
+
*/
|
|
493
|
+
function Relations({ data, tab, onTab, onFocus, onOpen, onViewLines }: RelationsProps){
|
|
494
|
+
const id = useId();
|
|
495
|
+
const file = data.kind === "file";
|
|
496
|
+
const tabs: { id: InspectorTab; label: string; count: number }[] = [
|
|
497
|
+
{ id: "uses", label: "Uses", count: data.uses.length },
|
|
498
|
+
{ id: "usedBy", label: "Used by", count: data.usedBy.length },
|
|
499
|
+
{ id: "inside", label: file ? "Declarations" : "Contents", count: (file ? data.symbols?.length : data.children?.length) ?? 0 },
|
|
500
|
+
];
|
|
501
|
+
|
|
502
|
+
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
|
|
503
|
+
const at = tabs.findIndex((t) => t.id === tab);
|
|
504
|
+
const next = event.key === "ArrowRight" ? (at + 1) % tabs.length
|
|
505
|
+
: event.key === "ArrowLeft" ? (at - 1 + tabs.length) % tabs.length
|
|
506
|
+
: event.key === "Home" ? 0
|
|
507
|
+
: event.key === "End" ? tabs.length - 1
|
|
508
|
+
: -1;
|
|
509
|
+
if (next === -1) return;
|
|
510
|
+
// the Map's own ← and → turn the page: these keys are the tabs' while a tab has the focus
|
|
511
|
+
event.preventDefault();
|
|
512
|
+
event.stopPropagation();
|
|
513
|
+
onTab(tabs[next].id);
|
|
514
|
+
event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="tab"]')[next]?.focus();
|
|
515
|
+
};
|
|
516
|
+
|
|
463
517
|
return (
|
|
464
|
-
<div
|
|
465
|
-
<
|
|
466
|
-
{
|
|
467
|
-
|
|
468
|
-
|
|
518
|
+
<div>
|
|
519
|
+
<div role="tablist" aria-label="Relationships" onKeyDown={onKeyDown} className="flex flex-wrap gap-x-[18px] border-b border-divider @max-[260px]:gap-x-3">
|
|
520
|
+
{tabs.map((t) => {
|
|
521
|
+
const on = t.id === tab;
|
|
522
|
+
return (
|
|
523
|
+
<button
|
|
524
|
+
key={t.id}
|
|
525
|
+
type="button"
|
|
526
|
+
role="tab"
|
|
527
|
+
id={`${id}-${t.id}`}
|
|
528
|
+
data-tab={t.id}
|
|
529
|
+
aria-selected={on}
|
|
530
|
+
aria-controls={`${id}-panel`}
|
|
531
|
+
tabIndex={on ? 0 : -1}
|
|
532
|
+
title={TAB_TITLES[t.id]}
|
|
533
|
+
onClick={() => onTab(t.id)}
|
|
534
|
+
// Uses and Used by are underlined in the color their lines take on the Map, Contents in the accent
|
|
535
|
+
className={`relative whitespace-nowrap pb-2 pt-1.5 text-[13px] ${on
|
|
536
|
+
? `text-text after:absolute after:inset-x-0 after:-bottom-px after:h-0.5 after:rounded-[1px] ${t.id === "inside" ? "after:bg-accent" : SIDE_COLOR[t.id].underline}`
|
|
537
|
+
: "text-text-2 hover:text-text"}`}
|
|
538
|
+
>
|
|
539
|
+
{t.label} {formatCount(t.count)}
|
|
540
|
+
</button>
|
|
541
|
+
);
|
|
542
|
+
})}
|
|
543
|
+
</div>
|
|
544
|
+
<div role="tabpanel" id={`${id}-panel`} aria-labelledby={`${id}-${tab}`} className="pt-2">
|
|
545
|
+
{tab === "uses" && <Areas key="uses" items={data.uses} empty="Imports nothing outside itself." onFocus={onFocus} />}
|
|
546
|
+
{tab === "usedBy" && <Areas key="usedBy" items={data.usedBy} empty="Nothing outside it imports it." onFocus={onFocus} usedBy />}
|
|
547
|
+
{tab === "inside" && file && (
|
|
548
|
+
<Preview items={data.symbols ?? []} empty={noDeclarationsText(data)} render={(symbol: SymbolInfo) => (
|
|
549
|
+
<DeclarationRow key={`${symbol.name}:${symbol.startLine}`} symbol={symbol} onViewLines={onViewLines} />
|
|
550
|
+
)} />
|
|
551
|
+
)}
|
|
552
|
+
{tab === "inside" && !file && (
|
|
553
|
+
<Preview items={data.children ?? []} empty="Nothing inside." render={(child: MapNode) => (
|
|
554
|
+
<ChildRow key={child.path} child={child} onFocus={onFocus} onOpen={onOpen} />
|
|
555
|
+
)} />
|
|
556
|
+
)}
|
|
557
|
+
</div>
|
|
469
558
|
</div>
|
|
470
559
|
);
|
|
471
560
|
}
|
|
472
561
|
|
|
473
|
-
|
|
562
|
+
/** Uses or Used by: each area a row that focuses it on the Map, its bar measured against the tab's largest. */
|
|
563
|
+
function Areas({ items, empty, onFocus, usedBy = false }: { items: readonly AreaCount[]; empty: string; onFocus: (targetPath: string) => void; usedBy?: boolean }){
|
|
564
|
+
const largest = largestCount(items);
|
|
565
|
+
return (
|
|
566
|
+
<Preview items={items} empty={empty} render={(area: AreaCount) => (
|
|
567
|
+
<BarRow
|
|
568
|
+
key={area.path}
|
|
569
|
+
chip={area.path}
|
|
570
|
+
name={shortPath(area.path)}
|
|
571
|
+
count={area.count}
|
|
572
|
+
largest={largest}
|
|
573
|
+
title={importsTitle(area.path, area.count)}
|
|
574
|
+
onClick={() => onFocus(area.path)}
|
|
575
|
+
usedBy={usedBy}
|
|
576
|
+
/>
|
|
577
|
+
)} />
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
type BarRowProps = {
|
|
582
|
+
name: string;
|
|
583
|
+
count: number;
|
|
584
|
+
largest: number;
|
|
585
|
+
/** Holds the name, so a name cut off with an ellipsis can be read in full. */
|
|
586
|
+
title: string;
|
|
587
|
+
onClick: () => void;
|
|
588
|
+
/** data-chip, on Uses and Used by rows. */
|
|
589
|
+
chip?: string;
|
|
590
|
+
/** data-linked, on a doorway's rows. */
|
|
591
|
+
linked?: string;
|
|
592
|
+
/** A Used by row: its bar in the gold its line takes on the Map, where every other row's is lilac. */
|
|
593
|
+
usedBy?: boolean;
|
|
594
|
+
};
|
|
474
595
|
|
|
475
596
|
/**
|
|
476
|
-
*
|
|
477
|
-
*
|
|
597
|
+
* A row with a bar (the mockup's .irow): the name in the code font, cut off with an ellipsis, the count on the right,
|
|
598
|
+
* and under both a 3 px bar as long as the count is against the largest in its list.
|
|
478
599
|
*/
|
|
479
|
-
function
|
|
480
|
-
const [all, setAll] = useState(false);
|
|
481
|
-
const shown = all ? items : items.slice(0, PREVIEW);
|
|
600
|
+
function BarRow({ name, count, largest, title, onClick, chip, linked, usedBy = false }: BarRowProps){
|
|
482
601
|
return (
|
|
483
|
-
<
|
|
484
|
-
<
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
>
|
|
498
|
-
<span className="truncate">{shortPath(area.path)}</span>
|
|
499
|
-
<span className="shrink-0 text-text-2">{formatCount(area.count)}</span>
|
|
500
|
-
</button>
|
|
501
|
-
</li>
|
|
502
|
-
))}
|
|
503
|
-
{items.length > PREVIEW && (
|
|
504
|
-
<li>
|
|
505
|
-
<button
|
|
506
|
-
type="button"
|
|
507
|
-
aria-expanded={all}
|
|
508
|
-
onClick={() => setAll((open) => !open)}
|
|
509
|
-
className="rounded-md border border-dashed border-border px-2 py-1 text-[13px] text-text-2 hover:text-text"
|
|
510
|
-
>
|
|
511
|
-
{all ? "Show fewer" : `+${formatCount(items.length - PREVIEW)} more`}
|
|
512
|
-
</button>
|
|
513
|
-
</li>
|
|
514
|
-
)}
|
|
515
|
-
</ul>
|
|
516
|
-
)}
|
|
517
|
-
</div>
|
|
602
|
+
<li>
|
|
603
|
+
<button
|
|
604
|
+
type="button"
|
|
605
|
+
data-chip={chip}
|
|
606
|
+
data-linked={linked}
|
|
607
|
+
title={title}
|
|
608
|
+
onClick={onClick}
|
|
609
|
+
className="grid w-full grid-cols-[minmax(0,1fr)_auto] items-center gap-x-2.5 rounded-[5px] px-2 py-1.5 text-left hover:bg-node"
|
|
610
|
+
>
|
|
611
|
+
<span className="truncate font-mono text-[12.5px] leading-[18px] text-text">{name}</span>
|
|
612
|
+
<span className="text-[11.5px] leading-4 text-text-2">{formatCount(count)}</span>
|
|
613
|
+
<span aria-hidden className={`col-span-2 mt-[5px] h-[3px] rounded-[2px] ${SIDE_COLOR[usedBy ? "usedBy" : "uses"].fill}`} style={{ width: `${barWidth(count, largest)}%` }} />
|
|
614
|
+
</button>
|
|
615
|
+
</li>
|
|
518
616
|
);
|
|
519
617
|
}
|
|
520
618
|
|
|
521
|
-
/** A child in Contents: a folder goes into it, a file is selected on the Map
|
|
619
|
+
/** A child in Contents: a folder goes into it, a file is selected on the Map. Its icon is the explorer's. */
|
|
522
620
|
function ChildRow({ child, onFocus, onOpen }: { child: MapNode; onFocus: (targetPath: string) => void; onOpen: (folder: string) => void }){
|
|
523
621
|
const folder = child.kind === "folder";
|
|
524
622
|
const Icon = folder ? IconFolder : IconFile;
|
|
@@ -527,46 +625,59 @@ function ChildRow({ child, onFocus, onOpen }: { child: MapNode; onFocus: (target
|
|
|
527
625
|
<button
|
|
528
626
|
type="button"
|
|
529
627
|
data-child={child.path}
|
|
530
|
-
title={child
|
|
628
|
+
title={childTitle(child)}
|
|
531
629
|
onClick={() => (folder ? onOpen(child.path) : onFocus(child.path))}
|
|
532
|
-
className="
|
|
630
|
+
className="grid w-full grid-cols-[14px_minmax(0,1fr)_auto] items-center gap-x-2 rounded-[5px] px-2 py-1.5 text-left hover:bg-node"
|
|
533
631
|
>
|
|
534
|
-
<Icon aria-hidden size={
|
|
535
|
-
<span className=
|
|
536
|
-
{folder && <span className="
|
|
632
|
+
<Icon aria-hidden size={14} stroke={1.75} className="text-text-3" />
|
|
633
|
+
<span className={`truncate font-mono text-[12.5px] leading-[18px] text-text ${folder ? "" : "col-span-2"}`}>{child.name}</span>
|
|
634
|
+
{folder && <span className="text-[11.5px] leading-4 text-text-2">{formatCount(child.fileCount)}</span>}
|
|
537
635
|
</button>
|
|
538
636
|
</li>
|
|
539
637
|
);
|
|
540
638
|
}
|
|
541
639
|
|
|
542
|
-
|
|
543
|
-
|
|
640
|
+
/**
|
|
641
|
+
* A declaration in a file: its name in the code font (brighter when it's exported), and its kind and line on the right.
|
|
642
|
+
* The name keeps at least 6 characters before the place moves under it, right-aligned, in a very narrow panel.
|
|
643
|
+
*/
|
|
644
|
+
function DeclarationRow({ symbol, onViewLines }: { symbol: SymbolInfo; onViewLines: (start: number, end: number) => void }){
|
|
645
|
+
return (
|
|
646
|
+
<li>
|
|
647
|
+
<button
|
|
648
|
+
type="button"
|
|
649
|
+
title={`Show ${symbol.name} in the source`}
|
|
650
|
+
onClick={() => onViewLines(symbol.startLine, symbol.endLine)}
|
|
651
|
+
className="flex w-full flex-wrap items-baseline gap-x-3 rounded-[5px] px-2 py-1.5 text-left hover:bg-node"
|
|
652
|
+
>
|
|
653
|
+
<span className={`min-w-0 flex-[1_1_6ch] truncate font-mono text-[12.5px] leading-[18px] ${symbol.exported ? "text-text" : "text-text-2"}`}>{symbol.name}</span>
|
|
654
|
+
<span className="ml-auto shrink-0 whitespace-nowrap text-[11.5px] leading-4 text-text-2">{declarationPlace(symbol)}</span>
|
|
655
|
+
</button>
|
|
656
|
+
</li>
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
type PreviewProps<T> = {
|
|
544
661
|
items: readonly T[];
|
|
662
|
+
/** Said in place of the list when it has no rows. */
|
|
663
|
+
empty: string;
|
|
545
664
|
render: (item: T) => ReactNode;
|
|
546
|
-
empty?: string;
|
|
547
665
|
};
|
|
548
666
|
|
|
549
|
-
/** A
|
|
550
|
-
function
|
|
667
|
+
/** A list's first 8 rows, then "+N more" shows the rest and "Show fewer" folds it back. */
|
|
668
|
+
function Preview<T>({ items, empty, render }: PreviewProps<T>){
|
|
551
669
|
const [all, setAll] = useState(false);
|
|
552
|
-
if (items.length === 0
|
|
670
|
+
if (items.length === 0) return <p className="px-2 py-1.5 text-[13px] text-text-2">{empty}</p>;
|
|
553
671
|
|
|
554
672
|
return (
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
{items
|
|
558
|
-
? <p className="text-[13px] text-text-2">{empty}</p>
|
|
559
|
-
: <ul className="-mx-2">{(all ? items : items.slice(0, PREVIEW)).map(render)}</ul>}
|
|
673
|
+
<>
|
|
674
|
+
{/* a plain block, not a grid: an auto grid track would widen to a long declaration's whole name */}
|
|
675
|
+
<ul className="space-y-0.5">{(all ? items : items.slice(0, PREVIEW)).map(render)}</ul>
|
|
560
676
|
{items.length > PREVIEW && (
|
|
561
|
-
<button
|
|
562
|
-
|
|
563
|
-
aria-expanded={all}
|
|
564
|
-
onClick={() => setAll((open) => !open)}
|
|
565
|
-
className="mt-2 text-[13px] text-control underline underline-offset-2 hover:text-text"
|
|
566
|
-
>
|
|
567
|
-
{all ? "Show fewer" : `View all ${formatCount(items.length)}`}
|
|
677
|
+
<button type="button" aria-expanded={all} onClick={() => setAll((open) => !open)} className={QUIET}>
|
|
678
|
+
{all ? "Show fewer" : `+${formatCount(items.length - PREVIEW)} more`}
|
|
568
679
|
</button>
|
|
569
680
|
)}
|
|
570
|
-
|
|
681
|
+
</>
|
|
571
682
|
);
|
|
572
683
|
}
|