Add metadata panel for front matter editing in virtual workspaces

Co-authored-by: estruyf <2900833+estruyf@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-01-07 16:27:42 +00:00
parent f9f69ed542
commit eece580b35
5 changed files with 549 additions and 1 deletions

View File

@@ -5,6 +5,11 @@ All notable changes to the Front Matter Lite extension will be documented in thi
## [Unreleased]
### Added
- **Metadata Panel** - Edit front matter fields directly in the sidebar panel
- View and edit all front matter fields for the current markdown file
- Support for text, textarea, date, and array fields (tags/categories)
- Auto-save changes to the file
- Refresh button to reload metadata
- Initial release of Front Matter Lite for virtual workspaces
- Dashboard webview with folder and file listing
- Register content folders via context menu
@@ -19,6 +24,7 @@ All notable changes to the Front Matter Lite extension will be documented in thi
### Features
- ✅ Register content folders
- ✅ Create new markdown files with front matter
-**Edit front matter metadata in panel**
- ✅ View registered folders
- ✅ List content files
- ✅ Open files from dashboard

View File

@@ -16,6 +16,7 @@ The lite version provides core content management functionality:
### ✅ Supported Features
- **Metadata Panel** - View and edit front matter for the currently open markdown file
- **Register Content Folders** - Right-click on folders in the Explorer to register them as content folders
- **Create Content** - Create new markdown files with front matter
- **View Configuration** - Manage your content folder settings
@@ -24,7 +25,7 @@ The lite version provides core content management functionality:
The following features from the full extension are not available in the lite version due to virtual workspace limitations:
- **Dashboard** - Full dashboard UI (under development)
- **Dashboard** - Full dashboard UI (basic version available)
- **Media Management** - File upload and media library
- **Local Server Preview** - Starting/stopping local dev servers
- **Git Integration** - Advanced git operations
@@ -40,6 +41,18 @@ The following features from the full extension are not available in the lite ver
## Usage
### Edit Front Matter Metadata
1. Open a markdown file in the editor
2. The **Metadata** panel in the Front Matter Lite sidebar shows all front matter fields
3. Edit fields directly in the panel:
- **Title** - Edit the page title
- **Description** - Edit the description (multiline)
- **Date** - Use the date picker to set publish date
- **Tags/Categories** - Add or remove tags by typing and pressing Enter
- **Other fields** - Edit any custom front matter fields
4. Changes are saved automatically to the file
### Register a Content Folder
1. In the Explorer, right-click on any folder

View File

@@ -51,6 +51,11 @@
},
"views": {
"frontmatter-lite": [
{
"type": "webview",
"id": "frontMatterLite.panel",
"name": "Metadata"
},
{
"type": "webview",
"id": "frontMatterLite.dashboard",

514
lite/src/PanelProvider.ts Normal file
View File

@@ -0,0 +1,514 @@
import * as vscode from 'vscode';
/**
* Panel provider for editing front matter metadata of the current file
*/
export class PanelProvider implements vscode.WebviewViewProvider {
public static readonly viewType = 'frontMatterLite.panel';
private _view?: vscode.WebviewView;
private _outputChannel: vscode.OutputChannel;
private _currentFileUri?: vscode.Uri;
constructor(
private readonly _extensionUri: vscode.Uri,
outputChannel: vscode.OutputChannel
) {
this._outputChannel = outputChannel;
}
public resolveWebviewView(
webviewView: vscode.WebviewView,
context: vscode.WebviewViewResolveContext,
_token: vscode.CancellationToken
) {
this._view = webviewView;
webviewView.webview.options = {
enableScripts: true,
localResourceRoots: [this._extensionUri]
};
webviewView.webview.html = this._getHtmlForWebview(webviewView.webview);
// Handle messages from the webview
webviewView.webview.onDidReceiveMessage(async (data) => {
switch (data.type) {
case 'updateField': {
await this._updateFrontMatterField(data.field, data.value);
break;
}
case 'refresh': {
await this._loadCurrentFile();
break;
}
}
});
// Listen for active editor changes
vscode.window.onDidChangeActiveTextEditor(() => {
this._loadCurrentFile();
});
// Initial load
this._loadCurrentFile();
}
private async _loadCurrentFile() {
if (!this._view) {
return;
}
const editor = vscode.window.activeTextEditor;
if (!editor) {
this._view.webview.postMessage({
type: 'noFile'
});
return;
}
const doc = editor.document;
const fileName = doc.uri.path.split('/').pop() || '';
// Only process markdown files
if (!fileName.match(/\.(md|mdx|markdown)$/i)) {
this._view.webview.postMessage({
type: 'notMarkdown'
});
return;
}
this._currentFileUri = doc.uri;
try {
const content = doc.getText();
const frontMatter = this._parseFrontMatter(content);
this._view.webview.postMessage({
type: 'fileLoaded',
fileName,
frontMatter
});
} catch (error) {
this._outputChannel.appendLine(`Error loading file: ${error}`);
this._view.webview.postMessage({
type: 'error',
message: error instanceof Error ? error.message : 'Unknown error'
});
}
}
private _parseFrontMatter(content: string): Record<string, any> {
const frontMatterRegex = /^---\s*\n([\s\S]*?)\n---/;
const match = content.match(frontMatterRegex);
if (!match) {
return {};
}
const frontMatterText = match[1];
const frontMatter: Record<string, any> = {};
// Simple YAML parser (for basic key: value pairs)
const lines = frontMatterText.split('\n');
for (const line of lines) {
const colonIndex = line.indexOf(':');
if (colonIndex === -1) continue;
const key = line.substring(0, colonIndex).trim();
let valueStr = line.substring(colonIndex + 1).trim();
// Handle arrays
if (valueStr.startsWith('[') && valueStr.endsWith(']')) {
frontMatter[key] = valueStr.substring(1, valueStr.length - 1)
.split(',')
.map(v => v.trim().replace(/^['"]|['"]$/g, ''));
} else {
// Remove quotes
frontMatter[key] = valueStr.replace(/^['"]|['"]$/g, '');
}
}
return frontMatter;
}
private async _updateFrontMatterField(field: string, value: any) {
if (!this._currentFileUri) {
return;
}
try {
const doc = await vscode.workspace.openTextDocument(this._currentFileUri);
const content = doc.getText();
const frontMatterRegex = /^---\s*\n([\s\S]*?)\n---/;
const match = content.match(frontMatterRegex);
if (!match) {
vscode.window.showErrorMessage('No front matter found in file');
return;
}
const frontMatterText = match[1];
const lines = frontMatterText.split('\n');
let updated = false;
// Update the field
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const colonIndex = line.indexOf(':');
if (colonIndex === -1) continue;
const key = line.substring(0, colonIndex).trim();
if (key === field) {
// Format the value
let formattedValue: string;
if (Array.isArray(value)) {
formattedValue = `[${value.map(v => `"${v}"`).join(', ')}]`;
} else if (typeof value === 'string') {
formattedValue = value;
} else {
formattedValue = String(value);
}
lines[i] = `${key}: ${formattedValue}`;
updated = true;
break;
}
}
if (!updated) {
// Field doesn't exist, add it
let formattedValue: string;
if (Array.isArray(value)) {
formattedValue = `[${value.map(v => `"${v}"`).join(', ')}]`;
} else if (typeof value === 'string') {
formattedValue = value;
} else {
formattedValue = String(value);
}
lines.push(`${field}: ${formattedValue}`);
}
const newFrontMatter = lines.join('\n');
const newContent = content.replace(frontMatterRegex, `---\n${newFrontMatter}\n---`);
// Write the updated content
const edit = new vscode.WorkspaceEdit();
edit.replace(
this._currentFileUri,
new vscode.Range(0, 0, doc.lineCount, 0),
newContent
);
await vscode.workspace.applyEdit(edit);
// Reload to show updated values
await this._loadCurrentFile();
this._outputChannel.appendLine(`Updated field "${field}" with value: ${value}`);
} catch (error) {
const errorMsg = `Error updating front matter: ${error instanceof Error ? error.message : 'Unknown error'}`;
vscode.window.showErrorMessage(errorMsg);
this._outputChannel.appendLine(errorMsg);
}
}
private _getHtmlForWebview(webview: vscode.Webview) {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Front Matter Panel</title>
<style>
body {
padding: 10px;
color: var(--vscode-foreground);
font-family: var(--vscode-font-family);
font-size: var(--vscode-font-size);
}
.header {
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 1px solid var(--vscode-panel-border);
}
h2 {
font-size: 14px;
margin: 0 0 5px 0;
font-weight: 600;
}
.file-name {
font-size: 12px;
color: var(--vscode-descriptionForeground);
}
.field-group {
margin-bottom: 15px;
}
label {
display: block;
font-size: 12px;
font-weight: 500;
margin-bottom: 4px;
color: var(--vscode-foreground);
}
input[type="text"],
input[type="datetime-local"],
textarea {
width: 100%;
background: var(--vscode-input-background);
color: var(--vscode-input-foreground);
border: 1px solid var(--vscode-input-border);
padding: 6px 8px;
font-family: var(--vscode-font-family);
font-size: var(--vscode-font-size);
box-sizing: border-box;
}
input[type="text"]:focus,
input[type="datetime-local"]:focus,
textarea:focus {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: -1px;
}
textarea {
resize: vertical;
min-height: 60px;
}
.tags-input {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 4px;
background: var(--vscode-input-background);
border: 1px solid var(--vscode-input-border);
min-height: 32px;
}
.tag {
background: var(--vscode-badge-background);
color: var(--vscode-badge-foreground);
padding: 2px 8px;
border-radius: 2px;
font-size: 11px;
display: flex;
align-items: center;
gap: 4px;
}
.tag-remove {
cursor: pointer;
font-weight: bold;
}
.tag-input {
border: none;
background: transparent;
color: var(--vscode-input-foreground);
flex: 1;
min-width: 100px;
padding: 4px;
outline: none;
}
.empty-state {
text-align: center;
padding: 40px 20px;
color: var(--vscode-descriptionForeground);
}
.empty-state-icon {
font-size: 48px;
margin-bottom: 10px;
}
button {
background: var(--vscode-button-background);
color: var(--vscode-button-foreground);
border: none;
padding: 6px 12px;
cursor: pointer;
font-size: 12px;
margin-top: 10px;
}
button:hover {
background: var(--vscode-button-hoverBackground);
}
</style>
</head>
<body>
<div id="content">
<div class="empty-state">
<div class="empty-state-icon">📄</div>
<p>Open a markdown file to edit its front matter</p>
</div>
</div>
<script>
const vscode = acquireVsCodeApi();
let currentFrontMatter = {};
let currentFileName = '';
window.addEventListener('message', event => {
const message = event.data;
switch (message.type) {
case 'fileLoaded': {
currentFrontMatter = message.frontMatter;
currentFileName = message.fileName;
renderFrontMatter();
break;
}
case 'noFile': {
renderEmptyState('No file open');
break;
}
case 'notMarkdown': {
renderEmptyState('Not a markdown file');
break;
}
case 'error': {
renderEmptyState(\`Error: \${message.message}\`);
break;
}
}
});
function renderEmptyState(message) {
const contentDiv = document.getElementById('content');
contentDiv.innerHTML = \`
<div class="empty-state">
<div class="empty-state-icon">📄</div>
<p>\${message}</p>
</div>
\`;
}
function renderFrontMatter() {
const contentDiv = document.getElementById('content');
let html = \`
<div class="header">
<h2>Front Matter</h2>
<div class="file-name">\${currentFileName}</div>
</div>
\`;
// Render common fields
const commonFields = ['title', 'description', 'date', 'tags', 'categories', 'draft'];
for (const field of commonFields) {
const value = currentFrontMatter[field];
if (value !== undefined) {
html += renderField(field, value);
}
}
// Render other fields
for (const [field, value] of Object.entries(currentFrontMatter)) {
if (!commonFields.includes(field)) {
html += renderField(field, value);
}
}
html += \`<button onclick="refreshPanel()">Refresh</button>\`;
contentDiv.innerHTML = html;
// Add event listeners
addFieldListeners();
}
function renderField(field, value) {
const fieldId = \`field-\${field}\`;
if (Array.isArray(value)) {
return \`
<div class="field-group">
<label>\${capitalizeFirst(field)}</label>
<div class="tags-input" id="\${fieldId}">
\${value.map(tag => \`<span class="tag">\${tag} <span class="tag-remove" onclick="removeTag('\${field}', '\${tag}')">×</span></span>\`).join('')}
<input type="text" class="tag-input" placeholder="Add \${field}..." onkeydown="handleTagInput(event, '\${field}')">
</div>
</div>
\`;
} else if (field === 'description') {
return \`
<div class="field-group">
<label>\${capitalizeFirst(field)}</label>
<textarea id="\${fieldId}" data-field="\${field}">\${value || ''}</textarea>
</div>
\`;
} else if (field === 'date') {
// Try to format date for datetime-local input
let dateValue = value;
if (value) {
try {
const d = new Date(value);
dateValue = d.toISOString().slice(0, 16);
} catch (e) {
dateValue = value;
}
}
return \`
<div class="field-group">
<label>\${capitalizeFirst(field)}</label>
<input type="datetime-local" id="\${fieldId}" data-field="\${field}" value="\${dateValue || ''}">
</div>
\`;
} else {
return \`
<div class="field-group">
<label>\${capitalizeFirst(field)}</label>
<input type="text" id="\${fieldId}" data-field="\${field}" value="\${value || ''}">
</div>
\`;
}
}
function capitalizeFirst(str) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
function addFieldListeners() {
const inputs = document.querySelectorAll('input[data-field], textarea[data-field]');
inputs.forEach(input => {
input.addEventListener('change', (e) => {
const field = e.target.getAttribute('data-field');
let value = e.target.value;
// Convert datetime-local to ISO string
if (e.target.type === 'datetime-local' && value) {
value = new Date(value).toISOString();
}
updateField(field, value);
});
});
}
function handleTagInput(event, field) {
if (event.key === 'Enter' && event.target.value.trim()) {
const tag = event.target.value.trim();
const currentTags = currentFrontMatter[field] || [];
if (!currentTags.includes(tag)) {
const newTags = [...currentTags, tag];
updateField(field, newTags);
event.target.value = '';
}
}
}
function removeTag(field, tag) {
const currentTags = currentFrontMatter[field] || [];
const newTags = currentTags.filter(t => t !== tag);
updateField(field, newTags);
}
function updateField(field, value) {
vscode.postMessage({
type: 'updateField',
field,
value
});
}
function refreshPanel() {
vscode.postMessage({ type: 'refresh' });
}
</script>
</body>
</html>`;
}
}

View File

@@ -1,5 +1,6 @@
import * as vscode from 'vscode';
import { DashboardProvider } from './DashboardProvider';
import { PanelProvider } from './PanelProvider';
import { isVirtualWorkspace } from './utils';
/**
@@ -14,6 +15,15 @@ export function activate(context: vscode.ExtensionContext) {
outputChannel = vscode.window.createOutputChannel('Front Matter Lite');
outputChannel.appendLine('Front Matter Lite activated for virtual workspace');
// Register Panel Webview Provider
const panelProvider = new PanelProvider(context.extensionUri, outputChannel);
context.subscriptions.push(
vscode.window.registerWebviewViewProvider(
PanelProvider.viewType,
panelProvider
)
);
// Register Dashboard Webview Provider
const dashboardProvider = new DashboardProvider(context.extensionUri, outputChannel);
context.subscriptions.push(