diff --git a/.gitattributes b/.gitattributes index fcadb2cf97..d0c0c4c1dc 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ * text eol=lf +*.png binary diff --git a/.gitignore b/.gitignore index 03e5fcc9f8..a0d9356191 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ __pycache__/ .DS_Store .vscode/* .vscode-test-web/ +*.pem !.vscode/*.shared.json !.vscode/extensions.json /fuzz/target diff --git a/vscode/package.json b/vscode/package.json index c2aebbb58b..075d263540 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -4,6 +4,11 @@ "description": "Q# Language Support", "version": "0.0.0", "publisher": "quantum", + "icon": "resources/qdk.png", + "galleryBanner": { + "color": "#252526", + "theme": "dark" + }, "type": "commonjs", "engines": { "vscode": "^1.77.0" @@ -18,9 +23,75 @@ "onNotebook:jupyter-notebook", "onDebug", "onDebugResolve:qsharp", - "onDebugDynamicConfigurations:qsharp" + "onDebugDynamicConfigurations:qsharp", + "onFileSystem:qsharp-vfs" ], "contributes": { + "walkthroughs": [ + { + "id": "qsharp-vscode.welcome", + "title": "The Azure Quantum Development Kit", + "description": "Getting started with the Azure Quantum Development Kit in VS Code", + "steps": [ + { + "id": "qsharp-vscode.welcome.editor", + "title": "Welcome to the Azure Quantum Development Kit", + "description": "The Azure Quantum Development Kit (QDK) is an open-source SDK that you can use to write quantum programs and execute them on quantum hardware. This walkthrough will show you how to get started with the Azure Quantum Development Kit in VS Code.\n\nThe QDK gives you rich editor support for writing quantum programs in the Q# language, such as error checking, signature help, completion lists, safely renaming identifiers, and much more.", + "media": { + "image": "resources/intellisense.png", + "altText": "Intellisense" + } + }, + { + "id": "qsharp-vscode.welcome.debug", + "title": "Debug Q# code", + "description": "With your Q# code open in the editor, use the F5 shortcut or the top right icons in the code edtior to run or debug the code.", + "media": { + "image": "resources/debug.png", + "altText": "Debug" + } + }, + { + "id": "qsharp-vscode.welcome.simulator", + "title": "Run quantum simulations", + "description": "You can run quantum simulations directly in VS Code and see the program output in the integrated terminal.", + "media": { + "image": "resources/console.png", + "altText": "Console" + } + }, + { + "id": "qsharp-vscode.welcome.submit", + "title": "Run on Azure Quantum", + "description": "If you have an Azure subscription, you can connect to your Azure Quantum workspace and submit your Q# program directly to quantum hardware", + "media": { + "image": "resources/submit.png", + "altText": "Submit to Azure" + } + }, + { + "id": "qsharp-vscode.welcome.starters", + "title": "Starting points", + "description": "Expore Q# safely by opening files in the [Q# playground](command:qsharp-vscode.openPlayground), or work in Python by [creating a Jupyter Notebook](command:qsharp-vscode.createNotebook) from a template", + "media": { + "image": "resources/notebook.png", + "altText": "Jupyter Notebooks" + } + } + ] + } + ], + "webOpener": { + "scheme": "qsharp-vfs", + "runCommands": [ + { + "command": "qsharp-vscode.webOpener", + "args": [ + "$url" + ] + } + ] + }, "configuration": { "title": "Q#", "properties": { @@ -88,6 +159,10 @@ { "command": "qsharp-vscode.setTargetProfile", "when": "resourceLangId == qsharp" + }, + { + "command": "qsharp-vscode.webOpener", + "when": "false" } ], "view/title": [ @@ -143,6 +218,11 @@ } ], "commands": [ + { + "command": "qsharp-vscode.webOpener", + "title": "Internal web opener", + "category": "Q#" + }, { "command": "qsharp-vscode.debugEditorContents", "title": "Debug Q# file", @@ -212,6 +292,11 @@ "command": "qsharp-vscode.createNotebook", "category": "Q#", "title": "Create an Azure Quantum notebook" + }, + { + "command": "qsharp-vscode.openPlayground", + "category": "Q#", + "title": "Open Q# playground" } ], "breakpoints": [ diff --git a/vscode/resources/console.png b/vscode/resources/console.png new file mode 100644 index 0000000000..536bf00e94 Binary files /dev/null and b/vscode/resources/console.png differ diff --git a/vscode/resources/debug.png b/vscode/resources/debug.png new file mode 100644 index 0000000000..7a718d60fb Binary files /dev/null and b/vscode/resources/debug.png differ diff --git a/vscode/resources/intellisense.png b/vscode/resources/intellisense.png new file mode 100644 index 0000000000..15e9c17938 Binary files /dev/null and b/vscode/resources/intellisense.png differ diff --git a/vscode/resources/notebook.png b/vscode/resources/notebook.png new file mode 100644 index 0000000000..64e3549e4c Binary files /dev/null and b/vscode/resources/notebook.png differ diff --git a/vscode/resources/qdk.png b/vscode/resources/qdk.png new file mode 100644 index 0000000000..109aa6f0d9 Binary files /dev/null and b/vscode/resources/qdk.png differ diff --git a/vscode/resources/qdk.svg b/vscode/resources/qdk.svg new file mode 100644 index 0000000000..105ee9f98a --- /dev/null +++ b/vscode/resources/qdk.svg @@ -0,0 +1,8 @@ + + + + + + + diff --git a/vscode/resources/submit.png b/vscode/resources/submit.png new file mode 100644 index 0000000000..685b69157a Binary files /dev/null and b/vscode/resources/submit.png differ diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts index 7b5468cde1..a13861a537 100644 --- a/vscode/src/extension.ts +++ b/vscode/src/extension.ts @@ -30,6 +30,7 @@ import { initCodegen } from "./qirGeneration.js"; import { activateTargetProfileStatusBarItem } from "./statusbar.js"; import { createSignatureHelpProvider } from "./signature.js"; import { createRenameProvider } from "./rename.js"; +import { initFileSystem } from "./memfs.js"; export async function activate(context: vscode.ExtensionContext) { initializeLogger(); @@ -57,6 +58,7 @@ export async function activate(context: vscode.ExtensionContext) { initCodegen(context); activateDebugger(context); registerCreateNotebookCommand(context); + initFileSystem(context); log.info("Q# extension activated."); } diff --git a/vscode/src/memfs.ts b/vscode/src/memfs.ts new file mode 100644 index 0000000000..ed9a5b4a86 --- /dev/null +++ b/vscode/src/memfs.ts @@ -0,0 +1,419 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { log, samples } from "qsharp-lang"; +import * as vscode from "vscode"; + +export const scheme = "qsharp-vfs"; + +const playgroundAuthority = "playground"; +const playgroundRootUri = vscode.Uri.parse( + `${scheme}://${playgroundAuthority}/` +); + +const playgroundReadme = ` +# Azure Quantum Playground + +Welcome to the Azure Quantum Development Kit playground! An online environment to +safely learn and explore quantum computing with the Q# language. + +The samples folder contains a set of common quantum algorithms written in Q#. +You can run these samples by clicking the "Run" button in the top right corner +of the editor when you have the file open. You can also set breakpoints and +step through the code using the Debug button at the same location to see how the +algorithm changes quantum state as it executes. + +This playground exists entirely in memory and is not persisted to disk. All changes +will be lost when the editor window is closed. You should use the 'File: Save +As...' command in the VS Code Command Palette (accessed by pressing F1) to save +your work elsewhere if you wish to keep it. + +For more details on using the Azure Quantum Development Kit for Visual Studio +Code, see the wiki at +`; + +// Put the playground in its own 'authority', so we can keep the default space clean. +// This has the benefit of the URI https://vscode.dev/quantum/playground/ opening the playground +function populateSamples(vfs: MemFS) { + vfs.addAuthority(playgroundAuthority); + + const encoder = new TextEncoder(); + vfs.createDirectory(playgroundRootUri.with({ path: "/samples" })); + + samples.forEach((sample) => { + vfs.writeFile( + playgroundRootUri.with({ path: `/samples/${sample.title}.qs` }), + encoder.encode(sample.code), + { create: true, overwrite: true } + ); + }); + + vfs.writeFile( + playgroundRootUri.with({ path: "/README.md" }), + encoder.encode(playgroundReadme), + { create: true, overwrite: true } + ); +} + +export async function initFileSystem(context: vscode.ExtensionContext) { + const vfs = new MemFS(); + populateSamples(vfs); + + context.subscriptions.push( + vscode.workspace.registerFileSystemProvider(scheme, vfs, { + isCaseSensitive: true, + isReadonly: false, + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand( + "qsharp-vscode.openPlayground", + async () => { + await vscode.commands.executeCommand( + "vscode.openFolder", + playgroundRootUri + ); + } + ) + ); + + context.subscriptions.push( + vscode.commands.registerCommand("qsharp-vscode.webOpener", async (uri) => { + log.debug(`webOpener called with URI ${uri}`); + + // Open the README if the user has navigated to the playground + if (typeof uri === "string" && uri.endsWith("/playground/")) { + // Nice to have: First check if the readme is already open from a prior visit + await vscode.commands.executeCommand( + "markdown.showPreview", + playgroundRootUri.with({ path: "/README.md" }) + ); + return; + } + + // Example: https://vscode.dev/quantum?code=H4sIAAAAAAAAEz2Ouw6CQBRE%2B%2F2KITbQSI%2BNhRYWhkc0FoTiBm5kE9kld3c1xPDvEjSe8mQmM2mKS68dWtsxXuTgehLu8NQEwrU6RZFShgZ2I7WM81QGMj4Mhdi70IC3UljYH42XqbDa%2BDhZjR1ZyGtrcCZt4gQZKnbh4etmKeFHusznhzzDTbRnTDYIys33Tc%2FC239S2AcxqJvdqmY1qw8FRbBxvAAAAA%3D%3D + let linkedCode: string | undefined; + if (typeof uri === "string") { + const uriObj = vscode.Uri.parse(uri); + log.debug("uri query component: " + uriObj.query); + + // The query appears to be URIDecoded already, which is causing issues with URLSearchParams, so extract with a regex for now. + const code = uriObj.query.match(/code=([^&]*)/)?.[1]; + + if (code) { + log.debug("code from query: " + code); + try { + linkedCode = await compressedBase64ToCode(code); + const codeFile = vscode.Uri.parse(`${scheme}:/code.qs`); + + const encoder = new TextEncoder(); + vfs.writeFile(codeFile, encoder.encode(linkedCode), { + create: true, + overwrite: true, + }); + await vscode.commands.executeCommand("vscode.open", codeFile); + } catch (err) { + log.warn("Unable to decode the code in the URL. ", err); + } + } + } + }) + ); +} + +// basename and dirname are only called with a vscode.uri 'path', which should be a well-formed posix path +// Below tested to align with how NodeJS path.basename and path.dirname work +function basename(path: string) { + path = path.replace(/\/+$/, ""); + if (!path) { + return ""; + } + return path.substring(path.lastIndexOf("/") + 1); +} + +function dirname(path: string) { + if (!path) return "."; + if (path === "/") return "/"; + + path = path.replace(/\/+$/, ""); + const offset = path.lastIndexOf("/"); + + switch (offset) { + case -1: + return "."; + case 0: + return "/"; + default: + return path.substring(0, offset); + } +} + +// The below largely taken from the reference implementation at +// https://github.com/microsoft/vscode-extension-samples/blob/main/fsprovider-sample/src/fileSystemProvider.ts +// with a few additions (e.g. handling 'authority'). +export class File implements vscode.FileStat { + type: vscode.FileType; + ctime: number; + mtime: number; + size: number; + + name: string; + data?: Uint8Array; + + constructor(name: string) { + this.type = vscode.FileType.File; + this.ctime = Date.now(); + this.mtime = Date.now(); + this.size = 0; + this.name = name; + } +} + +export class Directory implements vscode.FileStat { + type: vscode.FileType; + ctime: number; + mtime: number; + size: number; + + name: string; + entries: Map; + + constructor(name: string) { + this.type = vscode.FileType.Directory; + this.ctime = Date.now(); + this.mtime = Date.now(); + this.size = 0; + this.name = name; + this.entries = new Map(); + } +} + +export type Entry = File | Directory; + +export class MemFS implements vscode.FileSystemProvider { + authorities = new Map([["", new Directory("")]]); + + addAuthority(authority: string) { + this.authorities.set(authority, new Directory("")); + } + + stat(uri: vscode.Uri): vscode.FileStat { + log.debug(`stat: ${uri.path}`); + return this._lookup(uri, false); + } + + readDirectory(uri: vscode.Uri): [string, vscode.FileType][] { + log.debug(`readDirectory: ${uri.path}`); + const entry = this._lookupAsDirectory(uri, false); + const result: [string, vscode.FileType][] = []; + for (const [name, child] of entry.entries) { + result.push([name, child.type]); + } + return result; + } + + readFile(uri: vscode.Uri): Uint8Array { + log.debug("readFile: " + uri.path); + const data = this._lookupAsFile(uri, false).data; + if (data) { + return data; + } + throw vscode.FileSystemError.FileNotFound(); + } + + writeFile( + uri: vscode.Uri, + content: Uint8Array, + options: { create: boolean; overwrite: boolean } + ): void { + log.debug("writeFile: " + uri.path); + + const baseName = basename(uri.path); + const parent = this._lookupParentDirectory(uri); + let entry = parent.entries.get(baseName); + if (entry instanceof Directory) { + throw vscode.FileSystemError.FileIsADirectory(uri); + } + if (!entry && !options.create) { + throw vscode.FileSystemError.FileNotFound(uri); + } + if (entry && options.create && !options.overwrite) { + throw vscode.FileSystemError.FileExists(uri); + } + if (!entry) { + entry = new File(baseName); + parent.entries.set(baseName, entry); + this._fireSoon({ type: vscode.FileChangeType.Created, uri }); + } + entry.mtime = Date.now(); + entry.size = content.byteLength; + entry.data = content; + + this._fireSoon({ type: vscode.FileChangeType.Changed, uri }); + } + + rename( + oldUri: vscode.Uri, + newUri: vscode.Uri, + options: { overwrite: boolean } + ): void { + if (!options.overwrite && this._lookup(newUri, true)) { + throw vscode.FileSystemError.FileExists(newUri); + } + + const entry = this._lookup(oldUri, false); + const oldParent = this._lookupParentDirectory(oldUri); + + const newParent = this._lookupParentDirectory(newUri); + const newName = basename(newUri.path); + + oldParent.entries.delete(entry.name); + entry.name = newName; + newParent.entries.set(newName, entry); + + this._fireSoon( + { type: vscode.FileChangeType.Deleted, uri: oldUri }, + { type: vscode.FileChangeType.Created, uri: newUri } + ); + } + + delete(uri: vscode.Uri): void { + const dirName = uri.with({ path: dirname(uri.path) }); + const baseName = basename(uri.path); + const parent = this._lookupAsDirectory(dirName, false); + if (!parent.entries.has(baseName)) { + throw vscode.FileSystemError.FileNotFound(uri); + } + parent.entries.delete(baseName); + parent.mtime = Date.now(); + parent.size -= 1; + this._fireSoon( + { type: vscode.FileChangeType.Changed, uri: dirName }, + { uri, type: vscode.FileChangeType.Deleted } + ); + } + + createDirectory(uri: vscode.Uri): void { + const baseName = basename(uri.path); + const dirName = uri.with({ path: dirname(uri.path) }); + const parent = this._lookupAsDirectory(dirName, false); + + const entry = new Directory(baseName); + parent.entries.set(entry.name, entry); + parent.mtime = Date.now(); + parent.size += 1; + this._fireSoon( + { type: vscode.FileChangeType.Changed, uri: dirName }, + { type: vscode.FileChangeType.Created, uri } + ); + } + + private _lookup(uri: vscode.Uri, silent: false): Entry; + private _lookup(uri: vscode.Uri, silent: boolean): Entry | undefined; + private _lookup(uri: vscode.Uri, silent: boolean): Entry | undefined { + const parts = uri.path.split("/"); + let entry: Entry | undefined = this.authorities.get(uri.authority); + if (!entry) { + throw vscode.FileSystemError.FileNotFound(uri); + } + + for (const part of parts) { + if (!part) { + continue; + } + let child: Entry | undefined; + if (entry instanceof Directory) { + child = entry.entries.get(part); + } + if (!child) { + if (!silent) { + throw vscode.FileSystemError.FileNotFound(uri); + } else { + return undefined; + } + } + entry = child; + } + return entry; + } + + private _lookupAsDirectory(uri: vscode.Uri, silent: boolean): Directory { + const entry = this._lookup(uri, silent); + if (entry instanceof Directory) { + return entry; + } + throw vscode.FileSystemError.FileNotADirectory(uri); + } + + private _lookupAsFile(uri: vscode.Uri, silent: boolean): File { + const entry = this._lookup(uri, silent); + if (entry instanceof File) { + return entry; + } + throw vscode.FileSystemError.FileIsADirectory(uri); + } + + private _lookupParentDirectory(uri: vscode.Uri): Directory { + const dirName = uri.with({ path: dirname(uri.path) }); + return this._lookupAsDirectory(dirName, false); + } + + private _emitter = new vscode.EventEmitter(); + private _bufferedEvents: vscode.FileChangeEvent[] = []; + private _fireSoonHandle?: any; + + readonly onDidChangeFile: vscode.Event = + this._emitter.event; + + watch(): vscode.Disposable { + // NOTE: Docs for this API state, "It is the file system provider's job to + // call onDidChangeFile for every change given these rules. No event should + // be emitted for files that match any of the provided excludes.". But this + // implementation just fires on every change (see below). However most of the + // other implementations I've seen do the same, so assume this is harmless. + return new vscode.Disposable(() => { + return; + }); + } + + private _fireSoon(...events: vscode.FileChangeEvent[]): void { + this._bufferedEvents.push(...events); + + if (this._fireSoonHandle) { + clearTimeout(this._fireSoonHandle); + } + + this._fireSoonHandle = setTimeout(() => { + this._emitter.fire(this._bufferedEvents); + this._bufferedEvents.length = 0; + }, 5); + } +} + +// Cleanup: This is taken from the playground. It should probably be moved to a common +// location in the npm package and shared between the two at some point. +export async function compressedBase64ToCode(base64: string) { + // Turn the base64 string into a string of bytes + const binStr = atob(base64); + + // Turn it into a byte array + const byteArray = new Uint8Array(binStr.length); + for (let i = 0; i < binStr.length; ++i) byteArray[i] = binStr.charCodeAt(i); + + // Decompress the bytes + const decompressor = new DecompressionStream("gzip"); + const writer = decompressor.writable.getWriter(); + writer.write(byteArray); + writer.close(); + + // Read the decompressed stream and turn into a byte string + const decompressedBuff = await new Response( + decompressor.readable + ).arrayBuffer(); + + // Decode the utf-8 bytes into a JavaScript string + const decoder = new TextDecoder(); + const code = decoder.decode(decompressedBuff); + return code; +}