Skip to content

Commit fcbaa92

Browse files
committed
Add Breadcrumb export to Markdown
1 parent b295e97 commit fcbaa92

6 files changed

Lines changed: 163 additions & 3 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ Security Notes allows the creation of notes within source files, which can be re
4242

4343
Breadcrumbs let you capture the path you follow while reverse-engineering a feature. Start a trail with `Security Notes: Create Breadcrumb Trail`, highlight the snippets you visit, and run `Security Notes: Add Breadcrumb Crumb` to drop "crumbs" along the way. Each crumb stores the code selection, file/line information, and an optional note.
4444

45-
Open the **Breadcrumbs** view from the Security Notes activity bar to see an interactive diagram of the active trail. Click any crumb in the diagram to jump back to that snippet in the editor, or switch trails from the dropdown to review other investigations. Trails are stored locally in `.security-notes-breadcrumbs.json` so you can revisit them later.
45+
Open the **Breadcrumbs** view from the Security Notes activity bar to see an interactive diagram of the active trail. Click any crumb in the diagram to jump back to that snippet in the editor, or switch trails from the dropdown to review other investigations. Trails are stored locally in `.security-notes-breadcrumbs.json` so you can revisit them later, and you can export the active trail to a Markdown report (via `Security Notes: Export Breadcrumb Trail` or the Export button) ready to paste into docs or reports.
4646

4747
## Local database for Comments
4848

package.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,10 @@
104104
{
105105
"command": "security-notes.breadcrumbs.showTrailDiagram",
106106
"title": "Security Notes: Show Breadcrumb Diagram"
107+
},
108+
{
109+
"command": "security-notes.breadcrumbs.exportTrail",
110+
"title": "Security Notes: Export Breadcrumb Trail"
107111
}
108112
],
109113
"configuration": {
@@ -272,6 +276,11 @@
272276
"command": "security-notes.breadcrumbs.createTrail",
273277
"group": "navigation@2",
274278
"when": "view == breadcrumbs-view"
279+
},
280+
{
281+
"command": "security-notes.breadcrumbs.exportTrail",
282+
"group": "navigation@3",
283+
"when": "view == breadcrumbs-view"
275284
}
276285
]
277286
},

src/breadcrumbs/commands.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { BreadcrumbStore } from './store';
55
import { Crumb, Trail } from '../models/breadcrumb';
66
import { fullPathToRelative } from '../utils';
77
import { formatRangeLabel, snippetPreview } from './format';
8+
import { exportTrailToMarkdown } from './export';
89

910
interface TrailQuickPickItem extends vscode.QuickPickItem {
1011
trail: Trail;
@@ -104,6 +105,7 @@ export const revealCrumb = async (crumb: Crumb) => {
104105

105106
interface RegisterBreadcrumbCommandsOptions {
106107
onShowTrailDiagram?: (trail: Trail) => Promise<void> | void;
108+
onExportTrail?: (trail: Trail) => Promise<void> | void;
107109
}
108110

109111
export const registerBreadcrumbCommands = (
@@ -264,5 +266,19 @@ export const registerBreadcrumbCommands = (
264266
}),
265267
);
266268

269+
disposables.push(
270+
vscode.commands.registerCommand('security-notes.breadcrumbs.exportTrail', async () => {
271+
const trail = await ensureActiveTrail(store);
272+
if (!trail) {
273+
return;
274+
}
275+
if (options.onExportTrail) {
276+
await options.onExportTrail(trail);
277+
} else {
278+
await exportTrailToMarkdown(trail);
279+
}
280+
}),
281+
);
282+
267283
disposables.forEach((disposable) => context.subscriptions.push(disposable));
268284
};

src/breadcrumbs/export.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
'use strict';
2+
3+
import * as vscode from 'vscode';
4+
import { Trail } from '../models/breadcrumb';
5+
import { formatRangeLabel } from './format';
6+
import { fullPathToRelative } from '../utils';
7+
8+
const escapeCodeBlock = (value: string) => value.replace(/```/g, '\`\`\`');
9+
10+
const headline = (level: number, text: string) => `${'#'.repeat(level)} ${text}`;
11+
12+
const formatDate = (value: string | undefined) =>
13+
value ? new Date(value).toLocaleString() : undefined;
14+
15+
const buildSummary = (trail: Trail) => {
16+
const files = new Set(trail.crumbs.map((crumb) => fullPathToRelative(crumb.uri.fsPath)));
17+
const first = formatDate(trail.crumbs[0]?.createdAt);
18+
const last = formatDate(trail.crumbs[trail.crumbs.length - 1]?.createdAt);
19+
20+
const lines: string[] = [];
21+
lines.push(headline(2, 'Summary'));
22+
lines.push('');
23+
lines.push(`- **Total crumbs:** ${trail.crumbs.length}`);
24+
lines.push(`- **Files touched:** ${files.size}`);
25+
lines.push(`- **Generated:** ${formatDate(new Date().toISOString())}`);
26+
if (first && last) {
27+
lines.push(`- **Investigation window:** ${first}${last}`);
28+
}
29+
lines.push('');
30+
return lines.join('\n');
31+
};
32+
33+
const buildCrumbSection = (trail: Trail) => {
34+
const lines: string[] = [];
35+
lines.push(headline(2, 'Trail'));
36+
lines.push('');
37+
38+
trail.crumbs.forEach((crumb, index) => {
39+
const filePath = fullPathToRelative(crumb.uri.fsPath);
40+
const rangeLabel = formatRangeLabel(crumb.range);
41+
const createdAt = formatDate(crumb.createdAt) ?? 'n/a';
42+
lines.push(headline(3, `${index + 1}. ${filePath}:${rangeLabel}`));
43+
lines.push('');
44+
lines.push(`- **Captured:** ${createdAt}`);
45+
if (crumb.note) {
46+
lines.push(`- **Note:** ${crumb.note}`);
47+
}
48+
lines.push('');
49+
lines.push('```');
50+
lines.push(escapeCodeBlock(crumb.snippet));
51+
lines.push('```');
52+
lines.push('');
53+
});
54+
55+
return lines.join('\n');
56+
};
57+
58+
const generateTrailMarkdown = (trail: Trail) => {
59+
const lines: string[] = [];
60+
lines.push(headline(1, `Breadcrumb Trail – ${trail.name}`));
61+
lines.push('');
62+
if (trail.description) {
63+
lines.push(trail.description);
64+
lines.push('');
65+
}
66+
lines.push(buildSummary(trail));
67+
lines.push(buildCrumbSection(trail));
68+
return lines.join('\n');
69+
};
70+
71+
export const exportTrailToMarkdown = async (trail: Trail, uri?: vscode.Uri) => {
72+
if (!trail.crumbs.length) {
73+
vscode.window.showInformationMessage('[Breadcrumbs] Cannot export an empty trail.');
74+
return undefined;
75+
}
76+
77+
const markdown = generateTrailMarkdown(trail);
78+
const buffer = Buffer.from(markdown, 'utf8');
79+
80+
if (!uri) {
81+
const fileNameSafe = trail.name.replace(/[^a-z0-9\-_]+/gi, '-').replace(/-+/g, '-');
82+
const defaultUri = vscode.workspace.workspaceFolders?.length
83+
? vscode.Uri.joinPath(
84+
vscode.workspace.workspaceFolders[0].uri,
85+
`${fileNameSafe || 'breadcrumb-trail'}.md`,
86+
)
87+
: undefined;
88+
89+
uri = await vscode.window.showSaveDialog({
90+
filters: { Markdown: ['md', 'markdown'] },
91+
defaultUri,
92+
saveLabel: 'Export Breadcrumb Trail',
93+
});
94+
if (!uri) {
95+
return undefined;
96+
}
97+
}
98+
99+
await vscode.workspace.fs.writeFile(uri, buffer);
100+
101+
const selection = await vscode.window.showInformationMessage(
102+
`Breadcrumb trail exported to ${uri.fsPath}.`,
103+
'Open export',
104+
);
105+
106+
if (selection === 'Open export') {
107+
const document = await vscode.workspace.openTextDocument(uri);
108+
await vscode.window.showTextDocument(document, { preview: false });
109+
}
110+
111+
return uri;
112+
};

src/webviews/assets/breadcrumbs.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
const content = document.getElementById('breadcrumbs-content');
88
const createButton = document.querySelector('[data-action="create"]');
99
const addButton = document.querySelector('[data-action="add"]');
10+
const exportButton = document.querySelector('[data-action="export"]');
1011

1112
let currentState;
1213

@@ -40,6 +41,10 @@
4041
vscode.postMessage({ type: 'addCrumb' });
4142
});
4243

44+
exportButton.addEventListener('click', () => {
45+
vscode.postMessage({ type: 'exportTrail' });
46+
});
47+
4348
function renderState(state) {
4449
populateTrailSelect(state);
4550

src/webviews/breadcrumbs/breadcrumbsWebview.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Crumb, Trail } from '../../models/breadcrumb';
66
import { formatRangeLabel, snippetPreview } from '../../breadcrumbs/format';
77
import { fullPathToRelative } from '../../utils';
88
import { revealCrumb } from '../../breadcrumbs/commands';
9+
import { exportTrailToMarkdown } from '../../breadcrumbs/export';
910

1011
interface WebviewCrumb {
1112
id: string;
@@ -42,7 +43,8 @@ type WebviewMessage =
4243
| { type: 'openCrumb'; trailId: string; crumbId: string }
4344
| { type: 'setActiveTrail'; trailId: string }
4445
| { type: 'createTrail' }
45-
| { type: 'addCrumb' };
46+
| { type: 'addCrumb' }
47+
| { type: 'exportTrail' };
4648

4749
export class BreadcrumbsWebview implements vscode.WebviewViewProvider, vscode.Disposable {
4850
public static readonly viewType = 'breadcrumbs-view';
@@ -114,9 +116,13 @@ export class BreadcrumbsWebview implements vscode.WebviewViewProvider, vscode.Di
114116
vscode.commands.executeCommand('security-notes.breadcrumbs.createTrail');
115117
break;
116118
}
117-
case 'addCrumb': {
119+
/*case 'addCrumb': {
118120
vscode.commands.executeCommand('security-notes.breadcrumbs.addCrumb');
119121
break;
122+
}*/
123+
case 'exportTrail': {
124+
this.handleExportTrail();
125+
break;
120126
}
121127
default: {
122128
break;
@@ -199,6 +205,17 @@ export class BreadcrumbsWebview implements vscode.WebviewViewProvider, vscode.Di
199205
revealCrumb(crumb);
200206
}
201207

208+
private async handleExportTrail() {
209+
const activeTrail = this.store.getActiveTrail();
210+
if (!activeTrail) {
211+
vscode.window.showInformationMessage(
212+
'[Breadcrumbs] Select a trail before exporting.',
213+
);
214+
return;
215+
}
216+
await exportTrailToMarkdown(activeTrail);
217+
}
218+
202219
private getHtmlForWebview(webview: vscode.Webview) {
203220
const scriptUri = webview.asWebviewUri(
204221
vscode.Uri.joinPath(
@@ -249,6 +266,7 @@ export class BreadcrumbsWebview implements vscode.WebviewViewProvider, vscode.Di
249266
<div class="breadcrumbs-actions">
250267
<button class="breadcrumbs-button" data-action="create">New trail</button>
251268
<button class="breadcrumbs-button" data-action="add">Add crumb</button>
269+
<button class="breadcrumbs-button" data-action="export">Export</button>
252270
</div>
253271
</section>
254272
<section>

0 commit comments

Comments
 (0)