jsonl-editor
Version:
Edit individual JSON lines in JSONL files
491 lines (418 loc) • 14.7 kB
text/typescript
import * as vscode from 'vscode';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
interface EditTarget {
body: string;
lineNumber: number;
}
interface TempFileInfo {
filePath: string;
originalUri: vscode.Uri;
lineNumber: number;
}
// Temporary file management class
class TempFileManager {
private _tempFiles = new Map<string, TempFileInfo>();
private _disposables: vscode.Disposable[] = [];
constructor() {
// Register event listener when editor is closed
this._disposables.push(
vscode.workspace.onDidCloseTextDocument(document => {
const filePath = document.uri.fsPath;
if (this._tempFiles.has(filePath)) {
this.deleteTempFile(filePath).catch(err => {
console.error(`Failed to delete temporary file: ${err}`);
});
}
})
);
// Register event listener when file is saved
this._disposables.push(
vscode.workspace.onDidSaveTextDocument(async document => {
const filePath = document.uri.fsPath;
if (this._tempFiles.has(filePath)) {
await this.saveToOriginalDocument(document).catch(err => {
vscode.window.showErrorMessage(`Failed to save to original document: ${err}`);
});
}
})
);
}
// Save the temporary file content to the original document
public async saveToOriginalDocument(document: vscode.TextDocument): Promise<void> {
const filePath = document.uri.fsPath;
const tempFileInfo = this._tempFiles.get(filePath);
if (!tempFileInfo) {
return;
}
let jsonContent = document.getText();
const originalDocument = await vscode.workspace.openTextDocument(tempFileInfo.originalUri);
const originalEditor = await vscode.window.showTextDocument(originalDocument);
try {
// Validate and minify JSON
try {
const parsed = JSON.parse(jsonContent.trim());
jsonContent = JSON.stringify(parsed);
} catch (e) {
vscode.window.showErrorMessage('Invalid JSON format');
return;
}
// Replace the specific line in JSONL file
await originalEditor.edit((editBuilder: vscode.TextEditorEdit) => {
const line = originalDocument.lineAt(tempFileInfo.lineNumber);
editBuilder.replace(line.range, jsonContent);
});
const tabs = vscode.window.tabGroups.all
.flatMap(group => group.tabs)
.filter(tab => tab.input instanceof vscode.TabInputText &&
tab.input.uri.fsPath === document.uri.fsPath);
await vscode.window.tabGroups.close(tabs);
await this.deleteTempFile(filePath);
} catch (error) {
if (error instanceof Error) {
vscode.window.showErrorMessage(`Error: ${error.message}`);
} else {
vscode.window.showErrorMessage('An unknown error occurred');
}
throw error;
}
}
public async createTempFile(content: string, originalUri: vscode.Uri, lineNumber: number): Promise<string> {
const tempDir = os.tmpdir();
const timestamp = Date.now();
const fileName = `jsonl_line_${timestamp}.json`;
const filePath = path.join(tempDir, fileName);
await fs.promises.writeFile(filePath, content, 'utf8');
this._tempFiles.set(filePath, {
filePath,
originalUri,
lineNumber,
});
return filePath;
}
public getTempFileInfo(filePath: string): TempFileInfo | undefined {
return this._tempFiles.get(filePath);
}
public async deleteTempFile(filePath: string): Promise<void> {
try {
await fs.promises.unlink(filePath);
this._tempFiles.delete(filePath);
} catch (error) {
console.error(`Failed to delete temporary file: ${error}`);
}
}
public async deleteAllTempFiles(): Promise<void> {
for (const [filePath] of this._tempFiles) {
await this.deleteTempFile(filePath);
}
}
// Release resources
public dispose(): void {
this._disposables.forEach(d => d.dispose());
this.deleteAllTempFiles().catch(err => {
console.error(`Failed to delete all temporary files: ${err}`);
});
}
}
function getJsonlLineTarget(editor: vscode.TextEditor): EditTarget | null {
const document = editor.document;
const selection = editor.selection;
const lineNumber = selection.start.line;
// Get the current line
const line = document.lineAt(lineNumber);
const lineText = line.text.trim();
if (!lineText) {
return null;
}
// Try to parse the line as JSON
try {
JSON.parse(lineText);
return {
body: lineText,
lineNumber: lineNumber
};
} catch (e) {
// Not valid JSON
return null;
}
}
// Preview panel management
class JsonlPreviewPanel {
private static currentPanel: JsonlPreviewPanel | undefined;
private readonly _panel: vscode.WebviewPanel;
private _disposables: vscode.Disposable[] = [];
private _currentEditor: vscode.TextEditor | undefined;
private _currentLine: number = 0;
private _isManualNavigation: boolean = false;
private _htmlTemplate: string | undefined;
public static createOrShow(extensionUri: vscode.Uri, editor?: vscode.TextEditor) {
const column = editor
? editor.viewColumn
: undefined;
// If we already have a panel, show it.
if (JsonlPreviewPanel.currentPanel) {
JsonlPreviewPanel.currentPanel._panel.reveal(column);
// Update editor if provided
if (editor) {
JsonlPreviewPanel.currentPanel._currentEditor = editor;
JsonlPreviewPanel.currentPanel._currentLine = editor.selection.start.line;
JsonlPreviewPanel.currentPanel._update();
}
return JsonlPreviewPanel.currentPanel;
}
// Otherwise, create a new panel.
const panel = vscode.window.createWebviewPanel(
'jsonlPreview',
'JSONL Preview',
vscode.ViewColumn.Two,
{
enableScripts: true,
retainContextWhenHidden: true
}
);
JsonlPreviewPanel.currentPanel = new JsonlPreviewPanel(panel, extensionUri, editor);
return JsonlPreviewPanel.currentPanel;
}
private constructor(panel: vscode.WebviewPanel, extensionUri: vscode.Uri, editor?: vscode.TextEditor) {
this._panel = panel;
// Load HTML template
const templatePath = path.join(__dirname, 'preview-template.html');
try {
this._htmlTemplate = fs.readFileSync(templatePath, 'utf8');
} catch (e) {
console.error('Failed to load preview template:', e);
}
// Initialize with provided editor or current editor
if (editor) {
this._currentEditor = editor;
this._currentLine = editor.selection.start.line;
} else if (vscode.window.activeTextEditor && vscode.window.activeTextEditor.document.uri.path.endsWith('.jsonl')) {
this._currentEditor = vscode.window.activeTextEditor;
this._currentLine = this._currentEditor.selection.start.line;
}
// Set the webview's initial html content
this._update();
// Listen for when the panel is disposed
this._panel.onDidDispose(() => this.dispose(), null, this._disposables);
// Update the content based on view changes
this._panel.onDidChangeViewState(
() => {
if (this._panel.visible) {
this._update();
}
},
null,
this._disposables
);
// Handle cursor position changes
vscode.window.onDidChangeTextEditorSelection(
e => {
if (e.textEditor.document.uri.path.endsWith('.jsonl') && !this._isManualNavigation) {
this._currentEditor = e.textEditor;
this._currentLine = e.textEditor.selection.start.line;
this._update();
}
},
null,
this._disposables
);
// Handle active editor changes
vscode.window.onDidChangeActiveTextEditor(
editor => {
if (editor && editor.document.uri.path.endsWith('.jsonl')) {
this._currentEditor = editor;
this._update();
}
},
null,
this._disposables
);
// Handle messages from the webview
this._panel.webview.onDidReceiveMessage(
message => {
switch (message.command) {
case 'goToLine':
this._goToLine(message.line);
return;
case 'navigate':
this._navigate(message.direction);
return;
}
},
null,
this._disposables
);
}
public dispose() {
JsonlPreviewPanel.currentPanel = undefined;
// Clean up our resources
this._panel.dispose();
while (this._disposables.length) {
const x = this._disposables.pop();
if (x) {
x.dispose();
}
}
}
private _goToLine(lineNumber: number) {
if (!this._currentEditor) {
return;
}
const document = this._currentEditor.document;
if (lineNumber < 1 || lineNumber > document.lineCount) {
vscode.window.showErrorMessage(`Line ${lineNumber} is out of range (1-${document.lineCount})`);
return;
}
this._isManualNavigation = true;
this._currentLine = lineNumber - 1; // Convert to 0-based
this._update();
// Reset manual navigation flag after a short delay
setTimeout(() => {
this._isManualNavigation = false;
}, 500);
}
private _navigate(direction: 'prev' | 'next') {
if (!this._currentEditor) {
return;
}
const document = this._currentEditor.document;
if (direction === 'prev' && this._currentLine > 0) {
this._currentLine--;
} else if (direction === 'next' && this._currentLine < document.lineCount - 1) {
this._currentLine++;
}
this._isManualNavigation = true;
this._update();
// Reset manual navigation flag after a short delay
setTimeout(() => {
this._isManualNavigation = false;
}, 500);
}
private _update() {
const webview = this._panel.webview;
this._panel.title = 'JSONL Preview';
this._panel.webview.html = this._getHtmlForWebview(webview);
}
private _getHtmlForWebview(_webview: vscode.Webview) {
let jsonContent = '';
let lineNumber = 0;
let totalLines = 0;
if (this._currentEditor) {
const document = this._currentEditor.document;
totalLines = document.lineCount;
// Ensure current line is within bounds
if (this._currentLine >= totalLines) {
this._currentLine = totalLines - 1;
}
if (this._currentLine < 0) {
this._currentLine = 0;
}
lineNumber = this._currentLine + 1; // Convert to 1-based line number
// Get the line content
const line = document.lineAt(this._currentLine);
const lineText = line.text.trim();
if (lineText) {
try {
const parsed = JSON.parse(lineText);
jsonContent = JSON.stringify(parsed, null, 2);
} catch (e) {
jsonContent = 'Invalid JSON on line ' + lineNumber;
}
} else {
jsonContent = 'Empty line';
}
} else {
jsonContent = 'No JSONL file is active';
}
// If template is not loaded, return fallback HTML
if (!this._htmlTemplate) {
return '<html><body><h1>Error: Template not loaded</h1></body></html>';
}
// Prepare content based on JSON validity
let content = '';
if (jsonContent.startsWith('Invalid') || jsonContent.startsWith('No') || jsonContent === 'Empty line') {
content = `<div class="error">${this._escapeHtml(jsonContent)}</div>`;
} else {
content = `<pre><code class="language-json">${this._escapeHtml(jsonContent)}</code></pre>`;
}
// Replace placeholders in template
let html = this._htmlTemplate;
html = html.replace(/{{LINE_NUMBER}}/g, lineNumber.toString());
html = html.replace(/{{TOTAL_LINES}}/g, totalLines.toString());
html = html.replace('{{PREV_DISABLED}}', lineNumber <= 1 ? 'disabled' : '');
html = html.replace('{{NEXT_DISABLED}}', lineNumber >= totalLines ? 'disabled' : '');
html = html.replace('{{CONTENT}}', content);
return html;
}
private _escapeHtml(text: string): string {
const map: { [key: string]: string } = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
};
return text.replace(/[&<>"']/g, m => map[m]);
}
}
export function activate(context: vscode.ExtensionContext) {
const tempFileManager = new TempFileManager();
context.subscriptions.push({ dispose: () => tempFileManager.dispose() });
const editJsonlLineCommand = vscode.commands.registerCommand('jsonl-editor.editJsonlLine', async () => {
const editor = vscode.window.activeTextEditor;
if (!editor) {
vscode.window.showErrorMessage('No active editor');
return;
}
const target = getJsonlLineTarget(editor);
if (!target) {
vscode.window.showErrorMessage('No valid JSON found on the current line.');
return;
}
try {
let jsonContent: string;
try {
// Parse and format the JSON line
const parsed = JSON.parse(target.body);
jsonContent = JSON.stringify(parsed, null, 2);
} catch (e) {
if (e instanceof Error) {
vscode.window.showErrorMessage(`Invalid JSON: ${e.message}`);
} else {
vscode.window.showErrorMessage('Invalid JSON format');
}
return;
}
const tempFilePath = await tempFileManager.createTempFile(
jsonContent,
editor.document.uri,
target.lineNumber,
);
const document = await vscode.workspace.openTextDocument(vscode.Uri.file(tempFilePath));
await vscode.window.showTextDocument(document, { preview: false });
vscode.languages.setTextDocumentLanguage(document, 'json');
vscode.window.showInformationMessage('JSONL line opened as JSON. Edit and save (Ctrl+S) to update the original line.');
} catch (error) {
if (error instanceof Error) {
vscode.window.showErrorMessage(`Error: ${error.message} ${error.stack}`);
} else {
vscode.window.showErrorMessage('An unknown error occurred');
}
}
});
const previewJsonlCommand = vscode.commands.registerCommand('jsonl-editor.previewJsonl', () => {
const editor = vscode.window.activeTextEditor;
if (!editor || !editor.document.uri.path.endsWith('.jsonl')) {
vscode.window.showErrorMessage('Please open a JSONL file first');
return;
}
JsonlPreviewPanel.createOrShow(context.extensionUri, editor);
});
context.subscriptions.push(editJsonlLineCommand);
context.subscriptions.push(previewJsonlCommand);
}
export function deactivate() {
// Processing when the extension becomes inactive
// TempFileManager's dispose is automatically called because it's registered in context.subscriptions
}