diff --git a/vsextension/ACTIONS.md b/vsextension/ACTIONS.md new file mode 100644 index 0000000..6c8e9e3 --- /dev/null +++ b/vsextension/ACTIONS.md @@ -0,0 +1,110 @@ +# projspec VSCode Extension — User Actions + +This document describes every user action available in the `projspec` VSCode +extension. + +The extension activates automatically once VS Code finishes starting up +(`onStartupFinished`). + +--- + +## Command Palette Commands + +### `Show Project Library` (`projspec.showTree`) + +Opens the Project Library panel. + +**Steps:** +1. Runs `projspec library list --json-out` to load the project tree. +2. Runs `projspec info` once to load documentation for all spec/content/artifact types (cached for the lifetime of the window). +3. Opens the **"Project Library"** panel. + +--- + +### `Open Project` (`projspec.openProject`) + +Opens a project folder in a new VS Code window. +This command is invoked internally when a project node is clicked inside +the Project Library panel. + +- **Local projects** (`file://` URLs): opens the folder directly with + `vscode.openFolder`. +- **Remote GCS projects** (`gs://` URLs): shows an error — "Cannot open GCS + buckets directly. Clone the repository locally first." +- **Other URL schemes**: shows an error — "Unsupported project URL scheme: …" + +--- + +### `Show` (`projspec.showJson`) + +Serialises a project tree node to JSON and opens it as a read-only editor tab. +This command is invoked internally from the Project Library panel when a +spec, content, or artifact node is selected. + +**Steps:** +1. Receives a tree node item. +2. JSON-stringifies the node's raw data. +3. Opens a new unsaved document with JSON syntax highlighting in the editor. + +--- + +## Project Library Panel + +The Project Library panel (`projspec.showTree`) renders a custom HTML UI +inside a Webview. The following interactive elements are available. + +### Toolbar + +| Element | Action | +|---------|--------| +| **Scan** button | Scans the current workspace folder into the projspec library (`projspec scan --library `), then refreshes the tree. | +| **Create** button | Opens the [Create Project modal](#create-project-modal). | +| **Search input** | Live-filters the tree by project name or any visible child field. Click the **×** button or press **Escape** to clear. | +| **Expand All** button | Expands every node in the tree. | +| **Collapse All** button | Collapses every node in the tree. | + +### Tree Nodes +The top-level nodes are all Projects, with a name and a project URL. The name is the final portion of the URL. +Projects contain Specs, and both Specs and Projects contain Contents and Artifacts. In the tree view, +all Artifacts are show, but only Contents that are direct +children of a Project are shown. + +Nodes are colour-coded: + +- **Projects** — bold, folder colour +- **Contents** — teal (`#4ec9b0`) +- **Artifacts** — orange (`#ce9178`) +- **Specs** — function symbol colour + +| Element | Action | +|---------|--------| +| Click a **project** node | Selects the node (no other action). Right-click to open the context menu. | +| Right-click a **project** node | Opens a context menu with two options: **Open** opens the project folder in a new VS Code window; **Remove** runs `projspec library delete ` and refreshes the panel. | +| Click a **spec / content / artifact** node | Opens (or updates) the **Project Details** panel in the side column, showing the project's full spec/content/artifact tree with the clicked item highlighted. | +| **▶ / ▼ arrow** on any node | Toggles the visibility of that node's children. | +| **"Make" button** on an artifact node | Runs `projspec make ""` in a dedicated **projspec** terminal panel. | +| **"i" info button** on a spec / content / artifact node | Shows an inline popup with the item's doc string and, when available, a link to the upstream specification documentation. Press **Escape** or click elsewhere to dismiss. | + +### Create Project Modal + +Opened by the **Create** button in the toolbar. + +| Element | Action | +|---------|--------| +| **Type input with autocomplete** | Start typing a project spec type; suggestions appear below. Use **↑ / ↓** arrow keys or click to select a suggestion. | +| **Create** button | Runs `projspec create ` in the current workspace folder, then automatically scans the result into the library (`projspec scan --library `) and refreshes the tree. | +| **Cancel** button / **Escape** key | Dismisses the modal without creating anything. | + +--- + +## Project Details Panel + +Opened when a spec, content, or artifact node is clicked in the Library panel. A single panel is reused and updated on each click. + +The panel displays a header with the project name and URL, followed by a colour-coded tree of all the project's specs, contents, and artifacts — using the same visual conventions as the Library panel. + +| Element | Action | +|---------|--------| +| **▶ / ▼ arrow** on any node | Toggles the visibility of that node's children. | +| **"Make" button** on an artifact node | Runs `projspec make ""` in a dedicated **projspec** terminal panel. | +| **"i" info button** on a spec / content / artifact node | Shows an inline popup with the item's doc string and a link to specification documentation. Press **Escape** or click elsewhere to dismiss. | diff --git a/vsextension/README.md b/vsextension/README.md index d8d2a53..7d57e74 100644 --- a/vsextension/README.md +++ b/vsextension/README.md @@ -10,3 +10,5 @@ artifact. ![screenshot](./im.png) Like the qt-app, this is POC experimental only. + +The user actions available are documented in ACTIONS.md (in LLM-friendly text). diff --git a/vsextension/package.json b/vsextension/package.json index b5fdaf5..cb67a56 100644 --- a/vsextension/package.json +++ b/vsextension/package.json @@ -16,10 +16,6 @@ "main": "./out/extension.js", "contributes": { "commands": [ - { - "command": "projspec.scan", - "title": "Projspec scan" - }, { "command": "projspec.showTree", "title": "Show Project Library" diff --git a/vsextension/src/extension.ts b/vsextension/src/extension.ts index 5febd86..13ead47 100644 --- a/vsextension/src/extension.ts +++ b/vsextension/src/extension.ts @@ -16,24 +16,10 @@ interface TreeNode { } let cachedInfo: { specs: Record; content: Record; artifact: Record } | null = null; -let projectDocument: vscode.TextDocument | undefined = undefined; -let projectJsonContent: string = ""; - -const projectDetailsScheme = 'projspec-details'; -const projectDetailsUri = vscode.Uri.parse(`${projectDetailsScheme}:/Project details.json`); - -const projectDetailsProvider = new class implements vscode.TextDocumentContentProvider { - onDidChangeEmitter = new vscode.EventEmitter(); - onDidChange = this.onDidChangeEmitter.event; - - provideTextDocumentContent(uri: vscode.Uri): string { - return projectJsonContent; - } - - update() { - this.onDidChangeEmitter.fire(projectDetailsUri); - } -}; +let cachedLibraryData: Record | null = null; +let detailsPanel: vscode.WebviewPanel | undefined = undefined; +let detailsPanelProjectUrl: string | undefined = undefined; +let extensionLogoUri: vscode.Uri | undefined = undefined; function getInfoData(): { specs: Record; content: Record; artifact: Record } | null { if (cachedInfo === null) { @@ -157,12 +143,12 @@ function buildTreeNodes(projectUrl: string, project: any): TreeNode[] { function getExampleData(): TreeNode { try { const out = execSync("projspec library list --json-out", { stdio: 'pipe', encoding: 'utf-8' }); - const data = JSON.parse(out) as Record; + cachedLibraryData = JSON.parse(out) as Record; const children: TreeNode[] = []; // Data is a dict of project_url -> project_data - for (const [projectUrl, project] of Object.entries(data)) { + for (const [projectUrl, project] of Object.entries(cachedLibraryData)) { const projectChildren = buildTreeNodes(projectUrl, project); // Extract basename from project URL for display @@ -204,128 +190,64 @@ async function handleOpenProject(item: TreeNode) { } } -async function handleSelectProject(item: TreeNode) { - if (!item || !item.infoData || item.infoData.trim() === '') { - return; - } - - const projectUrl = item.infoData; - - // Only handle file:// URLs - if (projectUrl.startsWith('file://')) { - const fsPath = projectUrl.replace('file://', ''); - const uri = vscode.Uri.file(fsPath); - await vscode.commands.executeCommand('vscode.openFolder', uri, { forceNewWindow: true }); - } else if (projectUrl.startsWith('gs://')) { - vscode.window.showErrorMessage('Cannot open GCS buckets directly. Clone the repository locally first.'); - } else { - vscode.window.showErrorMessage(`Unsupported project URL scheme: ${projectUrl}`); +function handleMakeArtifact(item: TreeNode) { + if (!item || !item.qname || !item.projectUrl) { return; } + let projectPath = item.projectUrl; + if (projectPath.startsWith('file://')) { + projectPath = projectPath.replace('file://', ''); } + let terminal = vscode.window.terminals.find(t => t.name === 'projspec'); + if (!terminal) { terminal = vscode.window.createTerminal('projspec'); } + terminal.show(); + terminal.sendText(`projspec make ${item.qname} "${projectPath}"`); } -function flattenDeep(data: any): any { - if (data === null || typeof data !== 'object') { - return data; - } - - if (Array.isArray(data)) { - return data.map(item => flattenDeep(item)); +function handleSelectItem(item: TreeNode) { + if (!item || !item.projectUrl) { + return; } - const result: any = {}; - const keysToFlatten = ['specs', '_contents', '_artifacts', 'contents', 'artifacts']; - - for (const [key, value] of Object.entries(data)) { - const flattenedValue = flattenDeep(value); - if (keysToFlatten.includes(key) && flattenedValue && typeof flattenedValue === 'object' && !Array.isArray(flattenedValue)) { - Object.assign(result, flattenedValue); - } else { - result[key] = flattenedValue; - } + const data = cachedLibraryData; + if (!data) { + vscode.window.showErrorMessage('Project library data is not loaded yet.'); + return; } - return result; -} - -async function handleSelectItem(item: TreeNode) { - if (!item || !item.projectUrl) { + const projectData = data[item.projectUrl]; + if (!projectData) { + vscode.window.showErrorMessage(`Project not found: ${item.projectUrl}`); return; } - try { - const out = execSync("projspec library list --json-out", { stdio: 'pipe', encoding: 'utf-8' }); - const data = JSON.parse(out) as Record; - const projectData = data[item.projectUrl]; + const projectBasename = item.projectUrl.split('/').pop() || item.projectUrl; - if (!projectData) { - vscode.window.showErrorMessage(`Project not found: ${item.projectUrl}`); + if (detailsPanel) { + detailsPanel.reveal(vscode.ViewColumn.Two, true); + if (detailsPanelProjectUrl === item.projectUrl) { + // Same project already shown — just scroll to the item + detailsPanel.webview.postMessage({ command: 'scrollTo', key: item.key }); return; } - - // Flatten the project data as requested: remove "specs", "_contents" and "_artifacts" - // and bump their children up a level recursively. - const flattenedProjectData = flattenDeep(projectData); - - projectJsonContent = JSON.stringify(flattenedProjectData, null, 2); - projectDetailsProvider.update(); - - const doc = await vscode.workspace.openTextDocument(projectDetailsUri); - const editor = await vscode.window.showTextDocument(doc, { - preview: false, - viewColumn: vscode.ViewColumn.Two + } else { + detailsPanel = vscode.window.createWebviewPanel( + 'projspecDetails', + `${projectBasename} — details`, + { viewColumn: vscode.ViewColumn.Two, preserveFocus: true }, + { enableScripts: true, retainContextWhenHidden: true } + ); + if (extensionLogoUri) { detailsPanel.iconPath = extensionLogoUri; } + detailsPanel.onDidDispose(() => { detailsPanel = undefined; detailsPanelProjectUrl = undefined; }); + detailsPanel.webview.onDidReceiveMessage(message => { + if (message.command === 'makeArtifact') { handleMakeArtifact(message.item); } }); - - // Try to find and reveal the item in the JSON - const text = doc.getText(); - const searchKey = item.key; - - if (searchKey) { - const searchText = searchKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const regex = new RegExp(`"${searchText}"`); - const match = text.match(regex); - - if (match) { - const start = doc.positionAt(match.index!); - const end = doc.positionAt(match.index! + match[0].length); - const range = new vscode.Range(start, end); - editor.selection = new vscode.Selection(start, end); - editor.revealRange(range, vscode.TextEditorRevealType.InCenter); - } - } - } catch (error) { - vscode.window.showErrorMessage(`Failed to load project: ${error}`); } + + detailsPanelProjectUrl = item.projectUrl; + detailsPanel.title = `${projectBasename} — details`; + detailsPanel.webview.html = getDetailsWebviewContent(projectBasename, item.projectUrl, projectData, item.key); } export function activate(context: vscode.ExtensionContext) { - // register a content provider for scheme - const myScheme = 'projspec'; - const myProvider = new class implements vscode.TextDocumentContentProvider { - - // emitter and its event - onDidChangeEmitter = new vscode.EventEmitter(); - onDidChange = this.onDidChangeEmitter.event; - - provideTextDocumentContent(uri: vscode.Uri): string { - const out = execSync("projspec --html-out " + uri.toString().substring(18), { stdio: 'pipe' }); - return uri.toString().substring(18) + out; - } - }; - - context.subscriptions.push(vscode.workspace.registerTextDocumentContentProvider(projectDetailsScheme, projectDetailsProvider)); - context.subscriptions.push(vscode.workspace.registerTextDocumentContentProvider(myScheme, myProvider)); - - context.subscriptions.push(vscode.commands.registerCommand('projspec.scan', async () => { - if (vscode.workspace.workspaceFolders !== undefined) { - const folderPath = vscode.workspace.workspaceFolders[0].uri.fsPath; - const uri = vscode.Uri.file(folderPath); - let text = vscode.Uri.parse("projspec:" + uri); - - const panel = vscode.window.createWebviewPanel("projspec", folderPath, vscode.ViewColumn.One, {}); - panel.webview.html = "" + to_html(text.toString()) + ""; - console.log(folderPath); - } - else {return;}; - })); + extensionLogoUri = vscode.Uri.joinPath(context.extensionUri, 'logo.png'); context.subscriptions.push(vscode.commands.registerCommand('projspec.showTree', async () => { const treeData = getExampleData(); @@ -343,6 +265,8 @@ export function activate(context: vscode.ExtensionContext) { } ); + if (extensionLogoUri) { panel.iconPath = extensionLogoUri; } + // Set the HTML content for the webview panel.webview.html = getTreeWebviewContent(treeData, specNames); @@ -367,21 +291,31 @@ export function activate(context: vscode.ExtensionContext) { } } break; - case 'openProject': - await handleOpenProject(message.item); - break; - case 'selectProject': - await handleSelectProject(message.item); - break; - case 'selectItem': - await handleSelectItem(message.item); - break; + case 'openProject': + await handleOpenProject(message.item); + break; + case 'removeProject': + if (message.item && message.item.infoData) { + try { + execSync(`projspec library delete ${message.item.infoData}`, { stdio: 'pipe', encoding: 'utf-8' }); + const treeData = getExampleData(); + const infoData = getInfoData(); + const specNames = infoData ? Object.keys(infoData.specs) : []; + panel.webview.html = getTreeWebviewContent(treeData, specNames); + } catch (error) { + vscode.window.showErrorMessage(`Remove failed: ${error}`); + } + } + break; + case 'selectItem': + handleSelectItem(message.item); + break; case 'createProject': if (vscode.workspace.workspaceFolders !== undefined) { const folderPath = vscode.workspace.workspaceFolders[0].uri.fsPath; try { const out = execSync(`projspec create ${message.projectType} ${folderPath}`, { stdio: 'pipe', encoding: 'utf-8' }); - const files = out.split('\n').map(f => f.trim()).filter(f => f.length > 0); + const files = out.split('\n').map((f: string) => f.trim()).filter((f: string) => f.length > 0); for (const file of files) { const filePath = vscode.Uri.file(file); @@ -401,27 +335,10 @@ export function activate(context: vscode.ExtensionContext) { vscode.window.showErrorMessage(`Create project failed: ${error}`); } } - break; - case 'makeArtifact': - if (message.item && message.item.qname && message.item.projectUrl) { - const projectUrl = message.item.projectUrl; - const qname = message.item.qname; - - // Only handle file:// URLs for path - let projectPath = projectUrl; - if (projectUrl.startsWith('file://')) { - projectPath = projectUrl.replace('file://', ''); - } - - // Execute in VS Code terminal so stdout/err are visible - let terminal = vscode.window.terminals.find(t => t.name === 'projspec'); - if (!terminal) { - terminal = vscode.window.createTerminal('projspec'); - } - terminal.show(); - terminal.sendText(`projspec make ${qname} "${projectPath}"`); - } - break; + break; + case 'makeArtifact': + handleMakeArtifact(message.item); + break; } }, undefined, @@ -443,9 +360,6 @@ export function activate(context: vscode.ExtensionContext) { })); context.subscriptions.push(vscode.commands.registerCommand('projspec.showInfo', async (...args: any[]) => { - console.log('showInfo command called with args:', args); - console.log('args length:', args.length); - console.log('arg types:', args.map(arg => typeof arg)); let item: TreeNode | undefined; @@ -457,13 +371,9 @@ export function activate(context: vscode.ExtensionContext) { if (!item || typeof item !== 'object') { vscode.window.showErrorMessage('No valid item provided to showInfo command'); - console.error('Invalid item received:', item); return; } - console.log('Using item:', item); - console.log('Item properties:', Object.keys(item)); - let infoContent = ''; if (item.infoData && item.infoData.trim() !== '') { infoContent = item.infoData; @@ -487,12 +397,607 @@ export function activate(context: vscode.ExtensionContext) { panel.webview.html = getInfoWebviewContent(item.key, infoContent); })); - context.subscriptions.push(vscode.commands.registerCommand('projspec.showItem', async (item: TreeNode) => { - // This command can now just call the same handler as selectItem - await handleSelectItem(item); + context.subscriptions.push(vscode.commands.registerCommand('projspec.showItem', (item: TreeNode) => { + handleSelectItem(item); })); +} + +function getDetailsWebviewContent(projectBasename: string, projectUrl: string, project: any, highlightKey?: string): string { + const infoData = getInfoData(); + + // Keys that are internal implementation details and add no user-facing value + const SKIP_KEYS = new Set(['klass', 'proc', 'storage_options', 'children', 'url']); + + // Classify what type of colour-coding a node should get based on where it sits in the tree + type NodeRole = 'spec' | 'content' | 'artifact' | 'field' | 'none'; + + interface DetailNode { + label: string; // display text + value?: string; // inline scalar value, shown after a colon + role: NodeRole; + children?: DetailNode[]; + // For artifact Make buttons: + qname?: string; + projectUrl?: string; + // For info popups: + infoData?: string | null; + itemType?: string; + } + + function escapeHtml(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + } + + // Render a scalar value compactly + function scalarLabel(v: any): string { + if (v === null || v === undefined) { return 'null'; } + return String(v); + } + + // Build detail nodes recursively. + // role: the role inherited from the parent context + // qnamePath: dot-separated path for artifact make commands (e.g. "pixi._artifacts.conda_env.default") + function buildNodes(obj: any, role: NodeRole, qnamePath: string): DetailNode[] { + if (obj === null || obj === undefined) { return []; } + + // Array of scalars → render as a single multi-value leaf + if (Array.isArray(obj)) { + return obj.map((item, i) => { + if (item !== null && typeof item === 'object') { + return { label: String(i), role, children: buildNodes(item, role, `${qnamePath}.${i}`) }; + } + return { label: scalarLabel(item), role: 'field' }; + }); + } + + if (typeof obj !== 'object') { + return [{ label: scalarLabel(obj), role: 'field' }]; + } + + const nodes: DetailNode[] = []; + + for (const [key, value] of Object.entries(obj)) { + if (SKIP_KEYS.has(key)) { continue; } + + const childPath = qnamePath ? `${qnamePath}.${key}` : key; + + // Determine child role based on structural key names + let childRole: NodeRole = role; + if (key === 'specs' || key === '_contents' || key === 'contents' || key === '_artifacts' || key === 'artifacts') { + // These are container keys — their children take a specific role. + // Pass qnamePath (not childPath) so the container key itself is NOT + // included in the qname used by `projspec make`. + childRole = key === 'specs' ? 'spec' + : (key === '_contents' || key === 'contents') ? 'content' + : 'artifact'; + // Don't emit a wrapper node for these containers, just inline their children with the right role + const children = buildNodes(value, childRole, qnamePath); + nodes.push(...children); + continue; + } + + // ── Artifact special handling ────────────────────────────────── + // Artifacts in the serialised JSON take one of two shapes: + // 1. string leaf: { "launch": ", " } + // 2. named dict: { "conda_env": { "default": ", ", "another": "..." } } + // In both cases the "leaf" artifact nodes must carry a qname so + // the Make button can invoke `projspec make ""`. + if (role === 'artifact') { + // Attach info popup data for this artifact type + let artInfoData: string | null = null; + const artInfo = infoData?.artifact?.[key]; + if (artInfo) { artInfoData = buildTooltip(artInfo.doc, artInfo.link); } + + if (typeof value === 'string' || value === null) { + // Shape 1: single string artifact — childPath is the qname + nodes.push({ + label: key, + role: 'artifact', + qname: childPath, + projectUrl, + infoData: artInfoData, + itemType: 'artifact', + }); + } else if (value && typeof value === 'object' && !Array.isArray(value)) { + // Inspect the values: if they are all strings/null this is shape 2 + // (named artifacts); otherwise it's an artifact object with fields. + const entries = Object.entries(value as Record); + const allStrings = entries.every(([, v]) => typeof v === 'string' || v === null); + if (allStrings) { + // Shape 2: named artifacts — emit one node per name + const namedChildren: DetailNode[] = entries.map(([name, cmd]) => ({ + label: name, + role: 'artifact' as NodeRole, + qname: `${childPath}.${name}`, + projectUrl, + itemType: 'artifact', + })); + nodes.push({ + label: key, + role: 'artifact', + children: namedChildren.length > 0 ? namedChildren : undefined, + infoData: artInfoData, + itemType: 'artifact', + }); + } else { + // Artifact object with fields (unusual) — recurse normally, + // treating the whole object as one artifact leaf. + const children = buildNodes(value, 'field', childPath); + nodes.push({ + label: key, + role: 'artifact', + qname: childPath, + projectUrl, + children: children.length > 0 ? children : undefined, + infoData: artInfoData, + itemType: 'artifact', + }); + } + } + continue; + } + + // Scalar value → leaf with inline display + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + if (Array.isArray(value) && value.every(v => v === null || typeof v !== 'object')) { + // Array of scalars: show as expandable list + const arrayChildren: DetailNode[] = (value as any[]).map(v => ({ label: scalarLabel(v), role: 'field' as NodeRole })); + nodes.push({ label: key, role: role === 'content' ? 'content' : role === 'spec' ? 'spec' : 'field', children: arrayChildren.length > 0 ? arrayChildren : undefined }); + } else if (Array.isArray(value)) { + nodes.push({ label: key, role, children: buildNodes(value, role, childPath) }); + } else { + nodes.push({ label: key, value: scalarLabel(value), role: role === 'content' ? 'content' : role === 'spec' ? 'spec' : 'field' }); + } + continue; + } + + // Object value + const children = buildNodes(value, role, childPath); + + // (qname is only relevant for artifacts, handled above) + + // Attach info popup data based on role + let nodeInfoData: string | null = null; + if (role === 'spec') { + const info = infoData?.specs?.[key]; + if (info) { nodeInfoData = buildTooltip(info.doc, info.link); } + } else if (role === 'content') { + const info = infoData?.content?.[key]; + if (info) { nodeInfoData = buildTooltip(info.doc, info.link); } + } + + nodes.push({ + label: key, + role, + children: children.length > 0 ? children : undefined, + infoData: nodeInfoData, + itemType: role !== 'none' && role !== 'field' ? role : undefined, + }); + } + + return nodes; + } + + const detailNodes = buildNodes(project, 'none', ''); + + // Determine if a node is an artifact that can be "made": + // it must have a qname and no children that are themselves artifacts + function isLeafArtifact(node: DetailNode): boolean { + if (node.role !== 'artifact' || !node.qname) { return false; } + if (!node.children) { return true; } + return !node.children.some(c => c.role === 'artifact'); + } + + function renderDetailNode(node: DetailNode, depth: number): string { + const hasChildren = node.children && node.children.length > 0; + const canMake = isLeafArtifact(node); + const hasInfoPopup = node.infoData != null && node.role !== 'field' && node.role !== 'none'; + + let nodeClass = 'tree-node'; + if (node.role === 'spec') { nodeClass += ' spec-node'; } + else if (node.role === 'content') { nodeClass += ' content-node'; } + else if (node.role === 'artifact') { nodeClass += ' artifact-node'; } + else if (node.role === 'field') { nodeClass += ' field-node'; } + + const iconClass = hasChildren + ? 'tree-icon expandable' + : 'tree-icon leaf'; + + // Build data attribute for Make / info buttons + const nodeData = JSON.stringify({ + key: node.label, + qname: node.qname, + projectUrl: node.projectUrl, + itemType: node.itemType, + infoData: node.infoData, + }).replace(/"/g, '"'); + + const labelText = node.value !== undefined + ? `${escapeHtml(node.label)}: ${escapeHtml(node.value)}` + : escapeHtml(node.label); + + const childrenHtml = hasChildren + ? `
    ${node.children!.map(c => renderDetailNode(c, depth + 1)).join('')}
` + : ''; + + return `
  • +
    + + ${labelText} + ${canMake ? `` : ''} + ${hasInfoPopup ? `` : ''} +
    + ${childrenHtml} +
  • `; + } + + const treeHtml = detailNodes.map(n => renderDetailNode(n, 0)).join(''); + + return ` + + + + + ${escapeHtml(projectBasename)} — details + + + +
    +
    ${escapeHtml(projectBasename)}
    +
    ${escapeHtml(projectUrl)}
    +
    + +
    + + + +
    + +
    +
      ${treeHtml}
    +
    + + +
    + + +
    + + + +`; } function getTreeWebviewContent(treeData: TreeNode, specNames: string[] = [], scrollToProjectUrl?: string): string { @@ -978,6 +1483,67 @@ function getTreeWebviewContent(treeData: TreeNode, specNames: string[] = [], scr .modal-button-secondary:hover { background: var(--vscode-button-secondaryHoverBackground); } + + /* Context menu styles */ + .context-menu { + position: fixed; + background: var(--vscode-menu-background); + border: 1px solid var(--vscode-menu-border); + border-radius: 4px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); + padding: 4px 0; + z-index: 3000; + display: none; + min-width: 140px; + } + + .context-menu.visible { + display: block; + } + + .context-menu-item { + padding: 6px 16px; + cursor: pointer; + color: var(--vscode-menu-foreground); + font-family: inherit; + font-size: var(--vscode-font-size); + white-space: nowrap; + } + + .context-menu-item:hover { + background: var(--vscode-menu-selectionBackground); + color: var(--vscode-menu-selectionForeground); + } + + /* Loading overlay */ + .loading-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.35); + display: none; + align-items: center; + justify-content: center; + z-index: 4000; + cursor: wait; + } + + .loading-overlay.visible { + display: flex; + } + + .loading-spinner { + width: 28px; + height: 28px; + border: 3px solid var(--vscode-foreground); + border-top-color: transparent; + border-radius: 50%; + animation: spin 0.7s linear infinite; + opacity: 0.8; + } + + @keyframes spin { + to { transform: rotate(360deg); } + } @@ -1001,6 +1567,11 @@ function getTreeWebviewContent(treeData: TreeNode, specNames: string[] = [], scr + +
    +
    +
    +
    + +
    +
    Open
    +
    Remove
    +
    +