Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ See more usage examples in [the docs directory](https://github.com/hilleer/vscod
- Convert a selection of YAML files to JSON by right clicking one of the selected files and selecting `Convert selected files to JSON`.
- Convert YAML files in a directory to JSON by right clicking the directory and selecting `Convert YAML files to JSON`.
- Convert JSON files in a directory to YAML by right clicking the directory and selecting `Convert JSON files to YAML`.

- **Interactive converter:** Open a persistent split-pane panel to convert arbitrary YAML/JSON content without needing a file on disk. Click the `⇄` icon in the editor toolbar or run `YAML+JSON: Open Interactive Converter` from the command palette. Pre-populates from the active editor, supports real-time conversion, Swap, Copy, and Save As.
- **Comment preservation (YAML to or from JSONC):**
- When converting between YAML and JSONC, comments are preserved by default (`preserveComments: true`).
- YAML `#` comments map to JSONC `//` comments and vice versa.
Expand All @@ -43,6 +45,12 @@ See more usage examples in [the docs directory](https://github.com/hilleer/vscod
- **Reverting converted files:** When a file has been reverted, a _"revert"_ prompt will be shown to revert it. Using this will return the entirety of the original file, including YAML comments.
- **Overwriting existent files:** When trying to convert a file into a destination that already exist, you can use the `overwriteExistentFiles` configuration to overwrite such. **Notice** if you use the revert feature after overwriting a file, the extension cannot (currently) revert the overwritten file. Also, due to limitation in vscode of active user prompts, if you set it to `"ask"` you will only be prompted to overwrite N number of files, while others will be skipped.

## Usage examples

**Converting files via the interactive tool:**

![demo_interactive.gif](https://raw.githubusercontent.com/hilleer/vscode-yaml-plus-json/main/docs/demo_interactive.gif)

## Config

All configurations should be defined in the `yaml-plus-json` object of your vscode settings (e.g. in a workspace file `.vscode/settings.json`). Example:
Expand Down
Binary file added docs/demo_interactive.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 14 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,11 @@
{
"command": "extension.previewAsJson",
"title": "YAML+JSON: Preview as JSON (from YAML. Opens in new file)"
},
{
"command": "extension.interactiveConverter",
"title": "YAML+JSON: Open Interactive Converter",
"icon": "$(arrow-swap)"
}
],
"menus": {
Expand Down Expand Up @@ -130,6 +135,15 @@
{
"command": "extension.previewAsJson",
"when": "editorLangId == 'yaml'"
},
{
"command": "extension.interactiveConverter"
}
],
"editor/title": [
{
"command": "extension.interactiveConverter",
"group": "navigation"
}
],
"explorer/context": [
Expand Down
2 changes: 2 additions & 0 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
import { onConvertSelection } from './onConvertSelection';
import { ConvertFromType } from './converter';
import { onPreviewSelection } from './onPreviewSelection';
import { onInteractiveConverter } from './onInteractiveConverter';
import { ConfigId, getConfig } from './config';

const { registerCommand, executeCommand } = vscode.commands;
Expand Down Expand Up @@ -49,6 +50,7 @@ export function activate(context: vscode.ExtensionContext) {
registerCommand('extension.convertYamlSelectionsToJson', onConvertSelectedYamlFilesToJson),
registerCommand('extension.previewAsYaml', onPreviewSelection(ConvertFromType.Json)),
registerCommand('extension.previewAsJson', onPreviewSelection(ConvertFromType.Yaml)),
registerCommand('extension.interactiveConverter', onInteractiveConverter),
);

vscode.workspace.onDidRenameFiles(onFileRename);
Expand Down
331 changes: 331 additions & 0 deletions src/onInteractiveConverter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,331 @@
import { contextProvider } from './contextProvider';
import { getYamlFromJson, getJsonFromYaml } from './helpers';

type Direction = 'json-to-yaml' | 'yaml-to-json';

let panel: import('vscode').WebviewPanel | undefined;

/** Generates a random one-time token for the CSP header and matching script/style tags,
* so the browser only executes inline content from this render — not anything injected by a third party. */
function generateNonce(): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < 32; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}

function detectDirection(input: string): Direction {
const trimmed = input.trim();
try {
JSON.parse(trimmed);
return 'json-to-yaml';
} catch {
return 'yaml-to-json';
}
}

function getEditorContent(editor: import('vscode').TextEditor): { input: string; direction: Direction } | null {
const langId = editor.document.languageId;
if (langId === 'json' || langId === 'jsonc') {
return { input: editor.document.getText(), direction: 'json-to-yaml' };
}
if (langId === 'yaml') {
return { input: editor.document.getText(), direction: 'yaml-to-json' };
}
const text = editor.document.getText();
if (text) {
return { input: text, direction: detectDirection(text) };
}
return null;
}

export function onInteractiveConverter(): void {
const vscode = contextProvider.vscode;

// Read editor before reveal() so we capture it while the file is still focused
const editorContent = vscode.window.activeTextEditor ? getEditorContent(vscode.window.activeTextEditor) : null;

if (panel) {
panel.reveal();
if (editorContent) {
panel.webview.postMessage({ type: 'init', input: editorContent.input, direction: editorContent.direction });
}
return;
}

const initialInput = editorContent?.input ?? '';
const initialDirection: Direction = editorContent?.direction ?? 'json-to-yaml';

panel = vscode.window.createWebviewPanel('yamlPlusJsonConverter', 'YAML ↔ JSON Converter', vscode.ViewColumn.Beside, {
enableScripts: true,
retainContextWhenHidden: true,
});

panel.webview.html = getWebviewHtml();

panel.webview.onDidReceiveMessage(
async (msg: { type: string; input?: string; direction?: Direction; text?: string }) => {
if (!panel) return;

switch (msg.type) {
case 'ready':
panel.webview.postMessage({ type: 'init', input: initialInput, direction: initialDirection });
break;

case 'convert': {
const { input = '', direction = 'json-to-yaml' } = msg;
try {
const output = direction === 'json-to-yaml' ? getYamlFromJson(input) : getJsonFromYaml(input);
panel.webview.postMessage({ type: 'result', output });
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Conversion failed.';
panel.webview.postMessage({ type: 'error', message: errorMessage });
}
break;
}

case 'copy':
if (msg.text !== undefined) {
void contextProvider.vscode.env.clipboard.writeText(msg.text);
}
break;

case 'saveAs': {
if (msg.text === undefined) break;
const isJsonToYaml = msg.direction === 'json-to-yaml';
const filename = isJsonToYaml ? 'output.yaml' : 'output.json';
const workspaceUri = contextProvider.vscode.workspace.workspaceFolders?.[0]?.uri;
const uri = await contextProvider.vscode.window.showSaveDialog({
...(workspaceUri ? { defaultUri: contextProvider.vscode.Uri.joinPath(workspaceUri, filename) } : {}),
filters: isJsonToYaml ? { 'YAML files': ['yaml', 'yml'] } : { 'JSON files': ['json', 'jsonc'] },
});
if (uri) {
await contextProvider.vscode.workspace.fs.writeFile(uri, new TextEncoder().encode(msg.text));
}
break;
}
}
},
);

panel.onDidDispose(() => {
panel = undefined;
});
}

function getWebviewHtml(): string {
const nonce = generateNonce();
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'nonce-${nonce}'; script-src 'nonce-${nonce}';" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>YAML &#8596; JSON Converter</title>
<style nonce="${nonce}">
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
display: flex;
flex-direction: column;
height: 100vh;
background: var(--vscode-editor-background);
color: var(--vscode-editor-foreground);
font-family: var(--vscode-editor-font-family, monospace);
font-size: var(--vscode-editor-font-size, 13px);
}
.toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: var(--vscode-editorGroupHeader-tabsBackground, var(--vscode-editor-background));
border-bottom: 1px solid var(--vscode-editorGroup-border, var(--vscode-panel-border));
flex-shrink: 0;
}
.direction-label {
flex: 1;
font-weight: bold;
font-size: 13px;
}
button {
background: var(--vscode-button-background);
color: var(--vscode-button-foreground);
border: none;
border-radius: 2px;
padding: 4px 10px;
cursor: pointer;
font-size: 12px;
font-family: inherit;
}
button:hover {
background: var(--vscode-button-hoverBackground);
}
button.secondary {
background: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
}
button.secondary:hover {
background: var(--vscode-button-secondaryHoverBackground);
}
.panes {
display: flex;
flex: 1;
overflow: hidden;
}
.pane {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
min-width: 0;
}
.pane + .pane {
border-left: 1px solid var(--vscode-editorGroup-border, var(--vscode-panel-border));
}
.pane-header {
display: flex;
align-items: center;
padding: 4px 12px;
background: var(--vscode-editorGroupHeader-tabsBackground, var(--vscode-editor-background));
border-bottom: 1px solid var(--vscode-editorGroup-border, var(--vscode-panel-border));
font-size: 11px;
color: var(--vscode-tab-inactiveForeground, var(--vscode-editor-foreground));
gap: 8px;
flex-shrink: 0;
}
.pane-header span { flex: 1; }
textarea {
flex: 1;
background: var(--vscode-editor-background);
color: var(--vscode-editor-foreground);
border: none;
outline: none;
resize: none;
padding: 12px;
font-family: var(--vscode-editor-font-family, monospace);
font-size: var(--vscode-editor-font-size, 13px);
line-height: 1.5;
width: 100%;
}
.output-content {
flex: 1;
overflow: auto;
padding: 12px;
font-family: var(--vscode-editor-font-family, monospace);
font-size: var(--vscode-editor-font-size, 13px);
line-height: 1.5;
white-space: pre-wrap;
word-break: break-all;
}
.output-content.error {
color: var(--vscode-inputValidation-errorForeground, #f44747);
background: var(--vscode-inputValidation-errorBackground, rgba(244, 71, 71, 0.1));
border-left: 3px solid var(--vscode-inputValidation-errorBorder, #f44747);
}
</style>
</head>
<body>
<div class="toolbar">
<span class="direction-label" id="dirLabel">JSON &#8594; YAML</span>
<button id="swapBtn">&#8644; Swap</button>
<button class="secondary" id="clearBtn">Clear</button>
</div>
<div class="panes">
<div class="pane">
<div class="pane-header"><span id="inputLabel">Input (JSON)</span></div>
<textarea id="input" spellcheck="false" placeholder="Paste or type content here..."></textarea>
</div>
<div class="pane">
<div class="pane-header">
<span id="outputLabel">Output (YAML)</span>
<button id="saveAsBtn">Save As</button>
<button id="copyBtn">Copy</button>
</div>
<div class="output-content" id="output"></div>
</div>
</div>
<script nonce="${nonce}">
const vscode = acquireVsCodeApi();
let direction = 'json-to-yaml';
let debounceTimer = null;
let lastOutput = '';

function updateLabels() {
const isJsonToYaml = direction === 'json-to-yaml';
document.getElementById('dirLabel').textContent = isJsonToYaml ? 'JSON \u2192 YAML' : 'YAML \u2192 JSON';
document.getElementById('inputLabel').textContent = isJsonToYaml ? 'Input (JSON)' : 'Input (YAML)';
document.getElementById('outputLabel').textContent = isJsonToYaml ? 'Output (YAML)' : 'Output (JSON)';
}

function requestConvert() {
const input = document.getElementById('input').value;
vscode.postMessage({ type: 'convert', input, direction });
}

function scheduleConvert() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(requestConvert, 300);
}

document.getElementById('input').addEventListener('input', scheduleConvert);

document.getElementById('swapBtn').addEventListener('click', () => {
direction = direction === 'json-to-yaml' ? 'yaml-to-json' : 'json-to-yaml';
document.getElementById('input').value = lastOutput;
lastOutput = '';
const outputEl = document.getElementById('output');
outputEl.textContent = '';
outputEl.classList.remove('error');
updateLabels();
requestConvert();
});

document.getElementById('clearBtn').addEventListener('click', () => {
document.getElementById('input').value = '';
lastOutput = '';
const outputEl = document.getElementById('output');
outputEl.textContent = '';
outputEl.classList.remove('error');
});

document.getElementById('saveAsBtn').addEventListener('click', () => {
vscode.postMessage({ type: 'saveAs', text: lastOutput, direction });
});

document.getElementById('copyBtn').addEventListener('click', () => {
vscode.postMessage({ type: 'copy', text: lastOutput });
});

window.addEventListener('message', (event) => {
const msg = event.data;
switch (msg.type) {
case 'init':
direction = msg.direction || 'json-to-yaml';
document.getElementById('input').value = msg.input || '';
updateLabels();
if (msg.input) requestConvert();
break;
case 'result': {
lastOutput = msg.output || '';
const outputEl = document.getElementById('output');
outputEl.textContent = lastOutput;
outputEl.classList.remove('error');
break;
}
case 'error': {
lastOutput = '';
const outputEl = document.getElementById('output');
outputEl.textContent = msg.message;
outputEl.classList.add('error');
break;
}
}
});

vscode.postMessage({ type: 'ready' });
</script>
</body>
</html>`;
}