diff --git a/CHANGELOG.md b/CHANGELOG.md index bba0b6d..81c3df6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ - Improved feedback when waiting for a slow Shiny app to start up. ([#65](https://github.com/posit-dev/shiny-vscode/pull/65)) +- The extension can now open Shinylive apps locally from `vscode://posit.shiny/shinylive?url=...` links. ([#70](https://github.com/posit-dev/shiny-vscode/pull/70)) + ## 1.0.0 The Shiny extension for VS Code now has a new extension ID: `Posit.shiny`! New Shiny users should install the Shiny extension from [the VS Code marketplace](https://marketplace.visualstudio.com/items?itemName=Posit.shiny) or [https://open-vsx.org/extension/posit/shiny](https://open-vsx.org/extension/posit/shiny). diff --git a/package.json b/package.json index 4818381..9de46f6 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,8 @@ "onCommand:shiny.python.runApp", "onCommand:shiny.python.debugApp", "onLanguage:r", - "onCommand:shiny.r.runApp" + "onCommand:shiny.r.runApp", + "onUri" ], "main": "./out/extension.js", "contributes": { @@ -238,4 +239,4 @@ "lz-string": "^1.5.0", "winreg": "^1.2.5" } -} +} \ No newline at end of file diff --git a/src/extension-onUri.ts b/src/extension-onUri.ts new file mode 100644 index 0000000..05be2bd --- /dev/null +++ b/src/extension-onUri.ts @@ -0,0 +1,81 @@ +import * as vscode from "vscode"; +import { URLSearchParams } from "url"; +import { + shinyliveSaveAppFromUrl, + shinyliveUrlDecode, + shinyliveUrlEncode, +} from "./shinylive"; + +export async function handlePositShinyUri(uri: vscode.Uri): Promise { + if (!["/shinylive", "/shinylive/"].includes(uri.path)) { + console.warn(`[shiny] Unexpected URI: ${uri.toString()}`); + return; + } + + const encodedUrl = new URLSearchParams(uri.query).get("url"); + + if (!encodedUrl) { + vscode.window.showErrorMessage( + "No URL provided in the Open from Shinylive link." + ); + return; + } + + const url = decodeURIComponent(encodedUrl); + const bundle = shinyliveUrlDecode(url); + + if (!bundle) { + vscode.window.showErrorMessage( + "Shinylive: Failed to parse the Shinylive link. " + + "Please check the link and try again." + ); + return; + } + + let filesText = bundle.files + .slice(0, 3) + .map((f) => f.name) + .join(", "); + if (bundle.files.length > 3) { + filesText += `, and ${bundle.files.length - 3} more files`; + } + + const reviewAction = await vscode.window.showWarningMessage( + `You are about to save a Shinylive app with ${filesText} to your workspace. Would you like to...`, + { modal: true }, + { title: "Cancel", action: "cancel" }, + { title: "Review the app on shinylive.io", action: "review" }, + { + title: `Save app ${bundle.files.length === 1 ? "file" : "files"} locally`, + action: "save", + } + ); + + let { action } = reviewAction || { action: "cancel" }; + + if (action === "cancel") { + return; + } + + if (action === "review") { + bundle.mode = "editor"; + const editorUrl = shinyliveUrlEncode(bundle); + vscode.env.openExternal(vscode.Uri.parse(editorUrl)); + + const openAfterReview = await vscode.window.showInformationMessage( + "After reviewing the Shinylive app, would you like to save it?", + { modal: true }, + "Yes" + ); + + if (!openAfterReview) { + return; + } + action = "save"; + } + + if (action === "save") { + await shinyliveSaveAppFromUrl(url); + return; + } +} diff --git a/src/extension.ts b/src/extension.ts index 499536e..d7b9789 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -6,6 +6,7 @@ import { shinyliveSaveAppFromUrl, shinyliveCreateFromExplorer, } from "./shinylive"; +import { handlePositShinyUri } from "./extension-onUri"; export function activate(context: vscode.ExtensionContext) { context.subscriptions.push( @@ -23,7 +24,12 @@ export function activate(context: vscode.ExtensionContext) { vscode.commands.registerCommand( "shiny.shinylive.createFromExplorer", shinyliveCreateFromExplorer - ) + ), + vscode.window.registerUriHandler({ + handleUri(uri: vscode.Uri): vscode.ProviderResult { + handlePositShinyUri(uri); + }, + }) ); const throttledUpdateContext = new Throttler(2000, () => { diff --git a/src/run.ts b/src/run.ts index 2b2c82a..90d2ea3 100644 --- a/src/run.ts +++ b/src/run.ts @@ -360,7 +360,7 @@ async function getRPathFromPositron(bin: string): Promise { return ""; } - console.log(`[shiny] runtimeMetadata: ${JSON.stringify(runtimeMetadata)}`) + console.log(`[shiny] runtimeMetadata: ${JSON.stringify(runtimeMetadata)}`); const runtimePath = runtimeMetadata.runtimePath; if (!runtimePath) { diff --git a/src/shinylive.ts b/src/shinylive.ts index 2932ec8..2b031fe 100644 --- a/src/shinylive.ts +++ b/src/shinylive.ts @@ -123,11 +123,19 @@ async function createAndOpenShinyliveLink( * files will be saved. The link is decoded and the files are saved into the * directory. * + * @param {string} [url] The Shinylive URL to save the app from. If not provided + * the user will be prompted to enter a URL. + * * @export * @async */ -export async function shinyliveSaveAppFromUrl(): Promise { - const url = await askUserForUrl(); +export async function shinyliveSaveAppFromUrl( + url: string | undefined +): Promise { + if (typeof url === "undefined") { + url = await askUserForUrl(); + } + if (!url) { return; } @@ -496,7 +504,7 @@ async function askUserForOutputLocation( * with the language, files, and mode to encode. * @returns {string} The encoded Shinylive URL. */ -function shinyliveUrlEncode({ language, files, mode }: ShinyliveBundle) { +export function shinyliveUrlEncode({ language, files, mode }: ShinyliveBundle) { const filesJson = JSON.stringify(files); const filesLZ = lzstring.compressToEncodedURIComponent(filesJson); @@ -523,7 +531,7 @@ function shinyliveUrlEncode({ language, files, mode }: ShinyliveBundle) { * @returns {ShinyliveBundle | undefined} The decoded Shinylive bundle, or * `undefined` if the URL could not be decoded. */ -function shinyliveUrlDecode(url: string): ShinyliveBundle | undefined { +export function shinyliveUrlDecode(url: string): ShinyliveBundle | undefined { const { hash, pathname } = new URL(url); const { searchParams } = new URL( "https://shinylive.io/?" + hash.substring(1)