Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add initial idea for flares #2680

Draft
wants to merge 10 commits into
base: dev
Choose a base branch
from
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
129 changes: 129 additions & 0 deletions docs/site/flares.md
Original file line number Diff line number Diff line change
@@ -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 "<h1>Hello, Calva!</h1>",
: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.
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ nav:
- fiddle-files.md
- connect-sequences.md
- custom-commands.md
- flares.md
- refactoring.md
- notebooks.md
- clojuredocs.md
Expand Down
3 changes: 3 additions & 0 deletions src/evaluate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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 () => {
Expand Down
129 changes: 129 additions & 0 deletions src/flare-handler.ts
Original file line number Diff line number Diff line change
@@ -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<string | null>;

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<string, (request: ActRequest, EvaluateFunction) => 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<string, any>): 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);
}
}
}
75 changes: 75 additions & 0 deletions src/webview.ts
Original file line number Diff line number Diff line change
@@ -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<string, vscode.WebviewPanel> = {};

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 `<!DOCTYPE html>
<html>
<head>
<style type="text/css">
body, html {
margin: 0; padding: 0; height: 100%; overflow: hidden;
}
#content {
position: absolute; left: 0; right: 0; bottom: 0; top: 0px;
}
</style>
</head>
<body>
<iframe src="${uri}" style="width:100%; height:100%; border:none;"></iframe>
</body>
</html>`;
}

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?