|
| 1 | +/** |
| 2 | + * Copyright (c) Meta Platforms, Inc. and affiliates. |
| 3 | + * |
| 4 | + * This source code is licensed under the MIT license found in the |
| 5 | + * LICENSE file in the root directory of this source tree. |
| 6 | + * |
| 7 | + * @format |
| 8 | + */ |
| 9 | + |
| 10 | +/** |
| 11 | + * Snappoints — a "snapshot-and-continue" breakpoint variant. |
| 12 | + * |
| 13 | + * A snappoint is a real ``vscode.SourceBreakpoint`` whose ``logMessage`` |
| 14 | + * field carries a fixed marker string (:data:`SNAPPOINT_LOG_MESSAGE_MARKER`). |
| 15 | + * VS Code renders it with its built-in logpoint diamond and lists it in |
| 16 | + * the Breakpoints view automatically; the snappoint module overlays a |
| 17 | + * camera icon in the gutter so users can distinguish snappoints from |
| 18 | + * regular logpoints at a glance. |
| 19 | + * |
| 20 | + * Each host intercepts the marker before ``setBreakpoints`` reaches its |
| 21 | + * adapter and prepares its capture runtime. Hosts preserve logpoint |
| 22 | + * semantics: evaluate, capture, and continue. |
| 23 | + */ |
| 24 | + |
| 25 | +import path from 'path'; |
| 26 | +import * as vscode from 'vscode'; |
| 27 | + |
| 28 | +/** |
| 29 | + * Fixed literal stored in ``SourceBreakpoint.logMessage`` to identify a |
| 30 | + * snappoint. Picked to be visibly distinct in the Breakpoints view |
| 31 | + * label and unlikely to collide with a user's own logpoint text. |
| 32 | + */ |
| 33 | +export const SNAPPOINT_LOG_MESSAGE_MARKER = '__tintype_snappoint__'; |
| 34 | + |
| 35 | +export function isSnappoint(bp: vscode.Breakpoint): bp is vscode.SourceBreakpoint { |
| 36 | + if (!(bp instanceof vscode.SourceBreakpoint)) { |
| 37 | + return false; |
| 38 | + } |
| 39 | + return bp.logMessage === SNAPPOINT_LOG_MESSAGE_MARKER; |
| 40 | +} |
| 41 | + |
| 42 | +export type SnappointManagerOptions = { |
| 43 | + /** |
| 44 | + * Absolute path to the extension root. Used to resolve the |
| 45 | + * ``resources/{light,dark}/snappoint.svg`` gutter icon paths. |
| 46 | + */ |
| 47 | + extensionPath: string; |
| 48 | + /** |
| 49 | + * Languages the toggle command applies to. Snappoints created on |
| 50 | + * other languages still round-trip through the marker — this is only |
| 51 | + * the authoring filter for the right-click menu invocation. |
| 52 | + */ |
| 53 | + supportedLanguages?: ReadonlySet<string>; |
| 54 | + /** Whether this host should paint snappoint gutter decorations. */ |
| 55 | + enableDecorations?: boolean; |
| 56 | +}; |
| 57 | + |
| 58 | +/** |
| 59 | + * Owns snappoint authoring (toggle/add/remove) and the gutter |
| 60 | + * decoration that overlays a camera icon on snappoint lines. |
| 61 | + * |
| 62 | + * Persistence is delegated entirely to VS Code's built-in breakpoint |
| 63 | + * store — snappoints survive reload because they are real |
| 64 | + * ``SourceBreakpoint`` instances. This class is stateless beyond the |
| 65 | + * decoration cache. |
| 66 | + */ |
| 67 | +export class SnappointManager implements vscode.Disposable { |
| 68 | + private readonly decorationType: vscode.TextEditorDecorationType | null; |
| 69 | + private readonly supportedLanguages: ReadonlySet<string>; |
| 70 | + private readonly disposables: vscode.Disposable[] = []; |
| 71 | + |
| 72 | + constructor(options: SnappointManagerOptions) { |
| 73 | + this.supportedLanguages = options.supportedLanguages ?? new Set(['python']); |
| 74 | + this.decorationType = |
| 75 | + options.enableDecorations === false |
| 76 | + ? null |
| 77 | + : vscode.window.createTextEditorDecorationType({ |
| 78 | + gutterIconPath: vscode.Uri.file( |
| 79 | + path.join(options.extensionPath, 'resources', 'light', 'snappoint.svg'), |
| 80 | + ), |
| 81 | + gutterIconSize: 'contain', |
| 82 | + dark: { |
| 83 | + gutterIconPath: vscode.Uri.file( |
| 84 | + path.join(options.extensionPath, 'resources', 'dark', 'snappoint.svg'), |
| 85 | + ), |
| 86 | + }, |
| 87 | + }); |
| 88 | + |
| 89 | + if (this.decorationType != null) { |
| 90 | + this.disposables.push( |
| 91 | + this.decorationType, |
| 92 | + vscode.debug.onDidChangeBreakpoints(() => this.refreshDecorations()), |
| 93 | + vscode.window.onDidChangeVisibleTextEditors(() => this.refreshDecorations()), |
| 94 | + ); |
| 95 | + |
| 96 | + // Initial paint for any editors open at extension activation time. |
| 97 | + this.refreshDecorations(); |
| 98 | + } |
| 99 | + } |
| 100 | + |
| 101 | + public dispose(): void { |
| 102 | + for (const d of this.disposables) { |
| 103 | + d.dispose(); |
| 104 | + } |
| 105 | + } |
| 106 | + |
| 107 | + /** |
| 108 | + * Find the existing snappoint at ``(uri, line)`` if any. ``line`` is |
| 109 | + * 0-based — same as ``vscode.Position`` / ``vscode.SourceBreakpoint``. |
| 110 | + */ |
| 111 | + public findAt(uri: vscode.Uri, line: number): vscode.SourceBreakpoint | undefined { |
| 112 | + for (const bp of vscode.debug.breakpoints) { |
| 113 | + if (!isSnappoint(bp)) { |
| 114 | + continue; |
| 115 | + } |
| 116 | + if (bp.location.uri.toString() === uri.toString() && bp.location.range.start.line === line) { |
| 117 | + return bp; |
| 118 | + } |
| 119 | + } |
| 120 | + return undefined; |
| 121 | + } |
| 122 | + |
| 123 | + public hasSnappointAt(uri: vscode.Uri, line: number): boolean { |
| 124 | + return this.findAt(uri, line) != null; |
| 125 | + } |
| 126 | + |
| 127 | + /** |
| 128 | + * Add a snappoint at ``(uri, line)``. No-op if one already exists. |
| 129 | + * Returns the new (or pre-existing) ``SourceBreakpoint``. |
| 130 | + */ |
| 131 | + public add(uri: vscode.Uri, line: number): vscode.SourceBreakpoint { |
| 132 | + const existing = this.findAt(uri, line); |
| 133 | + if (existing != null) { |
| 134 | + return existing; |
| 135 | + } |
| 136 | + const bp = new vscode.SourceBreakpoint( |
| 137 | + new vscode.Location(uri, new vscode.Position(line, 0)), |
| 138 | + /* enabled */ true, |
| 139 | + /* condition */ undefined, |
| 140 | + /* hitCondition */ undefined, |
| 141 | + SNAPPOINT_LOG_MESSAGE_MARKER, |
| 142 | + ); |
| 143 | + vscode.debug.addBreakpoints([bp]); |
| 144 | + return bp; |
| 145 | + } |
| 146 | + |
| 147 | + /** |
| 148 | + * Remove the snappoint at ``(uri, line)``. No-op if none exists. |
| 149 | + * Returns ``true`` when a snappoint was actually removed. |
| 150 | + */ |
| 151 | + public remove(uri: vscode.Uri, line: number): boolean { |
| 152 | + const existing = this.findAt(uri, line); |
| 153 | + if (existing == null) { |
| 154 | + return false; |
| 155 | + } |
| 156 | + vscode.debug.removeBreakpoints([existing]); |
| 157 | + return true; |
| 158 | + } |
| 159 | + |
| 160 | + /** |
| 161 | + * Toggle the snappoint at ``(uri, line)``. Returns ``'added'`` or |
| 162 | + * ``'removed'`` for the resulting state so callers can log telemetry. |
| 163 | + */ |
| 164 | + public toggle(uri: vscode.Uri, line: number): 'added' | 'removed' { |
| 165 | + if (this.remove(uri, line)) { |
| 166 | + return 'removed'; |
| 167 | + } |
| 168 | + this.add(uri, line); |
| 169 | + return 'added'; |
| 170 | + } |
| 171 | + |
| 172 | + /** |
| 173 | + * Languages the right-click "Add Snappoint" entry should apply to. |
| 174 | + * Used by callers that want to gate UI on document language. |
| 175 | + */ |
| 176 | + public isSupportedLanguage(languageId: string): boolean { |
| 177 | + return this.supportedLanguages.has(languageId); |
| 178 | + } |
| 179 | + |
| 180 | + /** |
| 181 | + * All snappoints currently registered (across all files). Used by |
| 182 | + * the DAP processor to decide whether it needs to wait on |
| 183 | + * ``ensureSnapshotting`` before forwarding a ``setBreakpoints``. |
| 184 | + */ |
| 185 | + public getActiveSnappoints(): vscode.SourceBreakpoint[] { |
| 186 | + const out: vscode.SourceBreakpoint[] = []; |
| 187 | + for (const bp of vscode.debug.breakpoints) { |
| 188 | + if (isSnappoint(bp)) { |
| 189 | + out.push(bp); |
| 190 | + } |
| 191 | + } |
| 192 | + return out; |
| 193 | + } |
| 194 | + |
| 195 | + private refreshDecorations(): void { |
| 196 | + if (this.decorationType == null) { |
| 197 | + return; |
| 198 | + } |
| 199 | + const byUri = new Map<string, vscode.Range[]>(); |
| 200 | + for (const bp of vscode.debug.breakpoints) { |
| 201 | + if (!isSnappoint(bp)) { |
| 202 | + continue; |
| 203 | + } |
| 204 | + const key = bp.location.uri.toString(); |
| 205 | + const ranges = byUri.get(key) ?? []; |
| 206 | + ranges.push(bp.location.range); |
| 207 | + byUri.set(key, ranges); |
| 208 | + } |
| 209 | + for (const editor of vscode.window.visibleTextEditors) { |
| 210 | + const ranges = byUri.get(editor.document.uri.toString()) ?? []; |
| 211 | + editor.setDecorations(this.decorationType, ranges); |
| 212 | + } |
| 213 | + } |
| 214 | +} |
0 commit comments