|
| 1 | +// Future goals: |
| 2 | +// - Persist owner/repo names in workspace storage |
| 3 | +// - Cache coverage |
| 4 | +// - Show coverage totals somewhere |
| 5 | +// - Maybe add codecov button to disable and/or view in codecov |
| 6 | + |
| 7 | +import { |
| 8 | + ExtensionContext, |
| 9 | + OverviewRulerLane, |
| 10 | + Position, |
| 11 | + Range, |
| 12 | + Uri, |
| 13 | + commands, |
| 14 | + window, |
| 15 | + workspace, |
| 16 | +} from "vscode"; |
| 17 | +import axios from "axios"; |
| 18 | + |
| 19 | +type Coverage = |
| 20 | + | { |
| 21 | + line_coverage: [number, number][]; |
| 22 | + } |
| 23 | + | undefined; |
| 24 | + |
| 25 | +const Colors = { |
| 26 | + covered: "rgb(33,181,119)", |
| 27 | + partial: "rgb(244,176,27)", |
| 28 | + missed: "rgb(245,32,32)", |
| 29 | +} as const; |
| 30 | + |
| 31 | +const Icons = { |
| 32 | + covered: Uri.parse( |
| 33 | + "data:image/svg+xml;base64," + |
| 34 | + Buffer.from( |
| 35 | + `<svg version="1.1" width="18" height="18" xmlns="http://www.w3.org/2000/svg"> |
| 36 | + <rect width="3" height="100%" fill="${Colors.covered}" /> |
| 37 | +</svg>` |
| 38 | + ).toString("base64") |
| 39 | + ), |
| 40 | + partial: Uri.parse( |
| 41 | + "data:image/svg+xml;base64," + |
| 42 | + Buffer.from( |
| 43 | + `<svg version="1.1" width="18" height="18" xmlns="http://www.w3.org/2000/svg"> |
| 44 | + <rect width="3" height="100%" fill="${Colors.partial}" /> |
| 45 | +</svg>` |
| 46 | + ).toString("base64") |
| 47 | + ), |
| 48 | + missed: Uri.parse( |
| 49 | + "data:image/svg+xml;base64," + |
| 50 | + Buffer.from( |
| 51 | + `<svg version="1.1" width="18" height="18" xmlns="http://www.w3.org/2000/svg"> |
| 52 | + <rect width="3" height="100%" fill="${Colors.missed}" /> |
| 53 | +</svg>` |
| 54 | + ).toString("base64") |
| 55 | + ), |
| 56 | +} as const; |
| 57 | + |
| 58 | +export function activateCoverage(context: ExtensionContext) { |
| 59 | + const command = "codecov.reset.api.key"; |
| 60 | + |
| 61 | + const resetApiKeyHandler = () => { |
| 62 | + context.secrets.delete("api.key"); |
| 63 | + updateDecorations(); |
| 64 | + }; |
| 65 | + |
| 66 | + context.subscriptions.push( |
| 67 | + commands.registerCommand(command, resetApiKeyHandler) |
| 68 | + ); |
| 69 | + |
| 70 | + const lineCoveredDecoration = window.createTextEditorDecorationType({ |
| 71 | + gutterIconPath: Icons.covered, |
| 72 | + overviewRulerColor: Colors.covered, |
| 73 | + overviewRulerLane: OverviewRulerLane.Right, |
| 74 | + }); |
| 75 | + const linePartialDecoration = window.createTextEditorDecorationType({ |
| 76 | + gutterIconPath: Icons.partial, |
| 77 | + overviewRulerColor: Colors.partial, |
| 78 | + overviewRulerLane: OverviewRulerLane.Right, |
| 79 | + }); |
| 80 | + const lineMissedDecoration = window.createTextEditorDecorationType({ |
| 81 | + gutterIconPath: Icons.missed, |
| 82 | + overviewRulerColor: Colors.missed, |
| 83 | + overviewRulerLane: OverviewRulerLane.Right, |
| 84 | + }); |
| 85 | + |
| 86 | + let activeEditor = window.activeTextEditor; |
| 87 | + |
| 88 | + async function updateDecorations() { |
| 89 | + if (!activeEditor) { |
| 90 | + return; |
| 91 | + } |
| 92 | + |
| 93 | + const config = workspace.getConfiguration("codecov"); |
| 94 | + |
| 95 | + const enabled = config.get("coverage.enabled"); |
| 96 | + if (!enabled) return; |
| 97 | + |
| 98 | + let apiKey = await context.secrets.get("api.key"); |
| 99 | + let apiUrl = config.get("api.url"); |
| 100 | + const provider = config.get("api.gitProvider"); |
| 101 | + |
| 102 | + if (!apiKey) { |
| 103 | + const result = await window.showErrorMessage( |
| 104 | + "To see Codecov line coverage in your editor, you must first set an API Key.", |
| 105 | + { |
| 106 | + modal: true, |
| 107 | + detail: |
| 108 | + "If you don't want to do this right now, you can disable Codecov line coverage in the Codecov extension's settings.", |
| 109 | + }, |
| 110 | + "Set an API Key" |
| 111 | + ); |
| 112 | + |
| 113 | + if (result === "Set an API Key") { |
| 114 | + apiKey = await window.showInputBox({ |
| 115 | + title: "Enter your Codecov API Key", |
| 116 | + prompt: |
| 117 | + "You can generate an API key in your account settings within the Codecov app.", |
| 118 | + }); |
| 119 | + if (apiKey) await context.secrets.store("api.key", apiKey); |
| 120 | + updateDecorations(); |
| 121 | + return; |
| 122 | + } |
| 123 | + } |
| 124 | + |
| 125 | + if (!apiUrl) { |
| 126 | + // Just reset it to default for this workspace |
| 127 | + await config.update("api.url", "https://api.codecov.io", false); |
| 128 | + apiUrl = "https://api.codecov.io"; |
| 129 | + } |
| 130 | + |
| 131 | + const path = encodeURIComponent( |
| 132 | + workspace.asRelativePath(activeEditor.document.fileName) |
| 133 | + ); |
| 134 | + |
| 135 | + const pathToWorkspace = workspace.getWorkspaceFolder( |
| 136 | + Uri.file(activeEditor.document.fileName) |
| 137 | + )?.uri.path; |
| 138 | + |
| 139 | + const gitConfig = Uri.file(`${pathToWorkspace}/.git/config`); |
| 140 | + const remote = await workspace.fs |
| 141 | + .readFile(gitConfig) |
| 142 | + .then((buf) => buf.toString()) |
| 143 | + .then((string) => string.split("\n")) |
| 144 | + .then((lines) => lines.find((line) => line.match(/git@.*:.*\/.*.git$/))) |
| 145 | + .then((line) => line?.replace(/.*:/, "").replace(".git", "").split("/")); |
| 146 | + if (!remote) return; |
| 147 | + const [owner, repo] = remote; |
| 148 | + |
| 149 | + const gitHead = Uri.file(`${pathToWorkspace}/.git/HEAD`); |
| 150 | + const branch = await workspace.fs |
| 151 | + .readFile(gitHead) |
| 152 | + .then((buf) => buf.toString()) |
| 153 | + .then((string) => string.replace("ref: refs/heads/", "").slice(0, -1)); |
| 154 | + |
| 155 | + if (!branch) return; |
| 156 | + |
| 157 | + // Don't need this right now, but may be useful in the future if we want to cache coverage |
| 158 | + //const gitRefFile = Uri.file(`${pathToWorkspace}/.git/refs/heads/${branch}`); |
| 159 | + //const commitHash = await workspace.fs |
| 160 | + // .readFile(gitRefFile) |
| 161 | + // .then((buf) => buf.toString()); |
| 162 | + //if (!commitHash) return; |
| 163 | + |
| 164 | + const coverageUrl = `${apiUrl}/api/v2/${provider}/${owner}/repos/${repo}/file_report/${path}`; |
| 165 | + |
| 166 | + // First try getting coverage for this branch |
| 167 | + let error = null; |
| 168 | + let coverage: Coverage = await axios |
| 169 | + .get(`${coverageUrl}?branch=${encodeURIComponent(branch)}`, { |
| 170 | + headers: { |
| 171 | + accept: "application/json", |
| 172 | + authorization: `Bearer ${apiKey}`, |
| 173 | + }, |
| 174 | + }) |
| 175 | + .then((response) => response.data) |
| 176 | + .catch(async (error) => { |
| 177 | + if (error?.response?.status >= 500) { |
| 178 | + const choice = await window.showErrorMessage( |
| 179 | + "Codecov: Unable to connect to server or something went seriously wrong.", |
| 180 | + "Reset your API key" |
| 181 | + ); |
| 182 | + if (choice === "Reset your API key") |
| 183 | + await commands.executeCommand(command); |
| 184 | + } else if (error?.response.status === 401) { |
| 185 | + const choice = await window.showErrorMessage( |
| 186 | + "Codecov: The provided API key is not authorized to access this repository.", |
| 187 | + "Reset your API key" |
| 188 | + ); |
| 189 | + if (choice === "Reset your API key") |
| 190 | + await commands.executeCommand(command); |
| 191 | + } |
| 192 | + error = error; |
| 193 | + }); |
| 194 | + |
| 195 | + if (error) return; |
| 196 | + |
| 197 | + if (!coverage || !coverage.line_coverage) { |
| 198 | + // No coverage for this file/branch. Fall back to default branch coverage. |
| 199 | + coverage = await axios |
| 200 | + .get(coverageUrl, { |
| 201 | + headers: { |
| 202 | + accept: "application/json", |
| 203 | + authorization: `Bearer ${apiKey}`, |
| 204 | + }, |
| 205 | + }) |
| 206 | + .then((response) => response.data); |
| 207 | + } |
| 208 | + |
| 209 | + if (!coverage || !coverage.line_coverage) return; |
| 210 | + |
| 211 | + const coveredLines: Range[] = []; |
| 212 | + const partialLines: Range[] = []; |
| 213 | + const missedLines: Range[] = []; |
| 214 | + |
| 215 | + coverage.line_coverage.forEach((line) => { |
| 216 | + if (line[1] === 0) { |
| 217 | + coveredLines.push( |
| 218 | + new Range(new Position(line[0] - 1, 0), new Position(line[0] - 1, 0)) |
| 219 | + ); |
| 220 | + } else if (line[1] === 2) { |
| 221 | + partialLines.push( |
| 222 | + new Range(new Position(line[0] - 1, 0), new Position(line[0] - 1, 0)) |
| 223 | + ); |
| 224 | + } else { |
| 225 | + missedLines.push( |
| 226 | + new Range(new Position(line[0] - 1, 0), new Position(line[0] - 1, 0)) |
| 227 | + ); |
| 228 | + } |
| 229 | + }); |
| 230 | + |
| 231 | + activeEditor.setDecorations(lineCoveredDecoration, coveredLines); |
| 232 | + activeEditor.setDecorations(linePartialDecoration, partialLines); |
| 233 | + activeEditor.setDecorations(lineMissedDecoration, missedLines); |
| 234 | + } |
| 235 | + |
| 236 | + if (activeEditor) { |
| 237 | + updateDecorations(); |
| 238 | + } |
| 239 | + |
| 240 | + window.onDidChangeActiveTextEditor( |
| 241 | + (editor) => { |
| 242 | + activeEditor = editor; |
| 243 | + if (editor) { |
| 244 | + updateDecorations(); |
| 245 | + } |
| 246 | + }, |
| 247 | + null, |
| 248 | + context.subscriptions |
| 249 | + ); |
| 250 | + |
| 251 | + workspace.onDidSaveTextDocument( |
| 252 | + (event) => { |
| 253 | + if (activeEditor && event === activeEditor.document) { |
| 254 | + updateDecorations(); |
| 255 | + } |
| 256 | + }, |
| 257 | + null, |
| 258 | + context.subscriptions |
| 259 | + ); |
| 260 | +} |
0 commit comments