Skip to content

Commit b295e97

Browse files
committed
Add Breadcrumbs feature with basic functionality
1 parent 5643bac commit b295e97

12 files changed

Lines changed: 1369 additions & 28 deletions

File tree

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@ Security Notes allows the creation of notes within source files, which can be re
3838

3939
![Demo for basic usage](images/demo-basic-usage.gif)
4040

41+
## Breadcrumb Trails
42+
43+
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.
44+
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.
46+
4147
## Local database for Comments
4248

4349
By default your notes are backed up in a JSON file once you close VSCode. Once you open the project again, saved comments are loaded and shown on the UI.

package.json

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,30 @@
8080
{
8181
"command": "security-notes.saveNotesToFile",
8282
"title": "Security-Notes: Save Notes to Local Database"
83+
},
84+
{
85+
"command": "security-notes.breadcrumbs.createTrail",
86+
"title": "Security Notes: Create Breadcrumb Trail"
87+
},
88+
{
89+
"command": "security-notes.breadcrumbs.selectTrail",
90+
"title": "Security Notes: Select Active Breadcrumb Trail"
91+
},
92+
{
93+
"command": "security-notes.breadcrumbs.addCrumb",
94+
"title": "Security Notes: Add Breadcrumb Crumb"
95+
},
96+
{
97+
"command": "security-notes.breadcrumbs.removeCrumb",
98+
"title": "Security Notes: Remove Breadcrumb Crumb"
99+
},
100+
{
101+
"command": "security-notes.breadcrumbs.editCrumbNote",
102+
"title": "Security Notes: Edit Breadcrumb Crumb Note"
103+
},
104+
{
105+
"command": "security-notes.breadcrumbs.showTrailDiagram",
106+
"title": "Security Notes: Show Breadcrumb Diagram"
83107
}
84108
],
85109
"configuration": {
@@ -95,6 +119,11 @@
95119
"description": "Local database file path.",
96120
"default": ".security-notes.json"
97121
},
122+
"security-notes.breadcrumbs.localDatabase": {
123+
"type": "string",
124+
"description": "Local database file path for breadcrumb trails.",
125+
"default": ".security-notes-breadcrumbs.json"
126+
},
98127
"security-notes.collab.enabled": {
99128
"type": "boolean",
100129
"description": "Enable collaboration via RethinkDB.",
@@ -225,6 +254,25 @@
225254
"group": "inline@2",
226255
"when": "commentController == security-notes"
227256
}
257+
],
258+
"editor/context": [
259+
{
260+
"command": "security-notes.breadcrumbs.addCrumb",
261+
"group": "navigation@10",
262+
"when": "editorHasSelection"
263+
}
264+
],
265+
"view/title": [
266+
{
267+
"command": "security-notes.breadcrumbs.addCrumb",
268+
"group": "navigation",
269+
"when": "view == breadcrumbs-view"
270+
},
271+
{
272+
"command": "security-notes.breadcrumbs.createTrail",
273+
"group": "navigation@2",
274+
"when": "view == breadcrumbs-view"
275+
}
228276
]
229277
},
230278
"views": {
@@ -238,6 +286,11 @@
238286
"type": "webview",
239287
"name": "Export Notes",
240288
"id": "export-notes-view"
289+
},
290+
{
291+
"type": "webview",
292+
"name": "Breadcrumbs",
293+
"id": "breadcrumbs-view"
241294
}
242295
]
243296
},

src/breadcrumbs/commands.ts

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
'use strict';
2+
3+
import * as vscode from 'vscode';
4+
import { BreadcrumbStore } from './store';
5+
import { Crumb, Trail } from '../models/breadcrumb';
6+
import { fullPathToRelative } from '../utils';
7+
import { formatRangeLabel, snippetPreview } from './format';
8+
9+
interface TrailQuickPickItem extends vscode.QuickPickItem {
10+
trail: Trail;
11+
}
12+
13+
interface CrumbQuickPickItem extends vscode.QuickPickItem {
14+
crumb: Crumb;
15+
}
16+
17+
const mapTrailToQuickPickItem = (trail: Trail, activeTrailId?: string): TrailQuickPickItem => ({
18+
label: trail.name,
19+
description: trail.description,
20+
detail: `${trail.crumbs.length} crumb${trail.crumbs.length === 1 ? '' : 's'} · Last updated ${new Date(
21+
trail.updatedAt,
22+
).toLocaleString()}`,
23+
trail,
24+
picked: trail.id === activeTrailId,
25+
});
26+
27+
const mapCrumbToQuickPickItem = (crumb: Crumb, index: number): CrumbQuickPickItem => ({
28+
label: `${index + 1}. ${fullPathToRelative(crumb.uri.fsPath)}:${formatRangeLabel(crumb.range)}`,
29+
description: crumb.note,
30+
detail: snippetPreview(crumb.snippet),
31+
crumb,
32+
});
33+
34+
const ensureActiveTrail = async (
35+
store: BreadcrumbStore,
36+
options: { promptUser?: boolean } = { promptUser: true },
37+
): Promise<Trail | undefined> => {
38+
const activeTrail = store.getActiveTrail();
39+
if (activeTrail) {
40+
return activeTrail;
41+
}
42+
43+
const trails = store.getTrails();
44+
if (!trails.length) {
45+
if (options.promptUser) {
46+
vscode.window.showInformationMessage(
47+
'[Breadcrumbs] No breadcrumb trails yet. Create one before adding crumbs.',
48+
);
49+
}
50+
return undefined;
51+
}
52+
53+
if (!options.promptUser) {
54+
return undefined;
55+
}
56+
57+
const picked = await vscode.window.showQuickPick(
58+
trails.map((trail) => mapTrailToQuickPickItem(trail, store.getState().activeTrailId)),
59+
{
60+
placeHolder: 'Select a breadcrumb trail to work with',
61+
},
62+
);
63+
64+
if (!picked) {
65+
return undefined;
66+
}
67+
68+
store.setActiveTrail(picked.trail.id);
69+
return store.getTrail(picked.trail.id);
70+
};
71+
72+
const promptForTrail = async (store: BreadcrumbStore, placeHolder: string) => {
73+
const trails = store.getTrails();
74+
if (!trails.length) {
75+
vscode.window.showInformationMessage('[Breadcrumbs] No trails available. Create one first.');
76+
return undefined;
77+
}
78+
const picked = await vscode.window.showQuickPick(
79+
trails.map((trail) => mapTrailToQuickPickItem(trail, store.getState().activeTrailId)),
80+
{ placeHolder },
81+
);
82+
return picked?.trail;
83+
};
84+
85+
const promptForCrumb = async (trail: Trail, placeHolder: string): Promise<Crumb | undefined> => {
86+
if (!trail.crumbs.length) {
87+
vscode.window.showInformationMessage('[Breadcrumbs] The selected trail has no crumbs yet.');
88+
return undefined;
89+
}
90+
const picked = await vscode.window.showQuickPick(
91+
trail.crumbs.map((crumb, index) => mapCrumbToQuickPickItem(crumb, index)),
92+
{ placeHolder },
93+
);
94+
return picked?.crumb;
95+
};
96+
97+
export const revealCrumb = async (crumb: Crumb) => {
98+
const document = await vscode.workspace.openTextDocument(crumb.uri);
99+
const editor = await vscode.window.showTextDocument(document, { preview: false });
100+
const selection = new vscode.Selection(crumb.range.start, crumb.range.end);
101+
editor.selection = selection;
102+
editor.revealRange(crumb.range, vscode.TextEditorRevealType.InCenter);
103+
};
104+
105+
interface RegisterBreadcrumbCommandsOptions {
106+
onShowTrailDiagram?: (trail: Trail) => Promise<void> | void;
107+
}
108+
109+
export const registerBreadcrumbCommands = (
110+
context: vscode.ExtensionContext,
111+
store: BreadcrumbStore,
112+
options: RegisterBreadcrumbCommandsOptions = {},
113+
) => {
114+
const disposables: vscode.Disposable[] = [];
115+
116+
disposables.push(
117+
vscode.commands.registerCommand('security-notes.breadcrumbs.createTrail', async () => {
118+
const name = await vscode.window.showInputBox({
119+
prompt: 'Name for the new breadcrumb trail',
120+
placeHolder: 'e.g. User login flow',
121+
ignoreFocusOut: true,
122+
validateInput: (value) => (!value?.trim().length ? 'Trail name is required.' : undefined),
123+
});
124+
if (!name) {
125+
return;
126+
}
127+
const description = await vscode.window.showInputBox({
128+
prompt: 'Optional description',
129+
placeHolder: 'What does this trail capture?',
130+
ignoreFocusOut: true,
131+
});
132+
const trail = store.createTrail(name.trim(), {
133+
description: description?.trim() ? description.trim() : undefined,
134+
setActive: true,
135+
});
136+
vscode.window.showInformationMessage(
137+
`[Breadcrumbs] Created trail "${trail.name}" and set it as active.`,
138+
);
139+
}),
140+
);
141+
142+
disposables.push(
143+
vscode.commands.registerCommand('security-notes.breadcrumbs.selectTrail', async () => {
144+
const trail = await promptForTrail(store, 'Select the breadcrumb trail to activate');
145+
if (!trail) {
146+
return;
147+
}
148+
store.setActiveTrail(trail.id);
149+
vscode.window.showInformationMessage(
150+
`[Breadcrumbs] Active trail set to "${trail.name}".`,
151+
);
152+
}),
153+
);
154+
155+
disposables.push(
156+
vscode.commands.registerCommand('security-notes.breadcrumbs.addCrumb', async () => {
157+
const editor = vscode.window.activeTextEditor;
158+
if (!editor) {
159+
vscode.window.showInformationMessage(
160+
'[Breadcrumbs] Open a file and select the code you want to add as a crumb.',
161+
);
162+
return;
163+
}
164+
165+
const trail = await ensureActiveTrail(store);
166+
if (!trail) {
167+
return;
168+
}
169+
170+
const selection = editor.selection;
171+
const document = editor.document;
172+
const range = selection.isEmpty
173+
? document.lineAt(selection.start.line).range
174+
: new vscode.Range(selection.start, selection.end);
175+
const snippet = selection.isEmpty
176+
? document.lineAt(selection.start.line).text
177+
: document.getText(selection);
178+
179+
if (!snippet.trim().length) {
180+
vscode.window.showInformationMessage(
181+
'[Breadcrumbs] The selected snippet is empty. Expand the selection and try again.',
182+
);
183+
return;
184+
}
185+
186+
const note = await vscode.window.showInputBox({
187+
prompt: 'Optional note for this crumb',
188+
placeHolder: 'Why is this snippet important?',
189+
ignoreFocusOut: true,
190+
});
191+
192+
const crumb = store.addCrumb(trail.id, document.uri, range, snippet, {
193+
note: note?.trim() ? note.trim() : undefined,
194+
});
195+
196+
if (!crumb) {
197+
vscode.window.showErrorMessage('[Breadcrumbs] Failed to add crumb to the trail.');
198+
return;
199+
}
200+
201+
vscode.window.showInformationMessage(
202+
`[Breadcrumbs] Added crumb to "${trail.name}" at ${fullPathToRelative(
203+
crumb.uri.fsPath,
204+
)}:${formatRangeLabel(crumb.range)}.`,
205+
'View',
206+
).then((selectionAction) => {
207+
if (selectionAction === 'View') {
208+
revealCrumb(crumb);
209+
}
210+
});
211+
}),
212+
);
213+
214+
disposables.push(
215+
vscode.commands.registerCommand('security-notes.breadcrumbs.removeCrumb', async () => {
216+
const trail = await ensureActiveTrail(store);
217+
if (!trail) {
218+
return;
219+
}
220+
const crumb = await promptForCrumb(trail, 'Select the crumb to remove');
221+
if (!crumb) {
222+
return;
223+
}
224+
store.removeCrumb(trail.id, crumb.id);
225+
vscode.window.showInformationMessage(
226+
`[Breadcrumbs] Removed crumb from "${trail.name}".`,
227+
);
228+
}),
229+
);
230+
231+
disposables.push(
232+
vscode.commands.registerCommand('security-notes.breadcrumbs.editCrumbNote', async () => {
233+
const trail = await ensureActiveTrail(store);
234+
if (!trail) {
235+
return;
236+
}
237+
const crumb = await promptForCrumb(trail, 'Select the crumb to edit');
238+
if (!crumb) {
239+
return;
240+
}
241+
const note = await vscode.window.showInputBox({
242+
prompt: 'Update the crumb note',
243+
value: crumb.note,
244+
ignoreFocusOut: true,
245+
});
246+
store.updateCrumbNote(trail.id, crumb.id, note?.trim() ? note.trim() : undefined);
247+
vscode.window.showInformationMessage('[Breadcrumbs] Updated crumb note.');
248+
}),
249+
);
250+
251+
disposables.push(
252+
vscode.commands.registerCommand('security-notes.breadcrumbs.showTrailDiagram', async () => {
253+
const trail = await ensureActiveTrail(store);
254+
if (!trail) {
255+
return;
256+
}
257+
if (options.onShowTrailDiagram) {
258+
await options.onShowTrailDiagram(trail);
259+
} else {
260+
vscode.window.showInformationMessage(
261+
'[Breadcrumbs] Diagram view is not available yet in this session.',
262+
);
263+
}
264+
}),
265+
);
266+
267+
disposables.forEach((disposable) => context.subscriptions.push(disposable));
268+
};

src/breadcrumbs/format.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
'use strict';
2+
3+
import * as vscode from 'vscode';
4+
5+
export const formatRangeLabel = (range: vscode.Range) => {
6+
const startLine = range.start.line + 1;
7+
const endLine = range.end.line + 1;
8+
if (startLine === endLine) {
9+
return `L${startLine}`;
10+
}
11+
return `L${startLine}-L${endLine}`;
12+
};
13+
14+
export const snippetPreview = (snippet: string, maxLength = 80) => {
15+
const trimmed = snippet.trim();
16+
if (!trimmed.length) {
17+
return '(empty selection)';
18+
}
19+
const preview = trimmed.split('\n')[0].trim();
20+
return preview.length > maxLength ? `${preview.slice(0, maxLength - 3)}...` : preview;
21+
};

0 commit comments

Comments
 (0)