diff --git a/CHANGELOG.md b/CHANGELOG.md index b9b74cce7..e9fec793f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,11 @@ Changes to Calva. ## [Unreleased] +- [Add flare handler and webview](https://github.com/BetterThanTomorrow/calva/issues/2679) + ## [2.0.482] - 2024-12-03 -- Fix: [Added 'replace-refer-all-with-alias' & 'replace-refer-all-with-refer' actions to calva.](https://github.com/BetterThanTomorrow/calva/issues/2667) +- Fix: [Added 'replace-refer-all-with-alias' & 'replace-refer-all-with-refer' actions to calva.](https://github.com/BetterThanTomorrow/calva/issues/2667) ## [2.0.481] - 2024-10-29 diff --git a/docs/site/flares.md b/docs/site/flares.md new file mode 100644 index 000000000..e505cc3d8 --- /dev/null +++ b/docs/site/flares.md @@ -0,0 +1,129 @@ +--- +title: Calva Flares Documentation +description: Learn how to use Calva Flares to enhance your development experience. +--- + +# Calva Flares + +Flares are a mechanism in Calva that allow the REPL server (where your Clojure code runs) to send requests to the REPL client (your Calva IDE) to trigger specific behaviors. +They bridge the gap between user-space code and IDE features, enabling dynamic and interactive workflows. + +Flares are special values that, when encountered by the IDE, prompt it to perform predefined actions such as rendering HTML, showing notifications, or visualizing data. + +> **TIP:** +> Don't put flares in your project code. +> Flares are IDE specific, so they should be created by tooling code. +> Flares will be created when invoking a tool or custom action from your IDE. + +## How to Create Flares + +Flares take the form of a map with a single key-value pair. +The key specifies the flare is for Calva `:calva/flare`, while the value contains the details of the request. + +```clojure +{:calva/flare {:type :info + :message "Congratulations, you sent a flare!"}} +``` + +- **Key**: `:calva/flare` – Identifies this as a flare for Calva. +- **Value**: A map defining the specific request, such as showing a message, rendering HTML, or invoking an IDE command. + +Here’s a flare to display a HTML greeting: + +```clojure +{:calva/flare {:type :webview + :html "

Hello, Calva!

", + :title "Greeting"}} +``` + +## Typical Uses of Flares + +Flares enhance your development experience by enabling IDE features directly from user-space code. Below are common use cases: + +### 1. Data Visualization + +Used with tools like Clay, you can render HTML, SVG, or other visual elements directly in the IDE: + +```clojure +(calva.clay/webview $current-form $file) +``` + +Produces a flare: + +```clojure +{:calva/flare {:type :webview + :url "https://localhost:1971"}}} +``` + +Enabling you to create a custom action "Send to Clay" to visualize Kindly annotated visualizations. + +### 2. Notifications + +Test results or task completion: + +```clojure +{:calva/flare {:type :info + :message "Tests Passed 🎉"}}} +``` + +### 3. VSCode Commands + +Developers can define custom workflows or integrate with external tools: + +```clojure +{:calva/flare {:type :command + :command "workbench.action.toggleLightDarkThemes" }} +``` + +### 4. Debugging and Status Updates + +Send contextual data back to the IDE for live updates or inline annotations. + +## Why Use Flares? + +Flares enhance the feedback loop between your code and the IDE, reducing context switching and enabling a more interactive development experience. + +### Key Benefits + +- **Immediate Feedback**: See results, warnings, or visualizations inline as part of your workflow. +- **Custom Workflows**: Tailor IDE behavior to suit your needs using tools like Clay or by creating custom flares. +- **IDE-Specific Features**: Leverage the unique capabilities of Calva while maintaining the flexibility to extend or modify functionality. + +## Allowed Commands + +Calva supports a predefined set of flare actions and commands that are allowed. +If you want to access other commands, enable them in settings. + +Be mindful that flares are values, and values may originate from sources outside of your code. +For example if you read a value out of a logfile into a map, it could be a flare! + +If you want to experiment with new flare handlers, consider using Joyride to inject them. + +## Flare Reference + +All flares may have a `:then` in them which is a fully qualified symbol of a function to invoke with the result of the processed flare. + +| type | keys | +|------|-----| +| `:info` | `:message`, `items` | +| `:warn` | `:message`, `items` | +| `:error` | `:message`, `items` | +| `:webview` | `:title`, `:html`, `:url`, `:key` | +| `:command` | `:command`, `:args` | + +VSCode commands aren't comprehensively documented, you'll have to discover their ids and arguments with some guesswork and research. + +## Recap of how to use Flares + +To start using flares in your Calva environment, follow these steps: + +1. Ensure you have the latest version of Calva installed. +2. Open your Clojure project in Calva. +3. Connect to your REPL. +4. Use the provided examples to experiment with flares from the REPL. +5. Create custom user actions that trigger flares. +6. Request toolmakers provide flare producing actions. + +Flares enhance our development experience in Calva. +Whether you're visualizing data or creating custom workflows, they open up more possibilities for interactive development. +Let us know how you’re using flares, and share your feedback to make this feature even better. diff --git a/mkdocs.yml b/mkdocs.yml index fc8688eff..5bd3ef1c8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -64,6 +64,7 @@ nav: - fiddle-files.md - connect-sequences.md - custom-commands.md + - flares.md - refactoring.md - notebooks.md - clojuredocs.md diff --git a/src/evaluate.ts b/src/evaluate.ts index 7806aa6f7..98f8015cc 100644 --- a/src/evaluate.ts +++ b/src/evaluate.ts @@ -19,6 +19,7 @@ import * as output from './results-output/output'; import * as inspector from './providers/inspector'; import { resultAsComment } from './util/string-result'; import { highlight } from './highlight/src/extension'; +import * as flareHandler from './flare-handler'; let inspectorDataProvider: inspector.InspectorDataProvider; @@ -139,6 +140,8 @@ async function evaluateCodeUpdatingUI( result = value; + flareHandler.inspect(value, (code) => evaluateCodeUpdatingUI(code, options, selection)); + if (showResult) { inspectorDataProvider.addItem(value, false, `[${session.replType}] ${ns}`); output.appendClojureEval(value, { ns, replSessionType: session.replType }, async () => { diff --git a/src/flare-handler.ts b/src/flare-handler.ts new file mode 100644 index 000000000..299539b04 --- /dev/null +++ b/src/flare-handler.ts @@ -0,0 +1,129 @@ +import * as vscode from 'vscode'; +import * as path from 'path'; +import * as webview from './webview'; +import { parseEdn } from '../out/cljs-lib/cljs-lib'; + +type EvaluateFunction = (code: string) => Promise; + +type InfoRequest = { type: 'info'; message: string; items?: string[]; then?: string }; +type WarnRequest = { type: 'warn'; message: string; items?: string[]; then?: string }; +type ErrorRequest = { type: 'error'; message: string; items?: string[]; then?: string }; +type WebviewRequest = { + type: 'webview'; + title?: string; + html?: string; + url?: string; + key?: string; + column?: vscode.ViewColumn; + opts?: any; + then?: string; +}; +type CommandRequest = { type: 'command'; command: string; args?: string[]; then?: string }; +type CommandsRequest = { type: 'commands'; then?: string }; +type DefaultRequest = { type: 'default'; [key: string]: any }; + +type ActRequest = + | InfoRequest + | WarnRequest + | ErrorRequest + | WebviewRequest + | CommandRequest + | CommandsRequest + | DefaultRequest; + +function callback(p, thensym: string, evaluate: EvaluateFunction): void { + if (thensym) { + p.then((x: any) => evaluate(`((resolve '${thensym}) ${JSON.stringify(x)})`)).error((e) => { + // TODO: I haven't seen this work yet, test it somehow + vscode.window.showErrorMessage('failed callback: ' + e); + console.log('OH NO', e); + }); + } +} + +function parseArg(arg: string) { + try { + // Try to parse JSON (handles numbers, booleans, arrays, objects) + return JSON.parse(arg); + } catch { + // If parsing fails, check if it's a valid file path and convert to URI + if (path.isAbsolute(arg) || arg.startsWith('./') || arg.startsWith('../')) { + return vscode.Uri.file(arg); + } + // Return the argument as a string if it's not a valid file path + return arg; + } +} + +const actHandlers: Record void> = { + default: (request: DefaultRequest, evaluate: EvaluateFunction) => { + void vscode.window.showErrorMessage( + `Unknown flare request type: ${JSON.stringify(request.type)}` + ); + }, + info: ({ message, items = [], then }: InfoRequest, evaluate: EvaluateFunction) => { + const p = vscode.window.showInformationMessage(message, ...items); + callback(p, then, evaluate); + }, + warn: ({ message, items = [], then }: WarnRequest, evaluate: EvaluateFunction) => { + const p = vscode.window.showWarningMessage(message, ...items); + callback(p, then, evaluate); + }, + error: ({ message, items = [], then }: ErrorRequest, evaluate: EvaluateFunction) => { + const p = vscode.window.showErrorMessage(message, ...items); + callback(p, then, evaluate); + }, + webview: ({ then, ...request }: WebviewRequest, evaluate: EvaluateFunction) => { + const p = webview.show(request); + // TODO: p here is a panel, not a promise, so then clause will fail + callback(p, then, evaluate); + }, + // TODO: handling commands sounds like fun, but there aren't actually many that are useful afaik, + // there are some that could be considered dangerous + // there are so many and they aren't documented anywhere afaik + command: ({ command, args = [], then }: CommandRequest, evaluate: EvaluateFunction) => { + // TODO: args might not be an array like we expect + const parsedArgs = (args || []).map(parseArg); + console.log('ARGS', parsedArgs); + const p = vscode.commands.executeCommand(command, ...parsedArgs); + callback(p, then, evaluate); + }, + commands: ({ then }: CommandsRequest, evaluate: EvaluateFunction) => { + const p = vscode.commands.getCommands(); + callback(p, then, evaluate); + }, +}; + +function act(request: ActRequest, evaluate: EvaluateFunction): void { + const handler = actHandlers[request.type] || actHandlers.default; + handler(request, evaluate); +} + +function isFlare(x: any): boolean { + return typeof x === 'object' && x !== null && 'calva/flare' in x; +} + +function getFlareRequest(flare: Record): any { + return Object.values(flare)[0]; +} + +export function inspect(edn: string, evaluate: EvaluateFunction): any { + console.log('INSPECT', edn); + if ( + edn && + typeof edn === 'string' && + (edn.startsWith('{:calva/flare') || edn.startsWith('#:calva{:flare')) + ) { + try { + const x = parseEdn(edn); + console.log('PARSED', x); + if (isFlare(x)) { + console.log('FLARE', x); + const request = getFlareRequest(x); + act(request, evaluate); + } + } catch (e) { + console.log('ERROR: inspect failed', e); + } + } +} diff --git a/src/webview.ts b/src/webview.ts new file mode 100644 index 000000000..c6d0ac2f8 --- /dev/null +++ b/src/webview.ts @@ -0,0 +1,75 @@ +import * as vscode from 'vscode'; + +const defaultOpts = { + enableScripts: true, +}; + +// keep track of open webviews that have a key, +// so that they can be updated +const webviewRegistry: Record = {}; + +function setHtml(panel: vscode.WebviewPanel, title: string, html: string): vscode.WebviewPanel { + if (panel.title !== title) { + panel.title = title; + } + if (panel.webview.html !== html) { + panel.webview.html = html; + } + panel.reveal(); + return panel; +} + +function urlInIframe(uri: string): string { + return ` + + + + + + + +`; +} + +export function show({ + title = 'Webview', + html, + url, + key, + column = vscode.ViewColumn.Beside, + opts = defaultOpts, +}: { + title?: string; + html?: string; + url?: string; + key?: string; + column?: vscode.ViewColumn; + opts?: typeof defaultOpts; +}): vscode.WebviewPanel { + const finalHtml = url ? urlInIframe(url) : html || ''; + if (key) { + const existingPanel = webviewRegistry[key]; + if (existingPanel) { + return setHtml(existingPanel, title, finalHtml); + } + } + + const panel = vscode.window.createWebviewPanel('calva-webview', title, column, opts); + setHtml(panel, title, finalHtml); + + if (key) { + webviewRegistry[key] = panel; + panel.onDidDispose(() => delete webviewRegistry[key]); + } + + return panel; +} + +// TODO: register a command for creating a webview, because why not?