diff --git a/README.md b/README.md index 05719aa..52526a4 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,9 @@ --- -Security Notes allows the creation of notes within source files, which can be replied to, reacted to using emojis, and assigned statuses such as "TODO", "Vulnerable" and "Not Vulnerable". +Security Notes allows the creation of notes within source files, which can be replied to, reacted to using emojis, and assigned statuses such as "TODO", "Vulnerable" and "Not Vulnerable". Also, it allows importing the output from SAST tools (such as semgrep, bandit and brakeman), into notes, making the processing of the findings much easier. -Also, it allows importing the output from SAST tools (such as semgrep, bandit and brakeman), into notes, making the processing of the findings much easier. +Use the **Breadcrumbs** feature to track complex implementations accross different source files. This way you will be able to visualize how a feature works, and export it so you can share your analysis with others. Finally, collaborate with others by using a centralized database for notes that will be automatically synced in **real-time**! Create a note locally, and it will be automatically pushed to whoever is working with you on the project. @@ -42,25 +42,15 @@ Security Notes allows the creation of notes within source files, which can be re 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. -## Collaboration Mode - -Because chasing bugs with friends is more fun :) - -Security Notes allows sharing of notes in real-time with other users. To do so, it leverages the RethinkDB real-time database. - -First, make sure you have a RethinkDB database instance up and running. Then set your author name, and the database connection information in the extension's settings, and you are ready to go! Please see the section below for more details). - -Collaboration mode in action: +## Breadcrumb Trails -![Demo for collaboration](images/demo-collaboration.gif) +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 to Trail` to drop "crumbs" along the way. Each crumb stores the code selection, file/line information, and an optional note. -### Setting up the RethinkDB database +![Breadcrumbs view showing a trail](images/breadcrumbs-1.png) -We recommend following instructions in RethinkDB [installation guide](https://rethinkdb.com/docs/install/). Additionally, following [hardening steps](https://rethinkdb.com/docs/security/#wrapper), such as setting a password for the `admin` user and setting up SSL/TLS, are strongly encouraged. +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. -Naturally, you will want to collaborate with remote peers. To do so in a secure way, we recommend setting up access to RethinkDB via SSH or through a VPN like [Tailscale](http://tailscale.com). This way, you avoid having to expose the instance to any network, and also ensuring information in transit is encrypted. - -> **Important Notices:** When collaborating with others, ensure that all VSCode instances open the project from the same relative location. For example, if the source code repository you're reviewing has a directory structure like `source_code/app/src`, all peers should open VScode at the same level. Security Notes will store note location using relative paths, so they should be consistent. Also, after enabling the collaboration setting, VSCode would need to be restarted/reloaded for the change to have effect. +![Markdown export of a Breadcrumb](images/breadcrumbs-2.png) ## Importing SAST results @@ -91,6 +81,26 @@ gosec -fmt=json -out=gosec-results.json ./... semgrep scan --json -o semgrep-results.json --config=auto . ``` +## Collaboration Mode + +Because chasing bugs with friends is more fun :) + +Security Notes allows sharing of notes in real-time with other users. To do so, it leverages the RethinkDB real-time database. + +First, make sure you have a RethinkDB database instance up and running. Then set your author name, and the database connection information in the extension's settings, and you are ready to go! Please see the section below for more details). + +Collaboration mode in action: + +![Demo for collaboration](images/demo-collaboration.gif) + +### Setting up the RethinkDB database + +We recommend following instructions in RethinkDB [installation guide](https://rethinkdb.com/docs/install/). Additionally, following [hardening steps](https://rethinkdb.com/docs/security/#wrapper), such as setting a password for the `admin` user and setting up SSL/TLS, are strongly encouraged. + +Naturally, you will want to collaborate with remote peers. To do so in a secure way, we recommend setting up access to RethinkDB via SSH or through a VPN like [Tailscale](http://tailscale.com). This way, you avoid having to expose the instance to any network, and also ensuring information in transit is encrypted. + +> **Important Notices:** When collaborating with others, ensure that all VSCode instances open the project from the same relative location. For example, if the source code repository you're reviewing has a directory structure like `source_code/app/src`, all peers should open VScode at the same level. Security Notes will store note location using relative paths, so they should be consistent. Also, after enabling the collaboration setting, VSCode would need to be restarted/reloaded for the change to have effect. + ## Exporting notes in popular formats Currently we only support exporting notes to Markdown, but other formats such as HTML are coming soon. diff --git a/images/breadcrumbs-1.png b/images/breadcrumbs-1.png new file mode 100644 index 0000000..e9835a6 Binary files /dev/null and b/images/breadcrumbs-1.png differ diff --git a/images/breadcrumbs-2.png b/images/breadcrumbs-2.png new file mode 100644 index 0000000..ec1b01b Binary files /dev/null and b/images/breadcrumbs-2.png differ diff --git a/package.json b/package.json index b57b922..981d523 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,34 @@ { "command": "security-notes.saveNotesToFile", "title": "Security-Notes: Save Notes to Local Database" + }, + { + "command": "security-notes.breadcrumbs.createTrail", + "title": "Security Notes: Create Breadcrumb Trail" + }, + { + "command": "security-notes.breadcrumbs.selectTrail", + "title": "Security Notes: Select Active Breadcrumb Trail" + }, + { + "command": "security-notes.breadcrumbs.addCrumb", + "title": "Security Notes: Add Breadcrumb to Trail" + }, + { + "command": "security-notes.breadcrumbs.removeCrumb", + "title": "Security Notes: Remove Breadcrumb Crumb" + }, + { + "command": "security-notes.breadcrumbs.editCrumbNote", + "title": "Security Notes: Edit Breadcrumb Note" + }, + { + "command": "security-notes.breadcrumbs.showTrailDiagram", + "title": "Security Notes: Show Breadcrumb Diagram" + }, + { + "command": "security-notes.breadcrumbs.exportTrail", + "title": "Security Notes: Export Breadcrumb Trail" } ], "configuration": { @@ -95,6 +123,11 @@ "description": "Local database file path.", "default": ".security-notes.json" }, + "security-notes.breadcrumbs.localDatabase": { + "type": "string", + "description": "Local database file path for breadcrumb trails.", + "default": ".security-notes-breadcrumbs.json" + }, "security-notes.collab.enabled": { "type": "boolean", "description": "Enable collaboration via RethinkDB.", @@ -225,6 +258,13 @@ "group": "inline@2", "when": "commentController == security-notes" } + ], + "editor/context": [ + { + "command": "security-notes.breadcrumbs.addCrumb", + "group": "navigation@10", + "when": "editorHasSelection" + } ] }, "views": { @@ -238,6 +278,11 @@ "type": "webview", "name": "Export Notes", "id": "export-notes-view" + }, + { + "type": "webview", + "name": "Breadcrumbs", + "id": "breadcrumbs-view" } ] }, @@ -275,4 +320,4 @@ "rethinkdb": "^2.4.2", "uuid": "^9.0.0" } -} +} \ No newline at end of file diff --git a/src/breadcrumbs/commands.ts b/src/breadcrumbs/commands.ts new file mode 100644 index 0000000..9ddc6ff --- /dev/null +++ b/src/breadcrumbs/commands.ts @@ -0,0 +1,296 @@ +'use strict'; + +import * as vscode from 'vscode'; +import { BreadcrumbStore } from './store'; +import { Crumb, Trail } from '../models/breadcrumb'; +import { fullPathToRelative } from '../utils'; +import { formatRangeLabel, snippetPreview } from './format'; +import { exportTrailToMarkdown } from './export'; + +let lastActiveEditor: vscode.TextEditor | undefined = vscode.window.activeTextEditor ?? undefined; + +interface TrailQuickPickItem extends vscode.QuickPickItem { + trail: Trail; +} + +interface CrumbQuickPickItem extends vscode.QuickPickItem { + crumb: Crumb; +} + +const mapTrailToQuickPickItem = (trail: Trail, activeTrailId?: string): TrailQuickPickItem => ({ + label: trail.name, + description: trail.description, + detail: `${trail.crumbs.length} crumb${trail.crumbs.length === 1 ? '' : 's'} · Last updated ${new Date( + trail.updatedAt, + ).toLocaleString()}`, + trail, + picked: trail.id === activeTrailId, +}); + +const mapCrumbToQuickPickItem = (crumb: Crumb, index: number): CrumbQuickPickItem => ({ + label: `${index + 1}. ${fullPathToRelative(crumb.uri.fsPath)}:${formatRangeLabel(crumb.range)}`, + description: crumb.note, + detail: snippetPreview(crumb.snippet), + crumb, +}); + +const ensureActiveTrail = async ( + store: BreadcrumbStore, + options: { promptUser?: boolean } = { promptUser: true }, +): Promise => { + const activeTrail = store.getActiveTrail(); + if (activeTrail) { + return activeTrail; + } + + const trails = store.getTrails(); + if (!trails.length) { + if (options.promptUser) { + vscode.window.showInformationMessage( + '[Breadcrumbs] No breadcrumb trails yet. Create one before adding crumbs.', + ); + } + return undefined; + } + + if (!options.promptUser) { + return undefined; + } + + const picked = await vscode.window.showQuickPick( + trails.map((trail) => mapTrailToQuickPickItem(trail, store.getState().activeTrailId)), + { + placeHolder: 'Select a breadcrumb trail to work with', + }, + ); + + if (!picked) { + return undefined; + } + + store.setActiveTrail(picked.trail.id); + return store.getTrail(picked.trail.id); +}; + +const promptForTrail = async (store: BreadcrumbStore, placeHolder: string) => { + const trails = store.getTrails(); + if (!trails.length) { + vscode.window.showInformationMessage('[Breadcrumbs] No trails available. Create one first.'); + return undefined; + } + const picked = await vscode.window.showQuickPick( + trails.map((trail) => mapTrailToQuickPickItem(trail, store.getState().activeTrailId)), + { placeHolder }, + ); + return picked?.trail; +}; + +const promptForCrumb = async (trail: Trail, placeHolder: string): Promise => { + if (!trail.crumbs.length) { + vscode.window.showInformationMessage('[Breadcrumbs] The selected trail has no crumbs yet.'); + return undefined; + } + const picked = await vscode.window.showQuickPick( + trail.crumbs.map((crumb, index) => mapCrumbToQuickPickItem(crumb, index)), + { placeHolder }, + ); + return picked?.crumb; +}; + +export const revealCrumb = async (crumb: Crumb) => { + const document = await vscode.workspace.openTextDocument(crumb.uri); + const editor = await vscode.window.showTextDocument(document, { preview: false }); + const selection = new vscode.Selection(crumb.range.start, crumb.range.end); + editor.selection = selection; + editor.revealRange(crumb.range, vscode.TextEditorRevealType.InCenter); +}; + +interface RegisterBreadcrumbCommandsOptions { + onShowTrailDiagram?: (trail: Trail) => Promise | void; + onExportTrail?: (trail: Trail) => Promise | void; +} + +export const registerBreadcrumbCommands = ( + context: vscode.ExtensionContext, + store: BreadcrumbStore, + options: RegisterBreadcrumbCommandsOptions = {}, +) => { + const disposables: vscode.Disposable[] = []; + + disposables.push( + vscode.window.onDidChangeActiveTextEditor((editor) => { + if (editor) { + lastActiveEditor = editor; + } + }), + ); + + disposables.push( + vscode.commands.registerCommand('security-notes.breadcrumbs.createTrail', async () => { + const name = await vscode.window.showInputBox({ + prompt: 'Name for the new breadcrumb trail', + placeHolder: 'e.g. User login flow', + ignoreFocusOut: true, + validateInput: (value) => (!value?.trim().length ? 'Trail name is required.' : undefined), + }); + if (!name) { + return; + } + const description = await vscode.window.showInputBox({ + prompt: 'Optional description', + placeHolder: 'What does this trail capture?', + ignoreFocusOut: true, + }); + const trail = store.createTrail(name.trim(), { + description: description?.trim() ? description.trim() : undefined, + setActive: true, + }); + vscode.window.showInformationMessage( + `[Breadcrumbs] Created trail "${trail.name}" and set it as active.`, + ); + }), + ); + + disposables.push( + vscode.commands.registerCommand('security-notes.breadcrumbs.selectTrail', async () => { + const trail = await promptForTrail(store, 'Select the breadcrumb trail to activate'); + if (!trail) { + return; + } + store.setActiveTrail(trail.id); + vscode.window.showInformationMessage( + `[Breadcrumbs] Active trail set to "${trail.name}".`, + ); + }), + ); + + disposables.push( + vscode.commands.registerCommand('security-notes.breadcrumbs.addCrumb', async () => { + const editor = vscode.window.activeTextEditor ?? lastActiveEditor; + if (!editor) { + vscode.window.showInformationMessage( + '[Breadcrumbs] Open a file and select the code you want to add as a crumb.', + ); + return; + } + + await vscode.commands.executeCommand('workbench.action.focusActiveEditorGroup'); + + const trail = await ensureActiveTrail(store); + if (!trail) { + return; + } + + const selection = editor.selection; + const document = editor.document; + const range = selection.isEmpty + ? document.lineAt(selection.start.line).range + : new vscode.Range(selection.start, selection.end); + const snippet = selection.isEmpty + ? document.lineAt(selection.start.line).text + : document.getText(selection); + + if (!snippet.trim().length) { + vscode.window.showInformationMessage( + '[Breadcrumbs] The selected snippet is empty. Expand the selection and try again.', + ); + return; + } + + const note = await vscode.window.showInputBox({ + prompt: 'Optional note for this crumb', + placeHolder: 'Why is this snippet important?', + ignoreFocusOut: true, + }); + + const crumb = store.addCrumb(trail.id, document.uri, range, snippet, { + note: note?.trim() ? note.trim() : undefined, + }); + + if (!crumb) { + vscode.window.showErrorMessage('[Breadcrumbs] Failed to add crumb to the trail.'); + return; + } + + vscode.window.showInformationMessage( + `[Breadcrumbs] Added crumb to "${trail.name}" at ${fullPathToRelative( + crumb.uri.fsPath, + )}:${formatRangeLabel(crumb.range)}.`, + 'View', + ).then((selectionAction) => { + if (selectionAction === 'View') { + revealCrumb(crumb); + } + }); + }), + ); + + disposables.push( + vscode.commands.registerCommand('security-notes.breadcrumbs.removeCrumb', async () => { + const trail = await ensureActiveTrail(store); + if (!trail) { + return; + } + const crumb = await promptForCrumb(trail, 'Select the crumb to remove'); + if (!crumb) { + return; + } + store.removeCrumb(trail.id, crumb.id); + vscode.window.showInformationMessage( + `[Breadcrumbs] Removed crumb from "${trail.name}".`, + ); + }), + ); + + disposables.push( + vscode.commands.registerCommand('security-notes.breadcrumbs.editCrumbNote', async () => { + const trail = await ensureActiveTrail(store); + if (!trail) { + return; + } + const crumb = await promptForCrumb(trail, 'Select the crumb to edit'); + if (!crumb) { + return; + } + const note = await vscode.window.showInputBox({ + prompt: 'Update the crumb note', + value: crumb.note, + ignoreFocusOut: true, + }); + store.updateCrumbNote(trail.id, crumb.id, note?.trim() ? note.trim() : undefined); + vscode.window.showInformationMessage('[Breadcrumbs] Updated crumb note.'); + }), + ); + + disposables.push( + vscode.commands.registerCommand('security-notes.breadcrumbs.showTrailDiagram', async () => { + const trail = await ensureActiveTrail(store); + if (!trail) { + return; + } + if (options.onShowTrailDiagram) { + await options.onShowTrailDiagram(trail); + } else { + vscode.window.showInformationMessage( + '[Breadcrumbs] Diagram view is not available yet in this session.', + ); + } + }), + ); + + disposables.push( + vscode.commands.registerCommand('security-notes.breadcrumbs.exportTrail', async () => { + const trail = await ensureActiveTrail(store); + if (!trail) { + return; + } + if (options.onExportTrail) { + await options.onExportTrail(trail); + } else { + await exportTrailToMarkdown(trail); + } + }), + ); + + disposables.forEach((disposable) => context.subscriptions.push(disposable)); +}; diff --git a/src/breadcrumbs/export.ts b/src/breadcrumbs/export.ts new file mode 100644 index 0000000..210e5e8 --- /dev/null +++ b/src/breadcrumbs/export.ts @@ -0,0 +1,109 @@ +'use strict'; + +import * as vscode from 'vscode'; +import { Trail } from '../models/breadcrumb'; +import { formatRangeLabel } from './format'; +import { fullPathToRelative } from '../utils'; + +const escapeCodeBlock = (value: string) => value.replace(/```/g, '\`\`\`'); + +const headline = (level: number, text: string) => `${'#'.repeat(level)} ${text}`; + +const formatDate = (value: string | undefined) => + value ? new Date(value).toLocaleString() : undefined; + +const buildSummary = (trail: Trail) => { + const files = new Set(trail.crumbs.map((crumb) => fullPathToRelative(crumb.uri.fsPath))); + const first = formatDate(trail.crumbs[0]?.createdAt); + const last = formatDate(trail.crumbs[trail.crumbs.length - 1]?.createdAt); + + const lines: string[] = []; + lines.push(headline(2, 'Summary')); + lines.push(''); + lines.push(`- **Total crumbs:** ${trail.crumbs.length}`); + lines.push(`- **Source Files Involved:** ${files.size}`); + lines.push(`- **Generated:** ${formatDate(new Date().toISOString())}`); + lines.push(''); + return lines.join('\n'); +}; + +const buildCrumbSection = (trail: Trail) => { + const lines: string[] = []; + lines.push(headline(2, 'Trail')); + lines.push(''); + + trail.crumbs.forEach((crumb, index) => { + const filePath = fullPathToRelative(crumb.uri.fsPath); + const rangeLabel = formatRangeLabel(crumb.range); + const createdAt = formatDate(crumb.createdAt) ?? 'n/a'; + lines.push(headline(3, `${index + 1}. ${filePath}:${rangeLabel}`)); + lines.push(''); + lines.push(`- **Captured:** ${createdAt}`); + if (crumb.note) { + lines.push(`- **Note:** ${crumb.note}`); + } + lines.push(''); + lines.push('```'); + lines.push(escapeCodeBlock(crumb.snippet)); + lines.push('```'); + lines.push(''); + }); + + return lines.join('\n'); +}; + +const generateTrailMarkdown = (trail: Trail) => { + const lines: string[] = []; + lines.push(headline(1, `Breadcrumb Trail – ${trail.name}`)); + lines.push(''); + if (trail.description) { + lines.push(trail.description); + lines.push(''); + } + lines.push(buildSummary(trail)); + lines.push(buildCrumbSection(trail)); + return lines.join('\n'); +}; + +export const exportTrailToMarkdown = async (trail: Trail, uri?: vscode.Uri) => { + if (!trail.crumbs.length) { + vscode.window.showInformationMessage('[Breadcrumbs] Cannot export an empty trail.'); + return undefined; + } + + const markdown = generateTrailMarkdown(trail); + const buffer = Buffer.from(markdown, 'utf8'); + + if (!uri) { + const fileNameSafe = trail.name.replace(/[^a-z0-9\-_]+/gi, '-').replace(/-+/g, '-'); + const defaultUri = vscode.workspace.workspaceFolders?.length + ? vscode.Uri.joinPath( + vscode.workspace.workspaceFolders[0].uri, + `${fileNameSafe || 'breadcrumb-trail'}-Breadcrumb.md`, + ) + : undefined; + + uri = await vscode.window.showSaveDialog({ + filters: { Markdown: ['md', 'markdown'] }, + defaultUri, + saveLabel: 'Export Breadcrumb Trail', + }); + if (!uri) { + return undefined; + } + } + + await vscode.workspace.fs.writeFile(uri, buffer); + + const selection = await vscode.window.showInformationMessage( + `Breadcrumb trail exported to ${uri.fsPath}.`, + 'Open export', + ); + + if (selection === 'Open export') { + const document = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(document, { preview: false }); + } + + return uri; +}; diff --git a/src/breadcrumbs/format.ts b/src/breadcrumbs/format.ts new file mode 100644 index 0000000..860206c --- /dev/null +++ b/src/breadcrumbs/format.ts @@ -0,0 +1,21 @@ +'use strict'; + +import * as vscode from 'vscode'; + +export const formatRangeLabel = (range: vscode.Range) => { + const startLine = range.start.line + 1; + const endLine = range.end.line + 1; + if (startLine === endLine) { + return `${startLine}`; + } + return `${startLine}-${endLine}`; +}; + +export const snippetPreview = (snippet: string, maxLength = 80) => { + const trimmed = snippet.trim(); + if (!trimmed.length) { + return '(empty selection)'; + } + const preview = trimmed.split('\n')[0].trim(); + return preview.length > maxLength ? `${preview.slice(0, maxLength - 3)}...` : preview; +}; diff --git a/src/breadcrumbs/store.ts b/src/breadcrumbs/store.ts new file mode 100644 index 0000000..c024776 --- /dev/null +++ b/src/breadcrumbs/store.ts @@ -0,0 +1,178 @@ +'use strict'; + +import * as vscode from 'vscode'; +import { v4 as uuidv4 } from 'uuid'; +import { BreadcrumbState, Crumb, Trail } from '../models/breadcrumb'; + +export interface CreateTrailOptions { + description?: string; + setActive?: boolean; +} + +export interface CreateCrumbOptions { + note?: string; +} + +const cloneRange = (range: vscode.Range) => + new vscode.Range(range.start.line, range.start.character, range.end.line, range.end.character); + +const cloneUri = (uri: vscode.Uri) => vscode.Uri.parse(uri.toString()); + +const cloneCrumb = (crumb: Crumb): Crumb => ({ + ...crumb, + uri: cloneUri(crumb.uri), + range: cloneRange(crumb.range), +}); + +const cloneTrail = (trail: Trail): Trail => ({ + ...trail, + crumbs: trail.crumbs.map((crumb) => cloneCrumb(crumb)), +}); + +export class BreadcrumbStore { + private trails = new Map(); + + private activeTrailId: string | undefined; + + private readonly _onDidChange = new vscode.EventEmitter(); + + public readonly onDidChange: vscode.Event = this._onDidChange.event; + + public getState(): BreadcrumbState { + return { + activeTrailId: this.activeTrailId, + trails: [...this.trails.values()].map((trail) => cloneTrail(trail)), + }; + } + + public replaceState(state: BreadcrumbState) { + this.trails.clear(); + state.trails.forEach((trail) => { + const cloned = cloneTrail(trail); + this.trails.set(cloned.id, cloned); + }); + this.activeTrailId = state.activeTrailId; + this._onDidChange.fire(); + } + + public getTrails(): Trail[] { + return [...this.trails.values()] + .map((trail) => cloneTrail(trail)) + .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); + } + + public getTrail(trailId: string): Trail | undefined { + const trail = this.trails.get(trailId); + return trail ? cloneTrail(trail) : undefined; + } + + public getActiveTrail(): Trail | undefined { + if (!this.activeTrailId) { + return undefined; + } + return this.getTrail(this.activeTrailId); + } + + public setActiveTrail(trailId: string | undefined) { + this.activeTrailId = trailId; + this._onDidChange.fire(); + } + + public createTrail(name: string, options: CreateTrailOptions = {}): Trail { + const id = uuidv4(); + const timestamp = new Date().toISOString(); + const trail: Trail = { + id, + name, + description: options.description, + createdAt: timestamp, + updatedAt: timestamp, + crumbs: [], + }; + this.trails.set(id, trail); + if (options.setActive ?? true) { + this.activeTrailId = id; + } + this._onDidChange.fire(); + return cloneTrail(trail); + } + + public renameTrail(trailId: string, name: string, description?: string) { + const trail = this.trails.get(trailId); + if (!trail) { + return; + } + trail.name = name; + trail.description = description; + trail.updatedAt = new Date().toISOString(); + this._onDidChange.fire(); + } + + public deleteTrail(trailId: string) { + if (!this.trails.has(trailId)) { + return; + } + this.trails.delete(trailId); + if (this.activeTrailId === trailId) { + this.activeTrailId = this.trails.size ? [...this.trails.keys()][0] : undefined; + } + this._onDidChange.fire(); + } + + public addCrumb( + trailId: string, + uri: vscode.Uri, + range: vscode.Range, + snippet: string, + options: CreateCrumbOptions = {}, + ): Crumb | undefined { + const trail = this.trails.get(trailId); + if (!trail) { + return undefined; + } + const crumb: Crumb = { + id: uuidv4(), + trailId, + uri, + range, + snippet, + note: options.note, + createdAt: new Date().toISOString(), + }; + trail.crumbs = [...trail.crumbs, crumb]; + trail.updatedAt = new Date().toISOString(); + this._onDidChange.fire(); + return cloneCrumb(crumb); + } + + public updateCrumbNote(trailId: string, crumbId: string, note: string | undefined) { + const trail = this.trails.get(trailId); + if (!trail) { + return; + } + const index = trail.crumbs.findIndex((crumb) => crumb.id === crumbId); + if (index === -1) { + return; + } + trail.crumbs[index] = { + ...trail.crumbs[index], + note, + }; + trail.updatedAt = new Date().toISOString(); + this._onDidChange.fire(); + } + + public removeCrumb(trailId: string, crumbId: string) { + const trail = this.trails.get(trailId); + if (!trail) { + return; + } + const next = trail.crumbs.filter((crumb) => crumb.id !== crumbId); + if (next.length === trail.crumbs.length) { + return; + } + trail.crumbs = next; + trail.updatedAt = new Date().toISOString(); + this._onDidChange.fire(); + } +} diff --git a/src/extension.ts b/src/extension.ts index c9dcbc5..b5f9d9a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,33 +1,66 @@ 'use strict'; import * as vscode from 'vscode'; -import { NoteStatus } from './models/noteStatus'; -import { NoteComment } from './models/noteComment'; +import { NoteStatus } from './models/noteStatus'; +import { NoteComment } from './models/noteComment'; import { Resource } from './reactions/resource'; import { ImportToolResultsWebview } from './webviews/import-tool-results/importToolResultsWebview'; -import { ExportNotesWebview } from './webviews/export-notes/exportNotesWebview'; -import { commentController } from './controllers/comments'; -import { reactionHandler } from './handlers/reaction'; -import { saveNotesToFileHandler } from './handlers/saveNotesToFile'; -import { +import { ExportNotesWebview } from './webviews/export-notes/exportNotesWebview'; +import { commentController } from './controllers/comments'; +import { reactionHandler } from './handlers/reaction'; +import { saveNotesToFileHandler } from './handlers/saveNotesToFile'; +import { getSetting, - saveNoteComment, - setNoteStatus, - syncNoteMapWithRemote, -} from './helpers'; -import { RemoteDb } from './persistence/remote-db'; -import { loadNotesFromFile, saveNotesToFile } from './persistence/local-db'; - -const noteMap = new Map(); -let remoteDb: RemoteDb | undefined; - -export function activate(context: vscode.ExtensionContext) { - Resource.initialize(context); - if (getSetting('collab.enabled')) { - remoteDb = new RemoteDb( - getSetting('collab.host'), - getSetting('collab.port'), - getSetting('collab.username'), + saveNoteComment, + setNoteStatus, + syncNoteMapWithRemote, +} from './helpers'; +import { RemoteDb } from './persistence/remote-db'; +import { loadNotesFromFile, saveNotesToFile } from './persistence/local-db'; +import { BreadcrumbStore } from './breadcrumbs/store'; +import { registerBreadcrumbCommands } from './breadcrumbs/commands'; +import { + loadBreadcrumbsFromFile, + saveBreadcrumbsToFile, +} from './persistence/local-db/breadcrumbs'; +import { BreadcrumbsWebview } from './webviews/breadcrumbs/breadcrumbsWebview'; + +const noteMap = new Map(); +let remoteDb: RemoteDb | undefined; +const breadcrumbStore = new BreadcrumbStore(); + +export function activate(context: vscode.ExtensionContext) { + Resource.initialize(context); + const persistedBreadcrumbState = loadBreadcrumbsFromFile(); + breadcrumbStore.replaceState(persistedBreadcrumbState); + const breadcrumbStoreSubscription = breadcrumbStore.onDidChange(() => { + saveBreadcrumbsToFile(breadcrumbStore); + }); + context.subscriptions.push(breadcrumbStoreSubscription); + + const breadcrumbsWebview = new BreadcrumbsWebview( + context.extensionUri, + breadcrumbStore, + ); + context.subscriptions.push( + vscode.window.registerWebviewViewProvider( + BreadcrumbsWebview.viewType, + breadcrumbsWebview, + ), + breadcrumbsWebview, + ); + + registerBreadcrumbCommands(context, breadcrumbStore, { + onShowTrailDiagram: async (trail) => { + breadcrumbStore.setActiveTrail(trail.id); + breadcrumbsWebview.reveal(trail.id); + }, + }); + if (getSetting('collab.enabled')) { + remoteDb = new RemoteDb( + getSetting('collab.host'), + getSetting('collab.port'), + getSetting('collab.username'), getSetting('collab.password'), getSetting('collab.database'), getSetting('collab.projectName'), @@ -277,7 +310,8 @@ export function activate(context: vscode.ExtensionContext) { }, 1500); } -export function deactivate(context: vscode.ExtensionContext) { - // persist comments in file - saveNotesToFile(noteMap); -} +export function deactivate(context: vscode.ExtensionContext) { + // persist comments in file + saveNotesToFile(noteMap); + saveBreadcrumbsToFile(breadcrumbStore); +} diff --git a/src/models/breadcrumb.ts b/src/models/breadcrumb.ts new file mode 100644 index 0000000..fb86411 --- /dev/null +++ b/src/models/breadcrumb.ts @@ -0,0 +1,27 @@ +'use strict'; + +import * as vscode from 'vscode'; + +export interface Crumb { + id: string; + trailId: string; + uri: vscode.Uri; + range: vscode.Range; + snippet: string; + note?: string; + createdAt: string; +} + +export interface Trail { + id: string; + name: string; + description?: string; + createdAt: string; + updatedAt: string; + crumbs: Crumb[]; +} + +export interface BreadcrumbState { + activeTrailId?: string; + trails: Trail[]; +} diff --git a/src/persistence/local-db/breadcrumbs.ts b/src/persistence/local-db/breadcrumbs.ts new file mode 100644 index 0000000..dc36d1e --- /dev/null +++ b/src/persistence/local-db/breadcrumbs.ts @@ -0,0 +1,121 @@ +'use strict'; + +import * as fs from 'fs'; +import * as vscode from 'vscode'; +import { BreadcrumbStore } from '../../breadcrumbs/store'; +import { BreadcrumbState, Crumb, Trail } from '../../models/breadcrumb'; +import { fullPathToRelative, getBreadcrumbsDbFilePath, relativePathToFull } from '../../utils'; + +interface PersistedRange { + startLine: number; + startCharacter: number; + endLine: number; + endCharacter: number; +} + +interface PersistedCrumb { + id: string; + trailId: string; + uri: string; + range: PersistedRange; + snippet: string; + note?: string; + createdAt: string; +} + +interface PersistedTrail { + id: string; + name: string; + description?: string; + createdAt: string; + updatedAt: string; + crumbs: PersistedCrumb[]; +} + +interface PersistedState { + activeTrailId?: string; + trails: PersistedTrail[]; +} + +const persistenceFile = getBreadcrumbsDbFilePath(); + +const serializeRange = (range: vscode.Range): PersistedRange => ({ + startLine: range.start.line, + startCharacter: range.start.character, + endLine: range.end.line, + endCharacter: range.end.character, +}); + +const deserializeRange = (range: PersistedRange): vscode.Range => + new vscode.Range(range.startLine, range.startCharacter, range.endLine, range.endCharacter); + +const serializeCrumb = (crumb: Crumb): PersistedCrumb => ({ + id: crumb.id, + trailId: crumb.trailId, + uri: fullPathToRelative(crumb.uri.fsPath), + range: serializeRange(crumb.range), + snippet: crumb.snippet, + note: crumb.note, + createdAt: crumb.createdAt, +}); + +const deserializeCrumb = (crumb: PersistedCrumb): Crumb => ({ + id: crumb.id, + trailId: crumb.trailId, + uri: vscode.Uri.file(relativePathToFull(crumb.uri)), + range: deserializeRange(crumb.range), + snippet: crumb.snippet, + note: crumb.note, + createdAt: crumb.createdAt, +}); + +const serializeTrail = (trail: Trail): PersistedTrail => ({ + id: trail.id, + name: trail.name, + description: trail.description, + createdAt: trail.createdAt, + updatedAt: trail.updatedAt, + crumbs: trail.crumbs.map((crumb) => serializeCrumb(crumb)), +}); + +const deserializeTrail = (trail: PersistedTrail): Trail => ({ + id: trail.id, + name: trail.name, + description: trail.description, + createdAt: trail.createdAt, + updatedAt: trail.updatedAt, + crumbs: trail.crumbs.map((crumb) => deserializeCrumb(crumb)), +}); + +export const saveBreadcrumbsToFile = (store: BreadcrumbStore) => { + const state = store.getState(); + if (!fs.existsSync(persistenceFile) && !state.trails.length) { + return; + } + const persistedState: PersistedState = { + activeTrailId: state.activeTrailId, + trails: state.trails.map((trail) => serializeTrail(trail)), + }; + fs.writeFileSync(persistenceFile, JSON.stringify(persistedState, null, 2)); +}; + +export const loadBreadcrumbsFromFile = (): BreadcrumbState => { + if (!fs.existsSync(persistenceFile)) { + return { activeTrailId: undefined, trails: [] }; + } + + try { + const jsonFile = fs.readFileSync(persistenceFile).toString(); + const persistedState = JSON.parse(jsonFile) as PersistedState; + return { + activeTrailId: persistedState.activeTrailId, + trails: persistedState.trails.map((trail) => deserializeTrail(trail)), + }; + } catch (error) { + const message = error instanceof Error ? error.message : `${error}`; + vscode.window.showErrorMessage( + `[Breadcrumbs] Failed to load breadcrumbs from file: ${message}`, + ); + return { activeTrailId: undefined, trails: [] }; + } +}; diff --git a/src/utils/index.ts b/src/utils/index.ts index 5dbfae3..5b38851 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -34,6 +34,17 @@ export const getLocalDbFilePath = () => { } }; +export const getBreadcrumbsDbFilePath = () => { + const breadcrumbsDbFilePath = getSetting( + 'breadcrumbs.localDatabase', + '.security-notes-breadcrumbs.json', + ); + if (path.isAbsolute(breadcrumbsDbFilePath)) { + return breadcrumbsDbFilePath; + } + return relativePathToFull(breadcrumbsDbFilePath); +}; + export const relativePathToFull = (aPath: string, basePath?: string) => { if (basePath) { return path.join(basePath, aPath); diff --git a/src/webviews/assets/breadcrumbs.css b/src/webviews/assets/breadcrumbs.css new file mode 100644 index 0000000..5346f42 --- /dev/null +++ b/src/webviews/assets/breadcrumbs.css @@ -0,0 +1,187 @@ +.breadcrumbs-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1rem; +} + +.breadcrumbs-title { + margin: 0; + font-size: 1.2rem; +} + +.breadcrumbs-subtitle { + margin: 0.10rem 0 0; + color: var(--vscode-descriptionForeground); + font-size: 0.85rem; +} + +.breadcrumbs-actions { + display: flex; + gap: 0.5rem; +} + +.breadcrumbs-button { + background: var(--vscode-button-secondaryBackground); + border: 1px solid var(--vscode-button-border, transparent); + color: var(--vscode-button-secondaryForeground); + border-radius: 4px; + padding: 0.3rem 0.8rem; + cursor: pointer; + white-space: nowrap; +} + +.breadcrumbs-button:hover { + background: var(--vscode-button-secondaryHoverBackground); +} + +.breadcrumbs-select-label { + display: block; + margin-bottom: 0.25rem; + font-weight: 600; +} + +.breadcrumbs-select { + width: 100%; + margin-bottom: 1rem; + padding: 0.4rem; + border-radius: 4px; + border: 1px solid var(--vscode-settings-dropdownBorder); + background: var(--vscode-settings-dropdownBackground); + color: var(--vscode-settings-dropdownForeground); +} + +.breadcrumbs-content { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.breadcrumbs-empty { + color: var(--vscode-descriptionForeground); + font-style: italic; +} + +.breadcrumbs-summary h3 { + margin: 0; +} + +.breadcrumbs-summary p { + margin: 0.3rem 0 0; + color: var(--vscode-descriptionForeground); +} + +.crumb-list { + display: flex; + flex-direction: column; + gap: 0.9rem; +} + +.crumb-item { + border: 1px solid var(--vscode-editorWidget-border); + border-radius: 8px; + background: var(--vscode-editorWidget-background); + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.crumb-item[open] { + border-color: var(--vscode-focusBorder); + box-shadow: 0 0 0 1px var(--vscode-focusBorder); +} + +.crumb-item summary { + list-style: none; + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.85rem 1rem; + cursor: pointer; +} + +.crumb-item summary::-webkit-details-marker { + display: none; +} + +.crumb-summary__meta { + display: flex; + align-items: center; + gap: 0.75rem; + flex: 1; + min-width: 0; +} + +.crumb-step { + font-size: 0.75rem; + letter-spacing: 0.06em; + text-transform: uppercase; + background: var(--vscode-editorWidget-background); + border: 1px solid var(--vscode-editorWidget-border); + color: var(--vscode-editor-foreground); + border-radius: 5px; + padding: 0.15rem 0.5rem; + font-weight: 600; +} + +.crumb-title { + font-weight: 600; + color: var(--vscode-editor-foreground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.crumb-preview { + color: var(--vscode-descriptionForeground); + font-size: 0.85rem; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.crumb-chevron { + color: var(--vscode-descriptionForeground); + font-size: 0.85rem; +} + +.crumb-body { + padding: 0 1rem 1rem; + display: flex; + flex-direction: column; + gap: 0.75rem; + border-top: 1px solid var(--vscode-editorWidget-border); +} + +.crumb-meta { + margin: 0.5rem 0 0; + font-size: 0.75rem; + color: var(--vscode-descriptionForeground); +} + +.crumb-note { + margin: 0; + padding-left: 0.75rem; + border-left: 3px solid var(--vscode-textPreformat-foreground); + color: var(--vscode-descriptionForeground); + font-style: italic; +} + +.crumb-snippet { + margin: 0; + background: var(--vscode-editor-background); + border: 1px solid var(--vscode-editorWidget-border); + border-radius: 4px; + padding: 0.6rem; + white-space: pre-wrap; + word-break: break-word; + font-family: var(--vscode-editor-font-family, monospace); + font-size: 0.85rem; + cursor: pointer; + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.crumb-snippet:hover { + border-color: var(--vscode-focusBorder); + box-shadow: 0 0 0 1px var(--vscode-focusBorder); +} \ No newline at end of file diff --git a/src/webviews/assets/breadcrumbs.js b/src/webviews/assets/breadcrumbs.js new file mode 100644 index 0000000..7211636 --- /dev/null +++ b/src/webviews/assets/breadcrumbs.js @@ -0,0 +1,217 @@ +/* eslint-disable no-undef */ + +(function () { + const vscode = acquireVsCodeApi(); + + const trailSelect = document.getElementById('trail-select'); + const content = document.getElementById('breadcrumbs-content'); + const createButton = document.querySelector('[data-action="create"]'); + const addButton = document.querySelector('[data-action="add"]'); + const exportButton = document.querySelector('[data-action="export"]'); + + let currentState; + + window.addEventListener('message', (event) => { + const { type, payload } = event.data; + if (type === 'state') { + currentState = payload; + renderState(payload); + } + }); + + trailSelect.addEventListener('change', (event) => { + if (!currentState) { + return; + } + const selectedTrailId = event.target.value; + if (!selectedTrailId) { + return; + } + if (selectedTrailId === currentState.activeTrailId) { + return; + } + vscode.postMessage({ type: 'setActiveTrail', trailId: selectedTrailId }); + }); + + createButton.addEventListener('click', () => { + vscode.postMessage({ type: 'createTrail' }); + }); + + if (addButton) { + addButton.remove(); + } + + exportButton.addEventListener('click', () => { + vscode.postMessage({ type: 'exportTrail' }); + }); + + function renderState(state) { + populateTrailSelect(state); + + if (!state.trails.length) { + renderEmpty('Create a trail to start visualising your breadcrumbs.'); + return; + } + + if (!state.activeTrail) { + renderEmpty('Select a trail from the dropdown to view its breadcrumbs.'); + return; + } + + renderTrail(state.activeTrail); + } + + function populateTrailSelect(state) { + trailSelect.innerHTML = ''; + + const placeholderOption = document.createElement('option'); + placeholderOption.value = ''; + placeholderOption.textContent = state.trails.length ? 'Select a trail' : 'No trails yet'; + placeholderOption.disabled = true; + placeholderOption.hidden = true; + trailSelect.appendChild(placeholderOption); + + state.trails.forEach((trail) => { + const option = document.createElement('option'); + option.value = trail.id; + option.textContent = `${trail.name} (${trail.crumbCount})`; + if (trail.id === state.activeTrailId) { + option.selected = true; + } + trailSelect.appendChild(option); + }); + + if (state.activeTrailId) { + trailSelect.value = state.activeTrailId; + } else if (state.activeTrail) { + trailSelect.value = state.activeTrail.id; + } else { + trailSelect.value = ''; + } + } + + function renderEmpty(message) { + content.innerHTML = ''; + const empty = document.createElement('p'); + empty.className = 'breadcrumbs-empty'; + empty.textContent = message; + content.appendChild(empty); + } + + function renderTrail(trail) { + content.innerHTML = ''; + + const summary = document.createElement('div'); + summary.className = 'breadcrumbs-summary'; + + const title = document.createElement('h3'); + title.textContent = trail.name; + summary.appendChild(title); + + const meta = document.createElement('p'); + const crumbLabel = trail.crumbs.length === 1 ? 'crumb' : 'crumbs'; + const details = []; + details.push(`${trail.crumbs.length} ${crumbLabel}`); + if (trail.description) { + details.push(trail.description); + } + meta.textContent = details.join(' · '); + summary.appendChild(meta); + + content.appendChild(summary); + + if (!trail.crumbs.length) { + renderEmpty('This trail does not have any crumbs yet. Add one to build your diagram.'); + return; + } + + const list = document.createElement('div'); + list.className = 'crumb-list'; + + trail.crumbs.forEach((crumb, index) => { + list.appendChild(renderCrumb(crumb, index, trail.id)); + }); + + content.appendChild(list); + } + + function renderCrumb(crumb, index, trailId) { + const details = document.createElement('details'); + details.className = 'crumb-item'; + details.dataset.crumbId = crumb.id; + details.dataset.trailId = trailId; + if (index === 0) { + details.open = true; + } + + const summary = document.createElement('summary'); + summary.className = 'crumb-summary'; + + const summaryMeta = document.createElement('div'); + summaryMeta.className = 'crumb-summary__meta'; + + const step = document.createElement('span'); + step.className = 'crumb-step'; + step.textContent = `Step ${index + 1}`; + summaryMeta.appendChild(step); + + const title = document.createElement('span'); + title.className = 'crumb-title'; + title.textContent = `${crumb.filePath}:${crumb.rangeLabel}`; + summaryMeta.appendChild(title); + + summary.appendChild(summaryMeta); + + const preview = document.createElement('span'); + preview.className = 'crumb-preview'; + preview.textContent = crumb.note || crumb.snippetPreview || '(no preview)'; + summary.appendChild(preview); + + const chevron = document.createElement('span'); + chevron.className = 'crumb-chevron'; + chevron.innerHTML = '▾'; + summary.appendChild(chevron); + + details.appendChild(summary); + + const body = document.createElement('div'); + body.className = 'crumb-body'; + + const meta = document.createElement('p'); + meta.className = 'crumb-meta'; + const created = new Date(crumb.createdAt).toLocaleString(); + meta.textContent = `Captured ${created}`; + body.appendChild(meta); + + if (crumb.note) { + const note = document.createElement('p'); + note.className = 'crumb-note'; + note.textContent = crumb.note; + body.appendChild(note); + } + + const snippet = document.createElement('pre'); + snippet.className = 'crumb-snippet'; + snippet.textContent = crumb.snippet; + snippet.addEventListener('click', (event) => { + event.stopPropagation(); + vscode.postMessage({ type: 'openCrumb', trailId, crumbId: crumb.id }); + }); + body.appendChild(snippet); + + details.addEventListener('toggle', () => { + if (details.open) { + chevron.innerHTML = '▾'; + } else { + chevron.innerHTML = '▸'; + } + }); + + chevron.innerHTML = details.open ? '▾' : '▸'; + details.appendChild(body); + + return details; + } + + vscode.postMessage({ type: 'ready' }); +})(); diff --git a/src/webviews/breadcrumbs/breadcrumbsWebview.ts b/src/webviews/breadcrumbs/breadcrumbsWebview.ts new file mode 100644 index 0000000..5a6eb4b --- /dev/null +++ b/src/webviews/breadcrumbs/breadcrumbsWebview.ts @@ -0,0 +1,293 @@ +'use strict'; + +import * as vscode from 'vscode'; +import { BreadcrumbStore } from '../../breadcrumbs/store'; +import { Crumb, Trail } from '../../models/breadcrumb'; +import { formatRangeLabel, snippetPreview } from '../../breadcrumbs/format'; +import { fullPathToRelative } from '../../utils'; +import { revealCrumb } from '../../breadcrumbs/commands'; +import { exportTrailToMarkdown } from '../../breadcrumbs/export'; + +interface WebviewCrumb { + id: string; + index: number; + filePath: string; + rangeLabel: string; + note?: string; + snippetPreview: string; + snippet: string; + createdAt: string; +} + +interface WebviewTrail { + id: string; + name: string; + description?: string; + createdAt: string; + updatedAt: string; + crumbs: WebviewCrumb[]; +} + +interface WebviewStatePayload { + activeTrailId?: string; + trails: { + id: string; + name: string; + crumbCount: number; + }[]; + activeTrail?: WebviewTrail; +} + +type WebviewMessage = + | { type: 'ready' } + | { type: 'openCrumb'; trailId: string; crumbId: string } + | { type: 'setActiveTrail'; trailId: string } + | { type: 'createTrail' } + | { type: 'addCrumb' } + | { type: 'exportTrail' }; + +export class BreadcrumbsWebview implements vscode.WebviewViewProvider, vscode.Disposable { + public static readonly viewType = 'breadcrumbs-view'; + + private view: vscode.WebviewView | undefined; + + private isViewReady = false; + + private pendingTrailId: string | undefined; + + private readonly storeListener: vscode.Disposable; + + constructor( + private readonly extensionUri: vscode.Uri, + private readonly store: BreadcrumbStore, + ) { + this.storeListener = this.store.onDidChange(() => { + this.tryPostState(); + }); + } + + public dispose() { + this.storeListener.dispose(); + } + + public reveal(trailId?: string) { + if (trailId) { + this.pendingTrailId = trailId; + } + vscode.commands.executeCommand('workbench.view.extension.view-container'); + vscode.commands.executeCommand(`${BreadcrumbsWebview.viewType}.focus`); + if (this.view) { + this.view.show?.(true); + this.tryPostState(trailId); + } + } + + public resolveWebviewView( + webviewView: vscode.WebviewView, + _context: vscode.WebviewViewResolveContext, + _token: vscode.CancellationToken, + ) { + this.view = webviewView; + + webviewView.webview.options = { + enableScripts: true, + localResourceRoots: [this.extensionUri], + }; + + webviewView.webview.html = this.getHtmlForWebview(webviewView.webview); + + webviewView.webview.onDidReceiveMessage((message: WebviewMessage) => { + switch (message.type) { + case 'ready': { + this.isViewReady = true; + this.tryPostState(this.pendingTrailId); + this.pendingTrailId = undefined; + break; + } + case 'openCrumb': { + this.handleOpenCrumb(message.trailId, message.crumbId); + break; + } + case 'setActiveTrail': { + this.store.setActiveTrail(message.trailId); + break; + } + case 'createTrail': { + vscode.commands.executeCommand('security-notes.breadcrumbs.createTrail'); + break; + } + /*case 'addCrumb': { + vscode.commands.executeCommand('security-notes.breadcrumbs.addCrumb'); + break; + }*/ + case 'exportTrail': { + this.handleExportTrail(); + break; + } + default: { + break; + } + } + }); + + webviewView.onDidDispose(() => { + this.view = undefined; + this.isViewReady = false; + }); + } + + private tryPostState(trailId?: string) { + if (!this.view || !this.isViewReady) { + if (trailId) { + this.pendingTrailId = trailId; + } + return; + } + + const state = this.store.getState(); + const targetTrailId = trailId ?? state.activeTrailId; + let activeTrail = targetTrailId + ? state.trails.find((trail) => trail.id === targetTrailId) + : state.trails.find((trail) => trail.id === state.activeTrailId); + + if (!activeTrail && state.trails.length) { + activeTrail = state.trails[0]; + } + + const payload: WebviewStatePayload = { + activeTrailId: state.activeTrailId ?? activeTrail?.id, + trails: state.trails.map((trail) => ({ + id: trail.id, + name: trail.name, + crumbCount: trail.crumbs.length, + })), + activeTrail: activeTrail ? this.serializeTrail(activeTrail) : undefined, + }; + + this.view.webview.postMessage({ type: 'state', payload }); + } + + private serializeTrail(trail: Trail): WebviewTrail { + return { + id: trail.id, + name: trail.name, + description: trail.description, + createdAt: trail.createdAt, + updatedAt: trail.updatedAt, + crumbs: trail.crumbs.map((crumb, index) => this.serializeCrumb(trail, crumb, index)), + }; + } + + private serializeCrumb(trail: Trail, crumb: Crumb, index: number): WebviewCrumb { + return { + id: crumb.id, + index, + filePath: fullPathToRelative(crumb.uri.fsPath), + rangeLabel: formatRangeLabel(crumb.range), + note: crumb.note, + snippetPreview: snippetPreview(crumb.snippet), + snippet: crumb.snippet, + createdAt: crumb.createdAt, + }; + } + + private handleOpenCrumb(trailId: string, crumbId: string) { + const trail = this.store.getTrail(trailId); + if (!trail) { + vscode.window.showErrorMessage('[Breadcrumbs] Unable to locate the requested trail.'); + return; + } + const crumb = trail.crumbs.find((candidate) => candidate.id === crumbId); + if (!crumb) { + vscode.window.showErrorMessage('[Breadcrumbs] Unable to locate the requested crumb.'); + return; + } + revealCrumb(crumb); + } + + private async handleExportTrail() { + const activeTrail = this.store.getActiveTrail(); + if (!activeTrail) { + vscode.window.showInformationMessage( + '[Breadcrumbs] Select a trail before exporting.', + ); + return; + } + await exportTrailToMarkdown(activeTrail); + } + + private getHtmlForWebview(webview: vscode.Webview) { + const scriptUri = webview.asWebviewUri( + vscode.Uri.joinPath( + this.extensionUri, + 'src', + 'webviews', + 'assets', + 'breadcrumbs.js', + ), + ); + const styleResetUri = webview.asWebviewUri( + vscode.Uri.joinPath(this.extensionUri, 'src', 'webviews', 'assets', 'reset.css'), + ); + const styleVSCodeUri = webview.asWebviewUri( + vscode.Uri.joinPath(this.extensionUri, 'src', 'webviews', 'assets', 'vscode.css'), + ); + const styleMainUri = webview.asWebviewUri( + vscode.Uri.joinPath(this.extensionUri, 'src', 'webviews', 'assets', 'main.css'), + ); + const styleBreadcrumbsUri = webview.asWebviewUri( + vscode.Uri.joinPath( + this.extensionUri, + 'src', + 'webviews', + 'assets', + 'breadcrumbs.css', + ), + ); + + const nonce = getNonce(); + + return ` + + + + + + + + + + + +
+ + +
+ + + +`; + } +} + +const getNonce = () => { + let text = ''; + const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + for (let i = 0; i < 32; i += 1) { + text += possible.charAt(Math.floor(Math.random() * possible.length)); + } + return text; +};