Skip to content

Commit 3926731

Browse files
feat(cdk-explorer): three-way linked navigation and source-to-template double-click
- Add source→template navigation: double-click a line in the source pane to jump to the resource it produces in the template pane - Fix template highlight coordinates: TemplateViewer now resolves highlights from logicalId using its own display-format resources (JSON or YAML), eliminating the JSON-line-in-YAML-view mismatch - Replace js-yaml + regex-based YAML remapping with the `yaml` package's AST parser (parseDocument + LineCounter) for robust resource range resolution - Remove per-property line ranges from the API (block-level highlight only) - Add source-nav.ts: builds a per-file anchor index from the construct tree for efficient source-line-to-construct lookups
1 parent da61772 commit 3926731

10 files changed

Lines changed: 916 additions & 1291 deletions

File tree

.projenrc.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1736,6 +1736,7 @@ const cdkExplorer = configureProject(
17361736
'supertest@^6',
17371737
'@types/supertest@^6',
17381738
'@types/convert-source-map@^2',
1739+
'yaml@^2',
17391740
],
17401741
tsconfig: {
17411742
compilerOptions: {

packages/@aws-cdk/cdk-explorer/frontend/App.tsx

Lines changed: 29 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ import Header from '@cloudscape-design/components/header';
55
import SpaceBetween from '@cloudscape-design/components/space-between';
66
import Spinner from '@cloudscape-design/components/spinner';
77
import * as React from 'react';
8-
import { api, type DirEntry, type LineRange, type TemplateResponse, type TreeResponse, type ViolationsResponse } from './api';
8+
import { buildSourceAnchorIndex, findConstructAtLine } from '../lib/web/source-nav';
9+
import { api, type DirEntry, type TemplateResponse, type TreeResponse, type ViolationsResponse } from './api';
910
import { CodeViewer, type Diagnostic } from './components/CodeViewer';
1011
import { ConstructTree } from './components/ConstructTree';
1112
import { TemplateViewer } from './components/TemplateViewer';
@@ -16,7 +17,7 @@ export type { NavigateHandler } from './nav-types';
1617
/** Navigation target describing what both panes should show. */
1718
export interface NavTarget {
1819
readonly source?: { file: string; startLine: number; endLine: number };
19-
readonly template?: { file: string; logicalId: string; startLine: number; endLine: number; propertyLines?: LineRange };
20+
readonly template?: { file: string; logicalId: string };
2021
readonly color: string;
2122
readonly navCounter: number;
2223
}
@@ -83,39 +84,19 @@ export function App(): JSX.Element {
8384

8485
let templateTarget: NavTarget['template'];
8586
if (opts.templateFile && opts.logicalId) {
86-
let data = templateDataRef.current;
8787
if (templateFileRef.current !== opts.templateFile) {
8888
try {
89-
data = await api.getTemplate(opts.templateFile);
89+
const data = await api.getTemplate(opts.templateFile);
9090
setTemplateFile(opts.templateFile);
9191
setTemplateData(data);
9292
} catch {
9393
setTemplateFile(opts.templateFile);
9494
setTemplateData(undefined);
95-
data = undefined;
96-
}
97-
}
98-
if (data) {
99-
const resource = data.resources[opts.logicalId];
100-
if (resource) {
101-
let propertyLines: LineRange | undefined;
102-
if (opts.propertyPaths?.length) {
103-
const firstPath = opts.propertyPaths[0];
104-
const segments = firstPath.split('.');
105-
const propName = segments[0] === 'Properties' ? segments[1] : segments[0];
106-
if (propName && resource.properties?.[propName]) {
107-
propertyLines = resource.properties[propName];
108-
}
109-
}
110-
templateTarget = {
111-
file: opts.templateFile,
112-
logicalId: opts.logicalId,
113-
startLine: propertyLines?.startLine ?? resource.block.startLine,
114-
endLine: propertyLines?.endLine ?? resource.block.endLine,
115-
propertyLines,
116-
};
11795
}
11896
}
97+
// Carry identity only; TemplateViewer resolves the highlight line from the
98+
// rendered format (JSON or YAML), which is the sole coordinate authority.
99+
templateTarget = { file: opts.templateFile, logicalId: opts.logicalId };
119100
}
120101

121102
setNav({ source: sourceTarget, template: templateTarget, color, navCounter: counter });
@@ -143,17 +124,25 @@ export function App(): JSX.Element {
143124
});
144125
}, []);
145126

146-
/** Double-click a line in the template pane to jump to source. */
147-
const handleTemplateDoubleClick = React.useCallback((line: number) => {
148-
const data = templateDataRef.current;
149-
if (!data) return;
150-
for (const [logicalId, resource] of Object.entries(data.resources)) {
151-
if (line >= resource.block.startLine && line <= resource.block.endLine && resource.source) {
152-
void jumpToSource(logicalId);
153-
return;
154-
}
155-
}
156-
}, [jumpToSource]);
127+
// Per-file index of constructs navigable from source to template, built from
128+
// the construct tree the app already loads. Rebuilt only when the tree changes.
129+
const sourceAnchors = React.useMemo(
130+
() => (tree?.status === 'ok' ? buildSourceAnchorIndex(tree.tree) : undefined),
131+
[tree],
132+
);
133+
134+
/** Double-click a line in the source pane to jump to its resource in the template. */
135+
const handleSourceDoubleClick = React.useCallback((line: number) => {
136+
const file = sourceFileRef.current;
137+
if (!file) return;
138+
const node = findConstructAtLine(sourceAnchors?.get(file), line);
139+
if (!node) return;
140+
void navigate({
141+
sourceLocation: node.sourceLocation,
142+
templateFile: node.templateFile,
143+
logicalId: node.logicalId,
144+
});
145+
}, [sourceAnchors, navigate]);
157146

158147
// File picker state.
159148
const [showFilePicker, setShowFilePicker] = React.useState<false | 'source' | 'template'>(false);
@@ -235,6 +224,7 @@ export function App(): JSX.Element {
235224
highlightColor={nav?.color}
236225
navCounter={nav?.navCounter}
237226
scrollToLine={nav?.source?.startLine}
227+
onLineDoubleClick={handleSourceDoubleClick}
238228
diagnostics={buildDiagnostics(sourceFile, violations)}
239229
/>
240230
) : (
@@ -257,12 +247,10 @@ export function App(): JSX.Element {
257247
<TemplateViewer
258248
jsonContent={templateData.content}
259249
resources={templateData.resources}
260-
highlightStart={nav?.template?.startLine}
261-
highlightEnd={nav?.template?.endLine}
250+
highlightLogicalId={nav?.template?.logicalId}
262251
highlightColor={nav?.color}
263252
navCounter={nav?.navCounter}
264-
scrollToLine={nav?.template?.startLine}
265-
onLineDoubleClick={handleTemplateDoubleClick}
253+
onResourceDoubleClick={jumpToSource}
266254
/>
267255
) : templateFile ? (
268256
<Box color="text-status-error">Could not load {templateFile}</Box>

packages/@aws-cdk/cdk-explorer/frontend/components/TemplateViewer.tsx

Lines changed: 48 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,15 @@
1-
import * as yaml from 'js-yaml';
1+
import { isNode, isScalar, isMap, LineCounter, parseDocument, stringify } from 'yaml';
22
import * as React from 'react';
3-
import type { LineRange, TemplateResource } from '../api';
3+
import type { TemplateResource } from '../api';
44
import { CodeViewer } from './CodeViewer';
55

66
export interface TemplateViewerProps {
77
readonly jsonContent: string;
88
readonly resources: Record<string, TemplateResource>;
9-
readonly highlightStart?: number;
10-
readonly highlightEnd?: number;
9+
readonly highlightLogicalId?: string;
1110
readonly highlightColor?: string;
1211
readonly navCounter?: number;
13-
readonly scrollToLine?: number;
14-
readonly onLineDoubleClick?: (line: number) => void;
12+
readonly onResourceDoubleClick?: (logicalId: string) => void;
1513
}
1614

1715
type Format = 'yaml' | 'json';
@@ -25,12 +23,10 @@ interface ResourceSection {
2523
export function TemplateViewer({
2624
jsonContent,
2725
resources,
28-
highlightStart,
29-
highlightEnd,
26+
highlightLogicalId,
3027
highlightColor,
3128
navCounter,
32-
scrollToLine,
33-
onLineDoubleClick,
29+
onResourceDoubleClick,
3430
}: TemplateViewerProps): JSX.Element {
3531
const [format, setFormat] = React.useState<Format>('yaml');
3632
const [collapsedResources, setCollapsedResources] = React.useState<Set<string>>(new Set());
@@ -43,25 +39,23 @@ export function TemplateViewer({
4339
sections: buildSections(resources),
4440
};
4541
}
46-
return jsonToYaml(jsonContent, resources);
42+
return jsonToYaml(jsonContent);
4743
}, [jsonContent, resources, format]);
4844

4945
const filteredContent = React.useMemo(() => {
5046
if (collapsedResources.size === 0) return displayContent;
5147
return collapseContent(displayContent, sections, collapsedResources);
5248
}, [displayContent, sections, collapsedResources]);
5349

50+
// Resolve the highlight from the rendered format's own ranges (JSON block in
51+
// JSON view, YAML block in YAML view), so the highlight is always in the
52+
// coordinate system actually on screen. Derived, so toggling format re-resolves.
5453
const adjustedHighlight = React.useMemo(() => {
55-
if (highlightStart === undefined || highlightEnd === undefined) return undefined;
56-
if (collapsedResources.size === 0) return { start: highlightStart, end: highlightEnd };
57-
return adjustLineNumbers(highlightStart, highlightEnd, sections, collapsedResources);
58-
}, [highlightStart, highlightEnd, sections, collapsedResources]);
59-
60-
const adjustedScroll = React.useMemo(() => {
61-
if (scrollToLine === undefined) return undefined;
62-
if (collapsedResources.size === 0) return scrollToLine;
63-
return adjustSingleLine(scrollToLine, sections, collapsedResources);
64-
}, [scrollToLine, sections, collapsedResources]);
54+
const block = highlightLogicalId ? displayResources[highlightLogicalId]?.block : undefined;
55+
if (!block) return undefined;
56+
if (collapsedResources.size === 0) return { start: block.startLine, end: block.endLine };
57+
return adjustLineNumbers(block.startLine, block.endLine, sections, collapsedResources);
58+
}, [highlightLogicalId, displayResources, sections, collapsedResources]);
6559

6660
const toggleResource = React.useCallback((logicalId: string) => {
6761
setCollapsedResources((prev) => {
@@ -91,10 +85,15 @@ export function TemplateViewer({
9185
}
9286
}
9387

94-
if (onLineDoubleClick) {
95-
onLineDoubleClick(originalLine);
88+
if (onResourceDoubleClick) {
89+
// Map the clicked line back to the resource that owns it (display-coordinate
90+
// sections), so the reverse jump is identity-based like the forward one.
91+
const section = sections.find((s) => originalLine >= s.startLine && originalLine <= s.endLine);
92+
if (section) {
93+
onResourceDoubleClick(section.logicalId);
94+
}
9695
}
97-
}, [filteredContent, sections, collapsedResources, onLineDoubleClick, toggleResource]);
96+
}, [filteredContent, sections, collapsedResources, onResourceDoubleClick, toggleResource]);
9897

9998
const gutterDecorations = React.useMemo(() => {
10099
const decorations = new Map<number, { icon: string; logicalId: string }>();
@@ -141,7 +140,7 @@ export function TemplateViewer({
141140
highlightEnd={adjustedHighlight?.end}
142141
highlightColor={highlightColor}
143142
navCounter={navCounter}
144-
scrollToLine={adjustedScroll}
143+
scrollToLine={adjustedHighlight?.start}
145144
onLineDoubleClick={handleDoubleClick}
146145
/>
147146
</div>
@@ -160,77 +159,40 @@ interface YamlResult {
160159
sections: ResourceSection[];
161160
}
162161

163-
function jsonToYaml(jsonContent: string, jsonResources: Record<string, TemplateResource>): YamlResult {
162+
function jsonToYaml(jsonContent: string): YamlResult {
164163
let parsed: unknown;
165164
try {
166165
parsed = JSON.parse(jsonContent);
167166
} catch {
168-
return { displayContent: jsonContent, displayResources: jsonResources, sections: buildSections(jsonResources) };
167+
return { displayContent: jsonContent, displayResources: {}, sections: [] };
169168
}
170169

171-
const yamlContent = yaml.dump(parsed, { indent: 2, lineWidth: -1, noRefs: true, sortKeys: false });
172-
const yamlResources = remapResources(parsed as Record<string, unknown>, yamlContent);
173-
174-
return {
175-
displayContent: yamlContent,
176-
displayResources: yamlResources,
177-
sections: buildSections(yamlResources),
178-
};
179-
}
180-
181-
function remapResources(template: Record<string, unknown>, yamlText: string): Record<string, TemplateResource> {
182-
const resources = (template as { Resources?: Record<string, unknown> }).Resources;
183-
if (!resources) return {};
184-
185-
const lines = yamlText.split('\n');
186-
const result: Record<string, TemplateResource> = {};
187-
188-
for (const logicalId of Object.keys(resources)) {
189-
const pattern = new RegExp(`^ ${escapeRegex(logicalId)}:`);
190-
const startIdx = lines.findIndex((l) => pattern.test(l));
191-
if (startIdx === -1) continue;
192-
193-
let endIdx = startIdx + 1;
194-
while (endIdx < lines.length) {
195-
const line = lines[endIdx];
196-
if (line.length > 0 && !line.startsWith(' ') && !line.startsWith(' ') === false) {
197-
if (/^ \S/.test(line) && !line.startsWith(' ')) break;
170+
// Render and measure with the same parser so the text and the ranges always
171+
// agree. lineWidth:0 disables wrapping (keeps one CFN value per line, like the
172+
// old serializer); aliasDuplicateObjects:false keeps CloudFormation's repeated
173+
// objects inline instead of emitting &anchor/*alias.
174+
const displayContent = stringify(parsed, { indent: 2, lineWidth: 0, aliasDuplicateObjects: false });
175+
const lineCounter = new LineCounter();
176+
const doc = parseDocument(displayContent, { lineCounter });
177+
178+
const displayResources: Record<string, TemplateResource> = {};
179+
const resources = doc.get('Resources');
180+
if (isMap(resources)) {
181+
for (const pair of resources.items) {
182+
const key = pair.key;
183+
const value = pair.value;
184+
if (!isScalar(key) || !isNode(value) || key.range == null || value.range == null) {
185+
continue;
198186
}
199-
if (endIdx > startIdx && /^ [^\s]/.test(line)) break;
200-
endIdx++;
187+
// range is [valueStart, valueEnd, nodeEnd]; a resource block spans from its
188+
// logical-id key line through the end of its value.
189+
const startLine = lineCounter.linePos(key.range[0]).line;
190+
const endLine = lineCounter.linePos(value.range[1]).line;
191+
displayResources[String(key.value)] = { block: { startLine, endLine } };
201192
}
202-
203-
const block: LineRange = { startLine: startIdx + 1, endLine: endIdx };
204-
205-
const properties: Record<string, LineRange> = {};
206-
const resourceObj = resources[logicalId] as Record<string, unknown> | undefined;
207-
if (resourceObj?.Properties && typeof resourceObj.Properties === 'object') {
208-
for (const propName of Object.keys(resourceObj.Properties as Record<string, unknown>)) {
209-
const propPattern = new RegExp(`^ ${escapeRegex(propName)}:`);
210-
for (let pi = startIdx; pi < endIdx; pi++) {
211-
if (propPattern.test(lines[pi])) {
212-
let propEnd = pi + 1;
213-
while (propEnd < endIdx && lines[propEnd]?.startsWith(' ')) {
214-
propEnd++;
215-
}
216-
properties[propName] = { startLine: pi + 1, endLine: propEnd };
217-
break;
218-
}
219-
}
220-
}
221-
}
222-
223-
result[logicalId] = {
224-
block,
225-
...(Object.keys(properties).length > 0 && { properties }),
226-
};
227193
}
228194

229-
return result;
230-
}
231-
232-
function escapeRegex(s: string): string {
233-
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
195+
return { displayContent, displayResources, sections: buildSections(displayResources) };
234196
}
235197

236198
function collapseContent(content: string, sections: ResourceSection[], collapsed: Set<string>): string {

packages/@aws-cdk/cdk-explorer/lib/web/protocol.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,6 @@ export interface LineRange {
7878
export interface TemplateResource {
7979
/** Line range of the resource's value block `{ ... }`. */
8080
readonly block: LineRange;
81-
/** Line range per top-level CFN property (PascalCase key). */
82-
readonly properties?: Record<string, LineRange>;
8381
/** User source location for the construct that owns this resource. */
8482
readonly source?: WebSourceLocation;
8583
}

packages/@aws-cdk/cdk-explorer/lib/web/routes.ts

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -326,8 +326,7 @@ function toOccurrence(
326326

327327
/**
328328
* Build the resource metadata map for a template file. Each resource gets its
329-
* block line range, per-property line ranges (if it has Properties), and the
330-
* source location of the construct that owns it.
329+
* block line range and the source location of the construct that owns it.
331330
*/
332331
function buildTemplateResources(
333332
content: string,
@@ -344,13 +343,6 @@ function buildTemplateResources(
344343

345344
for (const [logicalId, ranges] of Object.entries(allRanges)) {
346345
const block = offsetRangeToLineRange(ranges.block, lineOffsets);
347-
let properties: Record<string, LineRange> | undefined;
348-
if (Object.keys(ranges.properties).length > 0) {
349-
properties = {};
350-
for (const [prop, offsetRange] of Object.entries(ranges.properties)) {
351-
properties[prop] = offsetRangeToLineRange(offsetRange, lineOffsets);
352-
}
353-
}
354346

355347
let source: WebSourceLocation | undefined;
356348
if (index) {
@@ -360,7 +352,7 @@ function buildTemplateResources(
360352
}
361353
}
362354

363-
resources[logicalId] = { block, ...(properties && { properties }), ...(source && { source }) };
355+
resources[logicalId] = { block, ...(source && { source }) };
364356
}
365357
return resources;
366358
}

0 commit comments

Comments
 (0)