microsoft/qdk
Publicmirrored fromhttps://github.com/microsoft/qdkAvailable
source/playground/src/editor.tsx
527lines · modecode
| 1 | // Copyright (c) Microsoft Corporation. |
| 2 | // Licensed under the MIT License. |
| 3 | |
| 4 | /// <reference types="../../../node_modules/monaco-editor/monaco.d.ts"/> |
| 5 | |
| 6 | import { useEffect, useRef, useState } from "preact/hooks"; |
| 7 | import { |
| 8 | CompilerState, |
| 9 | ICompilerWorker, |
| 10 | ILanguageServiceWorker, |
| 11 | QscEventTarget, |
| 12 | VSDiagnostic, |
| 13 | log, |
| 14 | ProgramConfig, |
| 15 | TargetProfile, |
| 16 | LanguageServiceDiagnosticEvent, |
| 17 | } from "qsharp-lang"; |
| 18 | import { Exercise, getExerciseSources } from "qsharp-lang/katas-md"; |
| 19 | import { codeToCompressedBase64, lsRangeToMonacoRange } from "./utils.js"; |
| 20 | import { ActiveTab } from "./main.js"; |
| 21 | |
| 22 | import type { KataSection } from "qsharp-lang/katas"; |
| 23 | |
| 24 | type ErrCollection = { |
| 25 | checkDiags: VSDiagnostic[]; |
| 26 | shotDiags: VSDiagnostic[]; |
| 27 | }; |
| 28 | |
| 29 | function VSDiagsToMarkers(errors: VSDiagnostic[]): monaco.editor.IMarkerData[] { |
| 30 | return errors.map((err) => { |
| 31 | let severity = monaco.MarkerSeverity.Error; |
| 32 | switch (err.severity) { |
| 33 | case "error": |
| 34 | severity = monaco.MarkerSeverity.Error; |
| 35 | break; |
| 36 | case "warning": |
| 37 | severity = monaco.MarkerSeverity.Warning; |
| 38 | break; |
| 39 | case "info": |
| 40 | severity = monaco.MarkerSeverity.Info; |
| 41 | break; |
| 42 | } |
| 43 | |
| 44 | const marker: monaco.editor.IMarkerData = { |
| 45 | ...lsRangeToMonacoRange(err.range), |
| 46 | severity, |
| 47 | message: err.message, |
| 48 | relatedInformation: err.related?.map((r) => { |
| 49 | const range = lsRangeToMonacoRange(r.location.span); |
| 50 | return { |
| 51 | resource: monaco.Uri.parse(r.location.source), |
| 52 | message: r.message, |
| 53 | ...range, |
| 54 | }; |
| 55 | }), |
| 56 | }; |
| 57 | |
| 58 | if (err.uri && err.code) { |
| 59 | marker.code = { |
| 60 | value: err.code, |
| 61 | target: monaco.Uri.parse(err.uri), |
| 62 | }; |
| 63 | } else if (err.code) { |
| 64 | marker.code = err.code; |
| 65 | } |
| 66 | |
| 67 | return marker; |
| 68 | }); |
| 69 | } |
| 70 | |
| 71 | // get the language service profile from the URL |
| 72 | // default to unrestricted if not specified |
| 73 | export function getProfile(): TargetProfile { |
| 74 | return (new URLSearchParams(window.location.search).get("profile") ?? |
| 75 | "unrestricted") as TargetProfile; |
| 76 | } |
| 77 | |
| 78 | export function Editor(props: { |
| 79 | code: string; |
| 80 | compiler: ICompilerWorker; |
| 81 | compiler_worker_factory: () => ICompilerWorker; |
| 82 | compilerState: CompilerState; |
| 83 | defaultShots: number; |
| 84 | evtTarget: QscEventTarget; |
| 85 | kataSection?: KataSection; |
| 86 | onRestartCompiler: () => void; |
| 87 | shotError?: VSDiagnostic; |
| 88 | showExpr: boolean; |
| 89 | showShots: boolean; |
| 90 | profile: TargetProfile; |
| 91 | setAst: (ast: string) => void; |
| 92 | setHir: (hir: string) => void; |
| 93 | setRir: (rir: string[]) => void; |
| 94 | setQir: (qir: string) => void; |
| 95 | activeTab: ActiveTab; |
| 96 | languageService: ILanguageServiceWorker; |
| 97 | }) { |
| 98 | const editor = useRef<monaco.editor.IStandaloneCodeEditor | null>(null); |
| 99 | const errMarks = useRef<ErrCollection>({ checkDiags: [], shotDiags: [] }); |
| 100 | const editorDiv = useRef<HTMLDivElement>(null); |
| 101 | |
| 102 | // Maintain a ref to the latest getAst/getHir functions, as it closes over a bunch of stuff |
| 103 | const irRef = useRef(async () => { |
| 104 | return; |
| 105 | }); |
| 106 | const [profile, setProfile] = useState(props.profile); |
| 107 | const [shotCount, setShotCount] = useState(props.defaultShots); |
| 108 | const [runExpr, setRunExpr] = useState(""); |
| 109 | const [errors, setErrors] = useState< |
| 110 | { location: string; severity: monaco.MarkerSeverity; msg: string[] }[] |
| 111 | >([]); |
| 112 | const [hasCheckErrors, setHasCheckErrors] = useState(false); |
| 113 | |
| 114 | function markErrors() { |
| 115 | const model = editor.current?.getModel(); |
| 116 | if (!model) return; |
| 117 | |
| 118 | const errs = [ |
| 119 | ...errMarks.current.checkDiags, |
| 120 | ...errMarks.current.shotDiags, |
| 121 | ]; |
| 122 | |
| 123 | const markers = VSDiagsToMarkers(errs); |
| 124 | monaco.editor.setModelMarkers(model, "qsharp", markers); |
| 125 | |
| 126 | const errList = markers.map((err) => ({ |
| 127 | location: `main.qs@(${err.startLineNumber},${err.startColumn})`, |
| 128 | severity: err.severity, |
| 129 | msg: err.message.split("\n\n"), |
| 130 | })); |
| 131 | setErrors(errList); |
| 132 | } |
| 133 | |
| 134 | irRef.current = async function updateIr() { |
| 135 | const code = editor.current?.getValue(); |
| 136 | if (code == null) return; |
| 137 | |
| 138 | const config = { |
| 139 | sources: [["code", code]] as [string, string][], |
| 140 | languageFeatures: [], |
| 141 | profile: profile, |
| 142 | }; |
| 143 | |
| 144 | if (props.activeTab === "ast-tab") { |
| 145 | props.setAst( |
| 146 | await props.compiler.getAst( |
| 147 | code, |
| 148 | config.languageFeatures, |
| 149 | config.profile, |
| 150 | ), |
| 151 | ); |
| 152 | } |
| 153 | if (props.activeTab === "hir-tab") { |
| 154 | props.setHir( |
| 155 | await props.compiler.getHir( |
| 156 | code, |
| 157 | config.languageFeatures, |
| 158 | config.profile, |
| 159 | ), |
| 160 | ); |
| 161 | } |
| 162 | const codeGenTimeout = 1000; // ms |
| 163 | if (props.activeTab === "qir-tab" || props.activeTab === "rir-tab") { |
| 164 | let timedOut = false; |
| 165 | const compiler = props.compiler_worker_factory(); |
| 166 | const compilerTimeout = setTimeout(() => { |
| 167 | log.info("Compiler timeout. Terminating worker."); |
| 168 | timedOut = true; |
| 169 | compiler.terminate(); |
| 170 | }, codeGenTimeout); |
| 171 | try { |
| 172 | if (props.activeTab === "rir-tab") { |
| 173 | const ir = await compiler.getRir(config); |
| 174 | clearTimeout(compilerTimeout); |
| 175 | props.setRir(ir); |
| 176 | } else { |
| 177 | const ir = await compiler.getQir(config); |
| 178 | clearTimeout(compilerTimeout); |
| 179 | props.setQir(ir); |
| 180 | } |
| 181 | } catch (e: any) { |
| 182 | if (timedOut) { |
| 183 | if (props.activeTab === "rir-tab") { |
| 184 | props.setRir(["timed out", "timed out"]); |
| 185 | } else { |
| 186 | props.setQir("timed out"); |
| 187 | } |
| 188 | } else { |
| 189 | if (props.activeTab === "rir-tab") { |
| 190 | props.setRir([e.toString(), e.toString()]); |
| 191 | } else { |
| 192 | props.setQir(e.toString()); |
| 193 | } |
| 194 | } |
| 195 | } finally { |
| 196 | compiler.terminate(); |
| 197 | } |
| 198 | } |
| 199 | }; |
| 200 | |
| 201 | async function onRun() { |
| 202 | const code = editor.current?.getValue(); |
| 203 | if (code == null) return; |
| 204 | props.evtTarget.clearResults(); |
| 205 | const config = { |
| 206 | sources: [["code", code]], |
| 207 | languageFeatures: [], |
| 208 | profile: profile, |
| 209 | } as ProgramConfig; |
| 210 | |
| 211 | try { |
| 212 | if (props.kataSection?.type === "exercise") { |
| 213 | // This is for a kata exercise. Provide the sources that implement the solution verification. |
| 214 | const sources = await getExerciseSources(props.kataSection as Exercise); |
| 215 | // check uses the unrestricted profile and doesn't do code gen, |
| 216 | // so we just pass the sources |
| 217 | await props.compiler.checkExerciseSolution( |
| 218 | code, |
| 219 | sources, |
| 220 | props.evtTarget, |
| 221 | ); |
| 222 | } else { |
| 223 | performance.mark("compiler-run-start"); |
| 224 | await props.compiler.run(config, runExpr, shotCount, props.evtTarget); |
| 225 | const runTimer = performance.measure( |
| 226 | "compiler-run", |
| 227 | "compiler-run-start", |
| 228 | ); |
| 229 | log.logTelemetry({ |
| 230 | id: "compiler-run", |
| 231 | data: { |
| 232 | duration: runTimer.duration, |
| 233 | codeSize: code.length, |
| 234 | shotCount, |
| 235 | }, |
| 236 | }); |
| 237 | } |
| 238 | } catch (err) { |
| 239 | // This could fail for several reasons, e.g. the run being cancelled. |
| 240 | if (err === "terminated") { |
| 241 | log.info("Run was terminated"); |
| 242 | } else { |
| 243 | log.error("Run failed with error: %o", err); |
| 244 | } |
| 245 | } |
| 246 | } |
| 247 | |
| 248 | useEffect(() => { |
| 249 | if (!editorDiv.current) return; |
| 250 | const newEditor = monaco.editor.create(editorDiv.current, { |
| 251 | minimap: { enabled: false }, |
| 252 | lineNumbersMinChars: 3, |
| 253 | automaticLayout: true, |
| 254 | }); |
| 255 | |
| 256 | editor.current = newEditor; |
| 257 | const srcModel = |
| 258 | monaco.editor.getModel( |
| 259 | monaco.Uri.parse(props.kataSection?.id ?? "main.qs"), |
| 260 | ) ?? |
| 261 | monaco.editor.createModel( |
| 262 | "", |
| 263 | "qsharp", |
| 264 | monaco.Uri.parse(props.kataSection?.id ?? "main.qs"), |
| 265 | ); |
| 266 | srcModel.setValue(props.code); |
| 267 | newEditor.setModel(srcModel); |
| 268 | srcModel.onDidChangeContent(() => irRef.current()); |
| 269 | |
| 270 | // TODO: If the language service ever changes, this callback |
| 271 | // will be invalid as it captures the *original* props.languageService |
| 272 | // and not the updated one. Not a problem currently since the language |
| 273 | // service is never updated, but not correct either. |
| 274 | srcModel.onDidChangeContent(async () => { |
| 275 | // Reset the shot errors whenever the document changes. |
| 276 | // The markers will be refreshed by the onDiagnostics callback |
| 277 | // when the language service finishes checking the document. |
| 278 | errMarks.current.shotDiags = []; |
| 279 | |
| 280 | performance.mark("update-document-start"); |
| 281 | await props.languageService.updateDocument( |
| 282 | srcModel.uri.toString(), |
| 283 | srcModel.getVersionId(), |
| 284 | srcModel.getValue(), |
| 285 | ); |
| 286 | const measure = performance.measure( |
| 287 | "update-document", |
| 288 | "update-document-start", |
| 289 | ); |
| 290 | log.info(`updateDocument took ${measure.duration}ms`); |
| 291 | }); |
| 292 | |
| 293 | function onResize() { |
| 294 | newEditor.layout(); |
| 295 | } |
| 296 | |
| 297 | // If the browser window resizes, tell the editor to update it's layout |
| 298 | window.addEventListener("resize", onResize); |
| 299 | return () => { |
| 300 | log.info("Disposing a monaco editor"); |
| 301 | window.removeEventListener("resize", onResize); |
| 302 | props.languageService.closeDocument(srcModel.uri.toString()); |
| 303 | newEditor.dispose(); |
| 304 | }; |
| 305 | }, []); |
| 306 | |
| 307 | useEffect(() => { |
| 308 | props.languageService.updateConfiguration({ |
| 309 | targetProfile: profile, |
| 310 | packageType: props.kataSection ? "lib" : "exe", |
| 311 | lints: props.kataSection |
| 312 | ? [] |
| 313 | : [{ lint: "needlessOperation", level: "warn" }], |
| 314 | }); |
| 315 | |
| 316 | function onDiagnostics(evt: LanguageServiceDiagnosticEvent) { |
| 317 | const diagnostics = evt.detail.diagnostics; |
| 318 | errMarks.current.checkDiags = diagnostics; |
| 319 | markErrors(); |
| 320 | setHasCheckErrors( |
| 321 | diagnostics.filter((d) => d.severity === "error").length > 0, |
| 322 | ); |
| 323 | } |
| 324 | |
| 325 | props.languageService.addEventListener("diagnostics", onDiagnostics); |
| 326 | |
| 327 | return () => { |
| 328 | log.info("Removing diagnostics listener"); |
| 329 | props.languageService.removeEventListener("diagnostics", onDiagnostics); |
| 330 | }; |
| 331 | }, [props.languageService, props.kataSection]); |
| 332 | |
| 333 | useEffect(() => { |
| 334 | const theEditor = editor.current; |
| 335 | if (!theEditor) return; |
| 336 | |
| 337 | theEditor.getModel()?.setValue(props.code); |
| 338 | theEditor.revealLineNearTop(1); |
| 339 | setShotCount(props.defaultShots); |
| 340 | setRunExpr(""); |
| 341 | }, [props.code, props.defaultShots]); |
| 342 | |
| 343 | useEffect(() => { |
| 344 | errMarks.current.shotDiags = props.shotError ? [props.shotError] : []; |
| 345 | markErrors(); |
| 346 | }, [props.shotError]); |
| 347 | |
| 348 | useEffect(() => { |
| 349 | // Whenever the active tab changes, run check again. |
| 350 | irRef.current(); |
| 351 | }, [props.activeTab]); |
| 352 | |
| 353 | useEffect(() => { |
| 354 | // Whenever the selected profile changes, update the language service configuration |
| 355 | // and run the tabs again. |
| 356 | props.languageService.updateConfiguration({ |
| 357 | targetProfile: profile, |
| 358 | }); |
| 359 | irRef.current(); |
| 360 | }, [profile]); |
| 361 | |
| 362 | // On reset, reload the initial code |
| 363 | function onReset() { |
| 364 | const theEditor = editor.current; |
| 365 | if (!theEditor) return; |
| 366 | theEditor.getModel()?.setValue(props.code || ""); |
| 367 | setShotCount(props.defaultShots); |
| 368 | setRunExpr(""); |
| 369 | } |
| 370 | |
| 371 | async function onGetLink(ev: MouseEvent) { |
| 372 | const code = editor.current?.getModel()?.getValue(); |
| 373 | if (!code) return; |
| 374 | |
| 375 | let messageText = "Unable to create the link"; |
| 376 | try { |
| 377 | const encodedCode = await codeToCompressedBase64(code); |
| 378 | const escapedCode = encodeURIComponent(encodedCode); |
| 379 | // Update or add the current URL parameters 'code' and 'profile' |
| 380 | const newURL = new URL(window.location.href); |
| 381 | newURL.searchParams.set("code", escapedCode); |
| 382 | newURL.searchParams.set("profile", profile); |
| 383 | |
| 384 | // Copy link to clipboard and update url without reloading the page |
| 385 | navigator.clipboard.writeText(newURL.toString()); |
| 386 | |
| 387 | window.history.pushState({}, "", newURL.toString()); |
| 388 | messageText = "Link was copied to the clipboard"; |
| 389 | } finally { |
| 390 | const popup = document.getElementById("popup") as HTMLDivElement; |
| 391 | popup.style.display = "block"; |
| 392 | popup.innerText = messageText; |
| 393 | popup.style.left = `${ev.clientX - 120}px`; |
| 394 | popup.style.top = `${ev.clientY - 40}px`; |
| 395 | |
| 396 | setTimeout(() => { |
| 397 | popup.style.display = "none"; |
| 398 | }, 2000); |
| 399 | } |
| 400 | } |
| 401 | |
| 402 | function shotCountChanged(e: Event) { |
| 403 | const target = e.target as HTMLInputElement; |
| 404 | setShotCount(parseInt(target.value) || 1); |
| 405 | } |
| 406 | |
| 407 | function runExprChanged(e: Event) { |
| 408 | const target = e.target as HTMLInputElement; |
| 409 | setRunExpr(target.value); |
| 410 | } |
| 411 | |
| 412 | function profileChanged(e: Event) { |
| 413 | const target = e.target as HTMLInputElement; |
| 414 | setProfile(target.value as TargetProfile); |
| 415 | } |
| 416 | |
| 417 | return ( |
| 418 | <div class="editor-column"> |
| 419 | <div style="display: flex; justify-content: space-between; align-items: center;"> |
| 420 | <div class="file-name">main.qs</div> |
| 421 | <div class="icon-row"> |
| 422 | <svg |
| 423 | onClick={onGetLink} |
| 424 | width="24px" |
| 425 | height="24px" |
| 426 | viewBox="0 0 24 24" |
| 427 | fill="none" |
| 428 | > |
| 429 | <title>Get a link to this code</title> |
| 430 | <path |
| 431 | d="M14 12C14 14.2091 12.2091 16 10 16H6C3.79086 16 2 14.2091 2 12C2 9.79086 3.79086 8 6 8H8M10 12C10 9.79086 11.7909 8 14 8H18C20.2091 8 22 9.79086 22 12C22 14.2091 20.2091 16 18 16H16" |
| 432 | stroke="#000000" |
| 433 | stroke-width="2" |
| 434 | stroke-linecap="round" |
| 435 | stroke-linejoin="round" |
| 436 | /> |
| 437 | </svg> |
| 438 | <svg |
| 439 | onClick={onReset} |
| 440 | width="24px" |
| 441 | height="24px" |
| 442 | viewBox="0 0 24 24" |
| 443 | fill="none" |
| 444 | > |
| 445 | <title>Reset code to initial state</title> |
| 446 | <path |
| 447 | d="M4,13 C4,17.4183 7.58172,21 12,21 C16.4183,21 20,17.4183 20,13 C20,8.58172 16.4183,5 12,5 C10.4407,5 8.98566,5.44609 7.75543,6.21762" |
| 448 | stroke="#0C0310" |
| 449 | stroke-width="2" |
| 450 | stroke-linecap="round" |
| 451 | ></path> |
| 452 | <path |
| 453 | d="M9.2384,1.89795 L7.49856,5.83917 C7.27552,6.34441 7.50429,6.9348 8.00954,7.15784 L11.9508,8.89768" |
| 454 | stroke="#0C0310" |
| 455 | stroke-width="2" |
| 456 | stroke-linecap="round" |
| 457 | ></path> |
| 458 | </svg> |
| 459 | </div> |
| 460 | </div> |
| 461 | <div class="code-editor" ref={editorDiv}></div> |
| 462 | <div class="button-row"> |
| 463 | {props.kataSection ? null : ( |
| 464 | <> |
| 465 | <span>Profile</span> |
| 466 | <select value={profile} onChange={profileChanged}> |
| 467 | <option value="unrestricted">Unrestricted</option> |
| 468 | <option value="adaptive_rif">Adaptive RIF</option> |
| 469 | <option value="adaptive_ri">Adaptive RI</option> |
| 470 | <option value="base">Base</option> |
| 471 | </select> |
| 472 | </> |
| 473 | )} |
| 474 | {props.showExpr ? ( |
| 475 | <> |
| 476 | <span>Start</span> |
| 477 | <input |
| 478 | style="width: 160px" |
| 479 | value={runExpr} |
| 480 | onChange={runExprChanged} |
| 481 | /> |
| 482 | </> |
| 483 | ) : null} |
| 484 | {props.showShots ? ( |
| 485 | <> |
| 486 | <span>Shots</span> |
| 487 | <input |
| 488 | style="width: 88px;" |
| 489 | type="number" |
| 490 | value={shotCount || 100} |
| 491 | max="1000" |
| 492 | min="1" |
| 493 | onChange={shotCountChanged} |
| 494 | /> |
| 495 | </> |
| 496 | ) : null} |
| 497 | <button |
| 498 | class="main-button" |
| 499 | onClick={onRun} |
| 500 | disabled={hasCheckErrors || props.compilerState === "busy"} |
| 501 | > |
| 502 | Run |
| 503 | </button> |
| 504 | <button |
| 505 | class="main-button" |
| 506 | onClick={props.onRestartCompiler} |
| 507 | disabled={props.compilerState === "idle"} |
| 508 | > |
| 509 | Cancel |
| 510 | </button> |
| 511 | </div> |
| 512 | <div class="diag-list"> |
| 513 | {errors.map((err) => ( |
| 514 | <div |
| 515 | className={`diag-row ${err.severity === monaco.MarkerSeverity.Error ? "error-row" : "warning-row"}`} |
| 516 | > |
| 517 | <span>{err.location}: </span> |
| 518 | <span>{err.msg[0]}</span> |
| 519 | {err.msg.length > 1 ? ( |
| 520 | <div class="diag-help">{err.msg[1]}</div> |
| 521 | ) : null} |
| 522 | </div> |
| 523 | ))} |
| 524 | </div> |
| 525 | </div> |
| 526 | ); |
| 527 | } |
| 528 | |