Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 3790 lines 144 kB view raw
1// VSCode Extension, 23.06.24.18.58 2// A VSCode extension for live coding aesthetic.computer pieces and 3// exploring the system documentation. 4 5/* #region TODO 📓 6#endregion */ 7 8// Import necessary modules from vscode 9import * as vscode from "vscode"; 10import { Buffer } from "buffer"; 11 12// (generated-views import removed — welcome panel now loads dev.html via iframe only) 13 14// 🌈 KidLisp Syntax Highlighting 15// Embedded copy of shared/kidlisp-syntax.mjs for bundling 16// This provides Monaco-parity highlighting for .lisp files in VS Code 17import * as KidLispSyntax from "./kidlisp-syntax"; 18 19// Dynamically import path, fs, child_process, and http to ensure web compatibility. 20let path: any, fs: any, cp: any, http: any; 21let _modulesReady: Promise<void>; 22_modulesReady = (async () => { 23 if (typeof window === "undefined") { 24 path = await import("path"); 25 fs = await import("fs"); 26 cp = await import("child_process"); 27 http = await import("http"); 28 } 29})(); 30 31import { AestheticAuthenticationProvider } from "./aestheticAuthenticationProviderRemote"; 32import * as acorn from "acorn"; 33const { keys } = Object; 34 35// AST parsing and tracking for JS/MJS files 36interface ASTNode { 37 id: string; 38 type: string; 39 name?: string; 40 start: number; 41 end: number; 42 children: ASTNode[]; 43 parent?: string; 44 depth: number; 45 loc?: { start: { line: number; column: number }; end: { line: number; column: number } }; 46 // Extra metadata for richer display 47 kind?: string; // const, let, var for VariableDeclaration 48 source?: string; // import source path 49 callee?: string; // function being called 50 property?: string; // member access property 51 value?: string; // literal value 52} 53 54interface TrackedFile { 55 uri: string; 56 fileName: string; 57 filePath: string; 58 ast: ASTNode | null; 59 lastUpdate: number; 60} 61 62const trackedFiles = new Map<string, TrackedFile>(); 63let astUpdateCallback: ((files: TrackedFile[]) => void) | null = null; 64 65function parseJSToAST(code: string, fileName: string): ASTNode | null { 66 try { 67 const ast = acorn.parse(code, { 68 ecmaVersion: 'latest', 69 sourceType: 'module', 70 locations: true, 71 }); 72 73 let nodeId = 0; 74 75 function convertNode(node: any, depth: number = 0, parentId?: string): ASTNode { 76 const id = `${fileName}-${nodeId++}`; 77 const children: ASTNode[] = []; 78 79 // Get a friendly name for the node 80 let name: string | undefined; 81 let kind: string | undefined; 82 let source: string | undefined; 83 let callee: string | undefined; 84 let property: string | undefined; 85 let value: string | undefined; 86 87 // Extract identifier names 88 if (node.id?.name) name = node.id.name; 89 else if (node.key?.name) name = node.key.name; 90 else if (node.key?.value) name = String(node.key.value); 91 else if (node.name) name = node.name; 92 else if (node.type === 'Identifier') name = node.name; 93 94 // Extract additional metadata based on node type 95 switch (node.type) { 96 case 'VariableDeclaration': 97 kind = node.kind; // const, let, var 98 break; 99 case 'ImportDeclaration': 100 source = node.source?.value; 101 break; 102 case 'ExportNamedDeclaration': 103 case 'ExportDefaultDeclaration': 104 if (node.declaration?.id?.name) name = node.declaration.id.name; 105 if (node.source?.value) source = node.source.value; 106 break; 107 case 'CallExpression': 108 if (node.callee?.name) callee = node.callee.name; 109 else if (node.callee?.property?.name) callee = node.callee.property.name; 110 else if (node.callee?.type === 'MemberExpression') { 111 const obj = node.callee.object?.name || ''; 112 const prop = node.callee.property?.name || ''; 113 callee = obj ? `${obj}.${prop}` : prop; 114 } 115 break; 116 case 'MemberExpression': 117 property = node.property?.name || node.property?.value; 118 break; 119 case 'Literal': 120 value = String(node.value).slice(0, 30); 121 name = value.length > 15 ? value.slice(0, 12) + '…' : value; 122 break; 123 case 'TemplateLiteral': 124 name = '`template`'; 125 break; 126 case 'Property': 127 name = node.key?.name || node.key?.value; 128 break; 129 } 130 131 const astNode: ASTNode = { 132 id, 133 type: node.type, 134 name, 135 start: node.start, 136 end: node.end, 137 children, 138 parent: parentId, 139 depth, 140 loc: node.loc, 141 kind, 142 source, 143 callee, 144 property, 145 value, 146 }; 147 148 // Recursively process child nodes 149 for (const key of Object.keys(node)) { 150 if (key === 'type' || key === 'start' || key === 'end' || key === 'loc' || key === 'range') continue; 151 const val = node[key]; 152 if (val && typeof val === 'object') { 153 if (Array.isArray(val)) { 154 for (const item of val) { 155 if (item && typeof item === 'object' && item.type) { 156 children.push(convertNode(item, depth + 1, id)); 157 } 158 } 159 } else if (val.type) { 160 children.push(convertNode(val, depth + 1, id)); 161 } 162 } 163 } 164 165 return astNode; 166 } 167 168 return convertNode(ast); 169 } catch (e) { 170 console.log(`AST parse error for ${fileName}:`, e); 171 return null; 172 } 173} 174 175function updateTrackedFile(document: vscode.TextDocument) { 176 const uri = document.uri.toString(); 177 const filePath = document.fileName; 178 const fileName = filePath.split('/').pop() || filePath; 179 180 // Track JS/MJS/TS files (for AST), plus Markdown and Lisp files (as open buffers) 181 const isCodeFile = fileName.endsWith('.js') || fileName.endsWith('.mjs') || fileName.endsWith('.ts'); 182 const isMarkdown = fileName.endsWith('.md'); 183 const isLisp = fileName.endsWith('.lisp'); 184 185 if (!isCodeFile && !isMarkdown && !isLisp) { 186 return; 187 } 188 189 // Only parse AST for code files 190 const ast = isCodeFile ? parseJSToAST(document.getText(), fileName) : null; 191 192 trackedFiles.set(uri, { 193 uri, 194 fileName, 195 filePath, 196 ast, 197 lastUpdate: Date.now(), 198 }); 199 200 if (astUpdateCallback) { 201 astUpdateCallback(Array.from(trackedFiles.values())); 202 } 203} 204 205function removeTrackedFile(uri: string) { 206 trackedFiles.delete(uri); 207 if (astUpdateCallback) { 208 astUpdateCallback(Array.from(trackedFiles.values())); 209 } 210} 211 212let local: boolean = false; 213let localServerAvailable: boolean = false; 214let codeChannel: string | undefined; 215 216// Detect if we're in GitHub Codespaces 217const isCodespaces = process?.env.CODESPACES === "true"; 218const codespaceName = process?.env.CODESPACE_NAME; 219const codespacesDomain = process?.env.GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN; 220 221let mergedDocs: any = {}; 222let docs: any; 223 224let extContext: any; 225let webWindow: any; 226let kidlispWindow: any; 227let newsWindow: any; 228let atWindow: any; 229let welcomePanel: vscode.WebviewPanel | null = null; 230let localServerCheckInterval: NodeJS.Timeout | undefined; 231let provider: AestheticViewProvider; 232let lastWebviewRefreshAt = 0; // Debounce webview refreshes 233const WEBVIEW_REFRESH_DEBOUNCE_MS = 10000; // Minimum 10s between auto-refreshes 234 235// Check if the local server is available 236async function checkLocalServer(): Promise<boolean> { 237 try { 238 // Use https module directly to allow self-signed certificates 239 const https = await import("https"); 240 return new Promise((resolve) => { 241 const req = https.request( 242 { 243 hostname: "localhost", 244 port: 8888, 245 // HEAD can return 404/redirect even when the server is healthy. 246 // We only care that something is listening and responding. 247 path: `/?vscode=1&ts=${Date.now()}`, 248 method: "GET", 249 rejectUnauthorized: false, // Allow self-signed certs 250 timeout: 2000, 251 }, 252 (res) => { 253 // Any response means the server is reachable. 254 resolve(true); 255 // Ensure we don't hang waiting for body data. 256 res.resume(); 257 } 258 ); 259 req.on("error", () => resolve(false)); 260 req.on("timeout", () => { 261 req.destroy(); 262 resolve(false); 263 }); 264 req.end(); 265 }); 266 } catch (e) { 267 return false; 268 } 269} 270 271// Start polling for local server availability 272function startLocalServerCheck() { 273 if (localServerCheckInterval) { 274 clearInterval(localServerCheckInterval); 275 } 276 277 // Helper to refresh all webviews with debouncing 278 function refreshAllWebviews() { 279 const now = Date.now(); 280 if (now - lastWebviewRefreshAt < WEBVIEW_REFRESH_DEBOUNCE_MS) { 281 console.log("⏳ Skipping webview refresh (debounced)"); 282 return; 283 } 284 lastWebviewRefreshAt = now; 285 console.log("✅ Local server is now available"); 286 // Refresh webviews when server becomes available 287 if (provider) provider.refreshWebview(); 288 refreshWebWindow(); 289 refreshKidLispWindow(); 290 refreshNewsWindow(); 291 refreshAtWindow(); 292 } 293 294 // Check immediately 295 checkLocalServer().then((available) => { 296 const wasAvailable = localServerAvailable; 297 localServerAvailable = available; 298 if (available && !wasAvailable) { 299 refreshAllWebviews(); 300 } 301 }); 302 303 // Then check every 3 seconds 304 localServerCheckInterval = setInterval(async () => { 305 const wasAvailable = localServerAvailable; 306 localServerAvailable = await checkLocalServer(); 307 308 if (localServerAvailable && !wasAvailable) { 309 refreshAllWebviews(); 310 } else if (!localServerAvailable && wasAvailable) { 311 console.log("⏳ Local server disconnected - waiting for reconnect..."); 312 // Don't immediately show waiting screen - server may come back quickly during hot reload 313 // The webview will show the waiting screen on next manual refresh or when we first load 314 } 315 }, 3000); 316} 317 318// Stop polling for local server 319function stopLocalServerCheck() { 320 if (localServerCheckInterval) { 321 clearInterval(localServerCheckInterval); 322 localServerCheckInterval = undefined; 323 } 324} 325 326// 🌈 KidLisp Syntax Highlighting Setup 327// Uses atomic decoration updates to avoid flickering (like Monaco's deltaDecorations) 328const kidlispDecorationTypes = new Map<string, vscode.TextEditorDecorationType>(); 329let kidlispHighlightInterval: NodeJS.Timeout | undefined; 330// Track which decoration type keys were used in the last render per editor 331const lastUsedDecorationKeys = new Map<string, Set<string>>(); 332 333function getOrCreateDecorationType(color: string, options: { bold?: boolean } = {}): vscode.TextEditorDecorationType { 334 const key = `${color}-${options.bold ? 'bold' : 'normal'}`; 335 if (!kidlispDecorationTypes.has(key)) { 336 const hexColor = KidLispSyntax.colorToHex(color); 337 kidlispDecorationTypes.set(key, vscode.window.createTextEditorDecorationType({ 338 color: hexColor, 339 fontWeight: options.bold ? 'bold' : 'normal', 340 })); 341 } 342 return kidlispDecorationTypes.get(key)!; 343} 344 345function applyKidLispDecorations(editor: vscode.TextEditor, isEditMode: boolean = true) { 346 if (!editor || editor.document.languageId !== 'kidlisp') { 347 return; 348 } 349 350 const document = editor.document; 351 const text = document.getText(); 352 const tokens = KidLispSyntax.tokenizeWithPositions(text); 353 const tokenValues = tokens.map(t => t.value); 354 355 // Group decorations by color 356 const decorationsByColor = new Map<string, vscode.DecorationOptions[]>(); 357 358 // Check if we're in light mode 359 const isLightTheme = vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.Light || 360 vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.HighContrastLight; 361 362 for (let i = 0; i < tokens.length; i++) { 363 const token = tokens[i]; 364 let color = KidLispSyntax.getTokenColor(token.value, tokenValues, i, { isEditMode }); 365 366 // Handle special color markers 367 if (color === 'RAINBOW') { 368 // Rainbow: each character gets a different color 369 for (let j = 0; j < token.value.length; j++) { 370 const charColor = KidLispSyntax.RAINBOW_COLORS[j % KidLispSyntax.RAINBOW_COLORS.length]; 371 const startPos = document.positionAt(token.pos + j); 372 const endPos = document.positionAt(token.pos + j + 1); 373 const range = new vscode.Range(startPos, endPos); 374 375 if (!decorationsByColor.has(charColor)) { 376 decorationsByColor.set(charColor, []); 377 } 378 decorationsByColor.get(charColor)!.push({ range }); 379 } 380 continue; 381 } 382 383 if (color === 'ZEBRA') { 384 // Zebra: alternating black and white 385 for (let j = 0; j < token.value.length; j++) { 386 const charColor = KidLispSyntax.ZEBRA_COLORS[j % KidLispSyntax.ZEBRA_COLORS.length]; 387 const startPos = document.positionAt(token.pos + j); 388 const endPos = document.positionAt(token.pos + j + 1); 389 const range = new vscode.Range(startPos, endPos); 390 391 if (!decorationsByColor.has(charColor)) { 392 decorationsByColor.set(charColor, []); 393 } 394 decorationsByColor.get(charColor)!.push({ range }); 395 } 396 continue; 397 } 398 399 if (color.startsWith('COMPOUND:')) { 400 // Compound: prefix char in one color, rest in another 401 const [, prefixColor, identifierColor] = color.split(':'); 402 403 // Prefix character ($ or # or !) 404 const prefixStart = document.positionAt(token.pos); 405 const prefixEnd = document.positionAt(token.pos + 1); 406 const prefixRange = new vscode.Range(prefixStart, prefixEnd); 407 408 const finalPrefixColor = isLightTheme ? KidLispSyntax.getLightModeColor(prefixColor) : prefixColor; 409 if (!decorationsByColor.has(finalPrefixColor)) { 410 decorationsByColor.set(finalPrefixColor, []); 411 } 412 decorationsByColor.get(finalPrefixColor)!.push({ range: prefixRange }); 413 414 // Identifier part 415 const idStart = document.positionAt(token.pos + 1); 416 const idEnd = document.positionAt(token.pos + token.value.length); 417 const idRange = new vscode.Range(idStart, idEnd); 418 419 const finalIdColor = isLightTheme ? KidLispSyntax.getLightModeColor(identifierColor) : identifierColor; 420 if (!decorationsByColor.has(finalIdColor)) { 421 decorationsByColor.set(finalIdColor, []); 422 } 423 decorationsByColor.get(finalIdColor)!.push({ range: idRange }); 424 425 continue; 426 } 427 428 // Regular color 429 const finalColor = isLightTheme ? KidLispSyntax.getLightModeColor(color) : color; 430 const startPos = document.positionAt(token.pos); 431 const endPos = document.positionAt(token.pos + token.value.length); 432 const range = new vscode.Range(startPos, endPos); 433 434 if (!decorationsByColor.has(finalColor)) { 435 decorationsByColor.set(finalColor, []); 436 } 437 decorationsByColor.get(finalColor)!.push({ range }); 438 } 439 440 // Atomic update: apply new decorations and clear stale ones in one pass 441 // setDecorations(type, ranges) replaces all decorations for that type, 442 // so we don't need to clear first — just set the new ranges. 443 const editorId = editor.document.uri.toString(); 444 const currentKeys = new Set<string>(); 445 446 for (const [color, ranges] of decorationsByColor) { 447 const decorationType = getOrCreateDecorationType(color, { bold: true }); 448 const key = `${color}-bold`; 449 currentKeys.add(key); 450 editor.setDecorations(decorationType, ranges); 451 } 452 453 // Only clear decoration types that were used last time but NOT this time 454 const previousKeys = lastUsedDecorationKeys.get(editorId); 455 if (previousKeys) { 456 for (const key of previousKeys) { 457 if (!currentKeys.has(key)) { 458 const decorationType = kidlispDecorationTypes.get(key); 459 if (decorationType) { 460 editor.setDecorations(decorationType, []); 461 } 462 } 463 } 464 } 465 466 lastUsedDecorationKeys.set(editorId, currentKeys); 467} 468 469// Check if any visible KidLisp editor has timing tokens that need blinking 470const timingPattern = /\d*\.?\d+[sf](?:\.\.\.?|!?)/; 471function hasTimingTokens(): boolean { 472 for (const editor of vscode.window.visibleTextEditors) { 473 if (editor.document.languageId === 'kidlisp' && timingPattern.test(editor.document.getText())) { 474 return true; 475 } 476 } 477 return false; 478} 479 480function setupKidLispHighlighting(context: vscode.ExtensionContext) { 481 // Apply highlighting to currently visible KidLisp editors 482 function refreshAllKidLispEditors() { 483 for (const editor of vscode.window.visibleTextEditors) { 484 if (editor.document.languageId === 'kidlisp') { 485 applyKidLispDecorations(editor, true); 486 } 487 } 488 } 489 490 // Initial application 491 refreshAllKidLispEditors(); 492 493 // Refresh when editors change 494 context.subscriptions.push( 495 vscode.window.onDidChangeVisibleTextEditors(() => { 496 refreshAllKidLispEditors(); 497 }) 498 ); 499 500 // Refresh when document content changes 501 context.subscriptions.push( 502 vscode.workspace.onDidChangeTextDocument(event => { 503 const editor = vscode.window.visibleTextEditors.find( 504 e => e.document === event.document 505 ); 506 if (editor && editor.document.languageId === 'kidlisp') { 507 applyKidLispDecorations(editor, true); 508 } 509 }) 510 ); 511 512 // Refresh when theme changes (for light/dark mode support) 513 context.subscriptions.push( 514 vscode.window.onDidChangeActiveColorTheme(() => { 515 // Clear cached decoration types since colors may need updating 516 for (const [, decorationType] of kidlispDecorationTypes) { 517 decorationType.dispose(); 518 } 519 kidlispDecorationTypes.clear(); 520 lastUsedDecorationKeys.clear(); 521 refreshAllKidLispEditors(); 522 }) 523 ); 524 525 // Timing blink animation interval — only refreshes when timing tokens exist 526 if (kidlispHighlightInterval) { 527 clearInterval(kidlispHighlightInterval); 528 } 529 kidlispHighlightInterval = setInterval(() => { 530 if (hasTimingTokens()) { 531 refreshAllKidLispEditors(); 532 } 533 }, 250); // ~4fps is enough for blink animation 534 535 // Clean up on deactivation 536 context.subscriptions.push({ 537 dispose: () => { 538 if (kidlispHighlightInterval) { 539 clearInterval(kidlispHighlightInterval); 540 kidlispHighlightInterval = undefined; 541 } 542 for (const [, decorationType] of kidlispDecorationTypes) { 543 decorationType.dispose(); 544 } 545 kidlispDecorationTypes.clear(); 546 lastUsedDecorationKeys.clear(); 547 } 548 }); 549} 550 551async function activate(context: vscode.ExtensionContext): Promise<void> { 552 // local = context.globalState.get("aesthetic:local", false); // Retrieve env. 553 554 // Show all environment variables... 555 // console.log("🌎 Environment:", process.env); 556 // Always retrieve the stored local state first 557 local = context.globalState.get("aesthetic:local", false); 558 559 const isInDevContainer = process?.env.REMOTE_CONTAINERS === "true"; 560 if (isInDevContainer) { 561 // console.log("✅ 🥡 Running inside a container."); 562 // Keep the retrieved local state 563 } else { 564 // console.log("❌ 🥡 Not in container."); 565 // Keep the user's preference even when not in container 566 } 567 568 // Start checking for local server if local mode is enabled 569 if (local) { 570 startLocalServerCheck(); 571 } 572 573 // 🌳 AST Tracking: Watch open JS/MJS documents for live AST visualization 574 // Track currently open editors 575 vscode.window.visibleTextEditors.forEach(editor => { 576 updateTrackedFile(editor.document); 577 }); 578 579 // Track when editors change 580 context.subscriptions.push( 581 vscode.window.onDidChangeVisibleTextEditors(editors => { 582 // Add new files 583 editors.forEach(editor => updateTrackedFile(editor.document)); 584 }) 585 ); 586 587 // Track document changes for live updates 588 context.subscriptions.push( 589 vscode.workspace.onDidChangeTextDocument(event => { 590 if (trackedFiles.has(event.document.uri.toString())) { 591 updateTrackedFile(event.document); 592 } 593 }) 594 ); 595 596 // Track document closes 597 context.subscriptions.push( 598 vscode.workspace.onDidCloseTextDocument(document => { 599 removeTrackedFile(document.uri.toString()); 600 }) 601 ); 602 603 // 🛡️ Periodic Cleanup: Ensure tracked files match actual open documents 604 // (Fixes issues where files might get "stuck" if events are missed) 605 setInterval(() => { 606 const openUris = new Set(vscode.workspace.textDocuments.map(d => d.uri.toString())); 607 let changed = false; 608 for (const [uri, file] of trackedFiles) { 609 if (!openUris.has(uri)) { 610 console.log('🧹 Pruning stuck file:', file.fileName); 611 trackedFiles.delete(uri); 612 changed = true; 613 } 614 } 615 if (changed && astUpdateCallback) { 616 astUpdateCallback(Array.from(trackedFiles.values())); 617 } 618 }, 2000); 619 620 // Set up AST update callback to send to welcome panel 621 astUpdateCallback = (files) => { 622 if (welcomePanel) { 623 welcomePanel.webview.postMessage({ 624 command: 'astUpdate', 625 files: files.map(f => ({ 626 id: f.uri, // Use URI as unique ID 627 fileName: f.fileName, 628 filePath: f.filePath, // Send full path 629 ast: f.ast, 630 lastUpdate: f.lastUpdate, 631 })), 632 }); 633 } 634 }; 635 636 // 🌈 KidLisp Syntax Highlighting with Decorations 637 // This provides Monaco-parity highlighting for .lisp files including: 638 // - Rainbow/zebra color words, timing blinks, fade expressions 639 // - Color codes, CSS colors, RGB channels, nested paren colors 640 setupKidLispHighlighting(context); 641 642 // console.log("🟢 Aesthetic Computer Extension: Activated"); 643 extContext = context; 644 645 // Load the docs from the web. 646 if (!docs) { 647 try { 648 // const url = `https://${ // local ? "localhost:8888" : "aesthetic.computer" }/api/docs`; 649 const url = `https://aesthetic.computer/docs.json`; 650 const response = await fetch(url); 651 if (!response.ok) { 652 throw new Error(`HTTP error! status: ${response.status}`); 653 } 654 const data: any = await response.json(); 655 // console.log("📚 Docs loaded:", data); 656 657 keys(data.api).forEach((key) => { 658 // Add the category to each doc before smushing them. 659 // Such as `structure` so doc pages can be found like `structure:boot`. 660 keys(data.api[key]).forEach((k) => { 661 data.api[key][k].category = key; 662 }); 663 // 😛 Smush them. 664 mergedDocs = { 665 ...mergedDocs, 666 ...data.api[key], 667 }; 668 }); 669 670 docs = data; 671 } catch (error) { 672 console.error("Failed to fetch documentation:", error); 673 } 674 } 675 676 // Set up all the autocompletion and doc hints. 677 const codeLensProvider = new AestheticCodeLensProvider(); 678 vscode.languages.registerCodeLensProvider( 679 { language: "javascript", pattern: "**/*.mjs" }, 680 codeLensProvider, 681 ); 682 683 const completionProvider = vscode.languages.registerCompletionItemProvider( 684 { language: "javascript", pattern: "**/*.mjs" }, 685 { 686 provideCompletionItems( 687 document: vscode.TextDocument, 688 position: vscode.Position, 689 ): vscode.ProviderResult< 690 vscode.CompletionItem[] | vscode.CompletionList 691 > { 692 if (document.lineCount > 500 || !docs) return []; // Skip for long files. 693 return keys(mergedDocs).map( 694 (word: string) => new vscode.CompletionItem(word), 695 ); 696 }, 697 }, 698 ); 699 700 context.subscriptions.push(completionProvider); 701 702 const hoverProvider = vscode.languages.registerHoverProvider( 703 { language: "javascript", pattern: "**/*.mjs" }, 704 { 705 provideHover(document, position) { 706 if (document.lineCount > 500 || !docs) return; // Skip for long files. 707 708 const range = document.getWordRangeAtPosition(position); 709 const word = document.getText(range); 710 711 if (keys(mergedDocs).indexOf(word) > -1) { 712 const contents = new vscode.MarkdownString(); 713 contents.isTrusted = true; // Enable for custom markdown. 714 contents.appendCodeblock(`${mergedDocs[word].sig}`, "javascript"); 715 contents.appendText("\n\n"); 716 contents.appendMarkdown(`${mergedDocs[word].desc}`); 717 return new vscode.Hover(contents, range); 718 } 719 }, 720 }, 721 ); 722 723 context.subscriptions.push(hoverProvider); 724 725 // Code Action Provider for manual documentation opening 726 const codeActionProvider = vscode.languages.registerCodeActionsProvider( 727 { language: "javascript", pattern: "**/*.mjs" }, 728 { 729 provideCodeActions( 730 document: vscode.TextDocument, 731 range: vscode.Range | vscode.Selection, 732 context: vscode.CodeActionContext, 733 token: vscode.CancellationToken 734 ): vscode.ProviderResult<(vscode.CodeAction | vscode.Command)[]> { 735 if (document.lineCount > 500 || !docs) return []; // Skip for long files. 736 737 const wordRange = document.getWordRangeAtPosition(range.start); 738 if (!wordRange) return []; 739 740 const word = document.getText(wordRange); 741 742 if (keys(mergedDocs).indexOf(word) > -1) { 743 const action = new vscode.CodeAction( 744 `📚 Open documentation for "${word}"`, 745 vscode.CodeActionKind.QuickFix 746 ); 747 action.command = { 748 title: `Open docs for ${word}`, 749 command: "aestheticComputer.openDoc", 750 arguments: [word] 751 }; 752 action.isPreferred = true; 753 return [action]; 754 } 755 756 return []; 757 } 758 } 759 ); 760 761 context.subscriptions.push(codeActionProvider); 762 const definitionProvider = vscode.languages.registerDefinitionProvider( 763 { language: "javascript", pattern: "**/*.mjs" }, 764 { 765 provideDefinition( 766 document, 767 position, 768 token, 769 ): vscode.ProviderResult<vscode.Definition | vscode.DefinitionLink[]> { 770 if (document.lineCount > 500) return; // Skip for large documents. 771 772 const range = document.getWordRangeAtPosition(position); 773 const word = document.getText(range); 774 775 if (mergedDocs[word]) { 776 // Instead of automatically opening docs, create a virtual definition location 777 // that shows in the peek definition view without triggering a popup 778 const uri = vscode.Uri.parse(`aesthetic-doc:${word}`); 779 return new vscode.Location(uri, new vscode.Position(0, 0)); 780 } 781 782 return null; 783 }, 784 }, 785 ); 786 787 let docsPanel: any = null; // Only keep one docs panel open. 788 789 context.subscriptions.push( 790 vscode.commands.registerCommand( 791 "aestheticComputer.openDoc", 792 (functionName) => { 793 // const uri = vscode.Uri.parse(`${docScheme}:${functionName}`); 794 795 let path = ""; 796 if (functionName) 797 path = "/" + mergedDocs[functionName].category + ":" + functionName; 798 799 const title = path || "docs"; 800 801 // Check if the panel already exists. If so, reveal it. 802 if (docsPanel) { 803 // Update the title 804 docsPanel.title = 805 "📚 " + title.replace("/", "") + " · Aesthetic Computer"; 806 docsPanel.reveal(vscode.ViewColumn.Beside); 807 } else { 808 // Create and show a new webview 809 docsPanel = vscode.window.createWebviewPanel( 810 "aestheticDoc", // Identifies the type of the webview. Used internally 811 "📚 " + title.replace("/", "") + " · Aesthetic Computer", // Title of the panel displayed to the user 812 vscode.ViewColumn.Beside, // Editor column to show the new webview panel in. 813 { enableScripts: true, enableForms: true }, // Webview options. 814 ); 815 816 // Reset when the current panel is closed 817 docsPanel.onDidDispose(() => { 818 docsPanel = null; 819 }, null); 820 } 821 822 const nonce = getNonce(); 823 824 // Build CSP frame-src based on environment 825 let cspFrameSrc = "frame-src https://aesthetic.computer https://aesthetic.local:8888 https://localhost:8888"; 826 let cspChildSrc = "child-src https://aesthetic.computer https://aesthetic.local:8888 https://localhost:8888"; 827 828 if (isCodespaces && codespacesDomain) { 829 const codespaceWildcard = `https://*.${codespacesDomain}`; 830 cspFrameSrc += ` ${codespaceWildcard}`; 831 cspChildSrc += ` ${codespaceWildcard}`; 832 } 833 834 // And set its HTML content 835 docsPanel.webview.html = ` 836 <!DOCTYPE html> 837 <html lang="en"> 838 <head> 839 <meta charset="UTF-8"> 840 <meta http-equiv="Permissions-Policy" content="midi=*"> 841 <meta http-equiv="Content-Security-Policy" content="default-src 'none'; ${cspFrameSrc}; ${cspChildSrc}; style-src 'nonce-${nonce}'; script-src 'nonce-${nonce}'; img-src https: data:;"> 842 <style nonce="${nonce}"> 843 body { 844 margin: 0; 845 padding: 0; 846 overflow: hidden;" 847 } 848 iframe { 849 border: none; 850 width: 100vw; 851 height: 100vh; 852 } 853 </style> 854 </head> 855 <body> 856 <iframe allow="clipboard-write; clipboard-read" credentialless sandbox="allow-scripts allow-modals allow-popups allow-popups-to-escape-sandbox allow-presentation" src="https://aesthetic.computer/docs${path}"> 857 </body> 858 </html> 859 `.trim(); 860 }, 861 ), 862 ); 863 864 // ✦ Dashboard — Git Status + AT Protocol Firehose + Tangled Knot Activity 865 // Multi-section live dashboard for all Aesthetic Computer platform activity. 866 867 interface GitRepoStatus { 868 name: string; 869 root: string; 870 branch: string; 871 files: { status: string; file: string }[]; 872 error?: string; 873 } 874 875 interface FirehoseEvent { 876 id: string; 877 type: string; // tape, mood, painting, news, handle, piece, kidlisp 878 action: string; // create, update, delete 879 handle?: string; 880 did?: string; 881 summary: string; 882 timestamp: string; 883 url?: string; 884 } 885 886 interface TangledEvent { 887 id: string; 888 type: string; // push, issue, star, fork 889 repo: string; 890 author?: string; 891 summary: string; 892 timestamp: string; 893 commitHash?: string; 894 url?: string; 895 } 896 897 // Detect current VS Code theme kind 898 function getVSCodeThemeKind(): 'dark' | 'light' { 899 const themeName = vscode.window.activeColorTheme.name || ''; 900 if (themeName.includes('Aesthetic Computer')) { 901 if (themeName.includes('Light')) return 'light'; 902 if (themeName.includes('Dark')) return 'dark'; 903 } 904 const kind = vscode.window.activeColorTheme.kind; 905 return (kind === 1 || kind === 4) ? 'light' : 'dark'; 906 } 907 908 // Run a git command and return stdout (fast, ~5-15ms) 909 async function gitExec(args: string, cwd: string): Promise<string> { 910 await _modulesReady; 911 return new Promise((resolve, reject) => { 912 if (!cp) { reject(new Error('child_process not available')); return; } 913 cp.exec(`git ${args}`, { cwd, timeout: 5000, maxBuffer: 1024 * 512 }, (err: any, stdout: string) => { 914 if (err) reject(err); 915 else resolve(stdout); 916 }); 917 }); 918 } 919 920 // Get git status for a repo directory (with graceful rev-parse fallback) 921 async function getGitStatus(repoPath: string, name: string): Promise<GitRepoStatus> { 922 try { 923 const statusOut = await gitExec('status --porcelain', repoPath); 924 let branch = '(init)'; 925 try { 926 branch = (await gitExec('rev-parse --abbrev-ref HEAD', repoPath)).trim(); 927 } catch { 928 // No commits yet — rev-parse fails on fresh repos, use symbolic-ref 929 try { 930 const ref = (await gitExec('symbolic-ref --short HEAD', repoPath)).trim(); 931 branch = ref || '(init)'; 932 } catch { 933 branch = '(no commits)'; 934 } 935 } 936 const files = statusOut.split('\n').filter(Boolean).map(line => ({ 937 status: line.substring(0, 2).trim(), 938 file: line.substring(3), 939 })); 940 return { name, root: repoPath, branch, files }; 941 } catch (e: any) { 942 return { name, root: repoPath, branch: '?', files: [], error: e.message }; 943 } 944 } 945 946 // Get recent git log for a repo 947 async function getGitLog(repoPath: string, count: number = 8): Promise<{ hash: string; subject: string; author: string; date: string }[]> { 948 try { 949 const logOut = await gitExec(`log --oneline --format="%h|%s|%an|%cr" -${count}`, repoPath); 950 return logOut.split('\n').filter(Boolean).map(line => { 951 const [hash, subject, author, date] = line.split('|'); 952 return { hash, subject, author, date }; 953 }); 954 } catch { 955 return []; 956 } 957 } 958 959 // Gather status for all repos 960 async function getAllGitStatus(): Promise<{ repos: GitRepoStatus[]; logs: { name: string; commits: { hash: string; subject: string; author: string; date: string }[] }[] }> { 961 await _modulesReady; 962 const repos: { name: string; root: string }[] = []; 963 964 const wsFolder = vscode.workspace.workspaceFolders?.[0]; 965 if (wsFolder && fs && path) { 966 const acRoot = wsFolder.uri.fsPath; 967 try { 968 if (fs.existsSync(path.join(acRoot, '.git'))) { 969 repos.push({ name: 'aesthetic-computer', root: acRoot }); 970 } 971 } catch {} 972 try { 973 const vaultPath = path.join(acRoot, 'aesthetic-computer-vault'); 974 if (fs.statSync(vaultPath).isDirectory() && fs.existsSync(path.join(vaultPath, '.git'))) { 975 repos.push({ name: 'vault', root: vaultPath }); 976 } 977 } catch {} 978 } 979 980 const [statuses, logs] = await Promise.all([ 981 Promise.all(repos.map(r => getGitStatus(r.root, r.name))), 982 Promise.all(repos.map(async r => ({ name: r.name, commits: await getGitLog(r.root) }))), 983 ]); 984 return { repos: statuses, logs }; 985 } 986 987 // AT Protocol firehose — poll PDS for recent records across all collections 988 const AT_COLLECTIONS = [ 989 { collection: 'computer.aesthetic.tape', label: 'Tape', icon: '\u{1F3AC}' }, 990 { collection: 'computer.aesthetic.mood', label: 'Mood', icon: '\u{1F30A}' }, 991 { collection: 'computer.aesthetic.painting', label: 'Painting', icon: '\u{1F3A8}' }, 992 { collection: 'computer.aesthetic.news', label: 'News', icon: '\u{1F4F0}' }, 993 { collection: 'computer.aesthetic.piece', label: 'Piece', icon: '\u{1F9E9}' }, 994 { collection: 'computer.aesthetic.kidlisp', label: 'KidLisp', icon: '\u{1F308}' }, 995 ]; 996 const PDS_URL = 'https://at.aesthetic.computer'; 997 const TANGLED_KNOT_URL = 'https://knot.aesthetic.computer'; 998 999 let firehoseEvents: FirehoseEvent[] = []; 1000 let tangledEvents: TangledEvent[] = []; 1001 let firehoseSeenIds = new Set<string>(); 1002 let tangledSeenIds = new Set<string>(); 1003 1004 async function fetchFirehoseEvents(): Promise<FirehoseEvent[]> { 1005 await _modulesReady; 1006 if (!http) return []; 1007 const events: FirehoseEvent[] = []; 1008 1009 // Fetch recent records from each collection on the PDS 1010 // We list records from the guest/system DID to show platform-wide activity 1011 for (const col of AT_COLLECTIONS) { 1012 try { 1013 const url = `${PDS_URL}/xrpc/com.atproto.sync.listRepos?limit=5`; 1014 // Instead, list records for known DIDs — use repo.listRecords with a broader approach 1015 // For now, use listRepos to discover DIDs, then sample records 1016 const reposJson = await httpGetJson(`${PDS_URL}/xrpc/com.atproto.sync.listRepos?limit=20`); 1017 if (!reposJson?.repos) continue; 1018 1019 for (const repo of reposJson.repos.slice(0, 5)) { 1020 try { 1021 const recordsJson = await httpGetJson( 1022 `${PDS_URL}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(repo.did)}&collection=${encodeURIComponent(col.collection)}&limit=3&reverse=true` 1023 ); 1024 if (!recordsJson?.records) continue; 1025 for (const rec of recordsJson.records) { 1026 const id = rec.uri || `${repo.did}:${col.collection}:${rec.cid}`; 1027 if (firehoseSeenIds.has(id)) continue; 1028 firehoseSeenIds.add(id); 1029 1030 const val = rec.value || {}; 1031 let summary = val.title || val.text || val.name || val.code || val.headline || col.label; 1032 if (summary.length > 80) summary = summary.substring(0, 77) + '...'; 1033 1034 events.push({ 1035 id, 1036 type: col.label.toLowerCase(), 1037 action: 'create', 1038 did: repo.did, 1039 handle: val.handle || repo.did.substring(0, 20), 1040 summary: `${col.icon} ${summary}`, 1041 timestamp: val.createdAt || val.when || new Date().toISOString(), 1042 url: val.uri || undefined, 1043 }); 1044 } 1045 } catch {} 1046 } 1047 } catch {} 1048 } 1049 1050 // Sort by timestamp descending 1051 events.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); 1052 return events.slice(0, 50); 1053 } 1054 1055 // Simple HTTPS GET → JSON helper using Node http/https 1056 function httpGetJson(url: string): Promise<any> { 1057 return new Promise((resolve) => { 1058 try { 1059 const https = require('https'); 1060 const req = https.get(url, { timeout: 8000 }, (res: any) => { 1061 let data = ''; 1062 res.on('data', (chunk: string) => { data += chunk; }); 1063 res.on('end', () => { 1064 try { resolve(JSON.parse(data)); } catch { resolve(null); } 1065 }); 1066 }); 1067 req.on('error', () => resolve(null)); 1068 req.on('timeout', () => { req.destroy(); resolve(null); }); 1069 } catch { resolve(null); } 1070 }); 1071 } 1072 1073 async function fetchTangledEvents(): Promise<TangledEvent[]> { 1074 await _modulesReady; 1075 const events: TangledEvent[] = []; 1076 1077 // Query the Tangled knot server for recent repo activity 1078 try { 1079 // Try the knot's repo endpoint for recent activity 1080 const repoInfo = await httpGetJson(`${TANGLED_KNOT_URL}/aesthetic.computer/core/info/refs?service=git-upload-pack`); 1081 // The knot exposes a REST-ish API — try listing recent commits via the appview 1082 const tangledCommits = await httpGetJson(`https://tangled.org/api/repos/aesthetic.computer/core/commits?limit=10`); 1083 if (tangledCommits && Array.isArray(tangledCommits)) { 1084 for (const commit of tangledCommits) { 1085 const id = commit.sha || commit.hash || commit.id; 1086 if (!id || tangledSeenIds.has(id)) continue; 1087 tangledSeenIds.add(id); 1088 events.push({ 1089 id, 1090 type: 'push', 1091 repo: 'aesthetic.computer/core', 1092 author: commit.author?.name || commit.author || 'unknown', 1093 summary: commit.message || commit.subject || '(no message)', 1094 timestamp: commit.date || commit.timestamp || new Date().toISOString(), 1095 commitHash: id.substring(0, 7), 1096 url: `https://tangled.org/aesthetic.computer/core/commit/${id}`, 1097 }); 1098 } 1099 } 1100 } catch {} 1101 1102 // Also try the git log locally if the tangled remote is configured 1103 try { 1104 const wsFolder = vscode.workspace.workspaceFolders?.[0]; 1105 if (wsFolder && path) { 1106 const acRoot = wsFolder.uri.fsPath; 1107 // Check if tangled remote exists 1108 const remotes = await gitExec('remote -v', acRoot); 1109 if (remotes.includes('tangled') || remotes.includes('knot')) { 1110 const remoteName = remotes.includes('tangled') ? 'tangled' : 'knot'; 1111 try { 1112 await gitExec(`fetch ${remoteName} --no-tags`, acRoot); 1113 } catch {} 1114 const logOut = await gitExec(`log ${remoteName}/main --oneline --format="%h|%s|%an|%cr" -10`, acRoot); 1115 for (const line of logOut.split('\n').filter(Boolean)) { 1116 const [hash, subject, author, date] = line.split('|'); 1117 const id = `tangled:${hash}`; 1118 if (tangledSeenIds.has(id)) continue; 1119 tangledSeenIds.add(id); 1120 events.push({ 1121 id, 1122 type: 'push', 1123 repo: 'aesthetic.computer/core', 1124 author, 1125 summary: subject, 1126 timestamp: date, 1127 commitHash: hash, 1128 url: `https://tangled.org/aesthetic.computer/core`, 1129 }); 1130 } 1131 } 1132 } 1133 } catch {} 1134 1135 return events; 1136 } 1137 1138 // Send all dashboard data to the welcome panel 1139 let dashboardPending = false; 1140 async function sendDashboardData() { 1141 if (!welcomePanel || dashboardPending) return; 1142 dashboardPending = true; 1143 try { 1144 const gitData = await getAllGitStatus(); 1145 if (welcomePanel) { 1146 welcomePanel.webview.postMessage({ command: 'gitStatus', repos: gitData.repos, logs: gitData.logs }); 1147 } 1148 } finally { 1149 dashboardPending = false; 1150 } 1151 } 1152 1153 // Fetch and send firehose data (separate from git for independent refresh) 1154 let firehosePending = false; 1155 async function sendFirehoseData() { 1156 if (!welcomePanel || firehosePending) return; 1157 firehosePending = true; 1158 try { 1159 const events = await fetchFirehoseEvents(); 1160 if (events.length > 0) { 1161 firehoseEvents = events; 1162 } 1163 if (welcomePanel) { 1164 welcomePanel.webview.postMessage({ command: 'firehose', events: firehoseEvents }); 1165 } 1166 } finally { 1167 firehosePending = false; 1168 } 1169 } 1170 1171 // Fetch and send Tangled data 1172 let tangledPending = false; 1173 async function sendTangledData() { 1174 if (!welcomePanel || tangledPending) return; 1175 tangledPending = true; 1176 try { 1177 const events = await fetchTangledEvents(); 1178 if (events.length > 0) { 1179 tangledEvents = events; 1180 } 1181 if (welcomePanel) { 1182 welcomePanel.webview.postMessage({ command: 'tangled', events: tangledEvents }); 1183 } 1184 } finally { 1185 tangledPending = false; 1186 } 1187 } 1188 1189 function getDashboardHtml(): string { 1190 const theme = getVSCodeThemeKind(); 1191 const c = theme === 'light' 1192 ? { bg: '#fcf7c5', fg: '#281e5a', fgMuted: '#907070', fgDim: '#b0a080', accent: '#387adf', 1193 added: '#1a7f37', modified: '#9a6700', deleted: '#cf222e', untracked: '#6e7781', 1194 fileBg: '#f6f0d0', fileBorder: '#e8e0b0', hoverBg: '#efe8c0', repoBg: '#f0e8b8', headerBg: '#e8dfa0', 1195 sectionBg: '#f2ecc0', badgeBg: '#e0d8a0', tapeBg: '#e8f0d8', moodBg: '#d8e8f0', paintBg: '#f0d8e8', 1196 newsBg: '#e8e0d8', commitBg: '#e0e8d8', liveDot: '#1a7f37', onlineBg: '#d8f0d8' } 1197 : { bg: '#181318', fg: '#e0d0e0', fgMuted: '#807080', fgDim: '#504050', accent: '#a87090', 1198 added: '#3fb950', modified: '#d29922', deleted: '#f85149', untracked: '#606870', 1199 fileBg: '#1e1820', fileBorder: '#2a2030', hoverBg: '#28202e', repoBg: '#201828', headerBg: '#1a1220', 1200 sectionBg: '#1a1520', badgeBg: '#2a2030', tapeBg: '#1a2818', moodBg: '#182028', paintBg: '#281828', 1201 newsBg: '#282018', commitBg: '#182818', liveDot: '#3fb950', onlineBg: '#1a2818' }; 1202 1203 return `<!DOCTYPE html> 1204<html lang="en"> 1205<head> 1206 <meta charset="UTF-8"> 1207 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 1208 <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src https://at.aesthetic.computer https://knot.aesthetic.computer https://tangled.org;"> 1209 <style> 1210 * { margin: 0; padding: 0; box-sizing: border-box; } 1211 body { background: ${c.bg}; color: ${c.fg}; font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', 'Consolas', monospace; font-size: 11px; height: 100vh; overflow: auto; padding: 0; } 1212 1213 /* Header */ 1214 .dash-header { padding: 12px 16px 10px; display: flex; align-items: center; gap: 8px; border-bottom: 1px solid ${c.fileBorder}; background: ${c.headerBg}; position: sticky; top: 0; z-index: 10; } 1215 .dash-header .star { font-size: 16px; color: ${c.accent}; } 1216 .dash-header .title { font-size: 12px; font-weight: 600; letter-spacing: 0.5px; } 1217 .dash-header .live { margin-left: auto; display: flex; align-items: center; gap: 5px; font-size: 10px; color: ${c.fgMuted}; } 1218 .dash-header .live-dot { width: 6px; height: 6px; border-radius: 50%; background: ${c.liveDot}; animation: livePulse 2s ease-in-out infinite; } 1219 @keyframes livePulse { 0%, 100% { opacity: 0.4; } 50% { opacity: 1; } } 1220 1221 /* Section nav tabs */ 1222 .section-tabs { display: flex; gap: 0; border-bottom: 1px solid ${c.fileBorder}; background: ${c.sectionBg}; position: sticky; top: 39px; z-index: 9; overflow-x: auto; } 1223 .section-tab { padding: 6px 12px; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; color: ${c.fgMuted}; cursor: pointer; border-bottom: 2px solid transparent; transition: all 0.15s; white-space: nowrap; display: flex; align-items: center; gap: 5px; } 1224 .section-tab:hover { color: ${c.fg}; background: ${c.hoverBg}; } 1225 .section-tab.active { color: ${c.accent}; border-bottom-color: ${c.accent}; } 1226 .section-tab .badge { background: ${c.badgeBg}; color: ${c.fgMuted}; padding: 1px 5px; border-radius: 8px; font-size: 9px; font-weight: 400; min-width: 16px; text-align: center; } 1227 1228 /* Sections */ 1229 .section { display: none; } 1230 .section.visible { display: block; } 1231 1232 /* Source Changes section */ 1233 .repo { margin: 0; } 1234 .repo-header { padding: 8px 16px 5px; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 1.5px; color: ${c.fgMuted}; background: ${c.repoBg}; border-bottom: 1px solid ${c.fileBorder}; display: flex; align-items: center; gap: 6px; } 1235 .repo-header .branch { font-weight: 400; text-transform: none; letter-spacing: 0; color: ${c.accent}; } 1236 .repo-header .count { font-weight: 400; text-transform: none; letter-spacing: 0; color: ${c.fgDim}; margin-left: auto; } 1237 .file-list { list-style: none; } 1238 .file-item { display: flex; align-items: center; gap: 6px; padding: 3px 16px; cursor: pointer; border-bottom: 1px solid ${c.fileBorder}; transition: background 0.1s; } 1239 .file-item:hover { background: ${c.hoverBg}; } 1240 .status { font-weight: 700; width: 16px; text-align: center; flex-shrink: 0; font-size: 10px; } 1241 .status.M { color: ${c.modified}; } 1242 .status.A { color: ${c.added}; } 1243 .status.D { color: ${c.deleted}; } 1244 .status.R { color: ${c.modified}; } 1245 .status.U { color: ${c.untracked}; } 1246 .status.q { color: ${c.untracked}; } 1247 .file-path { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } 1248 .file-dir { color: ${c.fgMuted}; } 1249 .file-name { color: ${c.fg}; } 1250 .empty { padding: 16px; text-align: center; color: ${c.fgMuted}; font-style: italic; font-size: 11px; } 1251 .error { padding: 10px 16px; color: ${c.deleted}; font-size: 10px; } 1252 1253 /* Git log */ 1254 .git-log { border-top: 1px solid ${c.fileBorder}; } 1255 .log-item { display: flex; align-items: center; gap: 6px; padding: 3px 16px; border-bottom: 1px solid ${c.fileBorder}; font-size: 10px; } 1256 .log-hash { color: ${c.accent}; font-weight: 600; flex-shrink: 0; } 1257 .log-subject { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: ${c.fg}; } 1258 .log-date { color: ${c.fgDim}; flex-shrink: 0; font-size: 9px; } 1259 1260 /* Firehose section */ 1261 .firehose-feed { max-height: none; } 1262 .feed-item { display: flex; align-items: flex-start; gap: 8px; padding: 6px 16px; border-bottom: 1px solid ${c.fileBorder}; transition: background 0.1s; animation: fadeIn 0.3s ease; } 1263 .feed-item:hover { background: ${c.hoverBg}; } 1264 .feed-item.new { background: ${c.onlineBg}; } 1265 @keyframes fadeIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } } 1266 .feed-icon { font-size: 14px; flex-shrink: 0; line-height: 1.4; } 1267 .feed-body { flex: 1; min-width: 0; } 1268 .feed-handle { color: ${c.accent}; font-weight: 600; font-size: 10px; } 1269 .feed-summary { color: ${c.fg}; margin-top: 1px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } 1270 .feed-time { color: ${c.fgDim}; font-size: 9px; flex-shrink: 0; align-self: center; } 1271 .feed-type-badge { display: inline-block; padding: 1px 5px; border-radius: 3px; font-size: 9px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; margin-right: 4px; } 1272 .feed-type-badge.tape { background: ${c.tapeBg}; color: ${c.added}; } 1273 .feed-type-badge.mood { background: ${c.moodBg}; color: ${c.accent}; } 1274 .feed-type-badge.painting { background: ${c.paintBg}; color: ${c.modified}; } 1275 .feed-type-badge.news { background: ${c.newsBg}; color: ${c.fg}; } 1276 .feed-type-badge.piece { background: ${c.sectionBg}; color: ${c.fgMuted}; } 1277 .feed-type-badge.kidlisp { background: ${c.tapeBg}; color: ${c.modified}; } 1278 1279 /* Tangled section */ 1280 .tangled-item { display: flex; align-items: flex-start; gap: 8px; padding: 6px 16px; border-bottom: 1px solid ${c.fileBorder}; transition: background 0.1s; } 1281 .tangled-item:hover { background: ${c.hoverBg}; } 1282 .tangled-hash { color: ${c.accent}; font-weight: 600; font-size: 10px; flex-shrink: 0; font-family: inherit; } 1283 .tangled-body { flex: 1; min-width: 0; } 1284 .tangled-msg { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: ${c.fg}; } 1285 .tangled-meta { color: ${c.fgDim}; font-size: 9px; margin-top: 1px; } 1286 1287 /* Filter bar */ 1288 .filter-bar { display: flex; gap: 4px; padding: 6px 16px; border-bottom: 1px solid ${c.fileBorder}; background: ${c.sectionBg}; flex-wrap: wrap; } 1289 .filter-pill { padding: 2px 8px; border-radius: 10px; font-size: 9px; cursor: pointer; border: 1px solid ${c.fileBorder}; color: ${c.fgMuted}; transition: all 0.15s; } 1290 .filter-pill:hover { border-color: ${c.accent}; color: ${c.fg}; } 1291 .filter-pill.active { background: ${c.accent}; color: ${c.bg}; border-color: ${c.accent}; } 1292 1293 /* Loading & status */ 1294 .loading { padding: 16px; text-align: center; color: ${c.fgMuted}; } 1295 .loading .dot { display: inline-block; animation: livePulse 1.5s ease-in-out infinite; color: ${c.accent}; } 1296 .status-line { padding: 4px 16px; font-size: 9px; color: ${c.fgDim}; text-align: right; border-bottom: 1px solid ${c.fileBorder}; } 1297 </style> 1298</head> 1299<body> 1300 <div class="dash-header"> 1301 <span class="star">✦</span> 1302 <span class="title">Dashboard</span> 1303 <span class="live"><span class="live-dot"></span>live</span> 1304 </div> 1305 1306 <div class="section-tabs"> 1307 <div class="section-tab active" data-section="source">Source <span class="badge" id="source-badge">0</span></div> 1308 <div class="section-tab" data-section="firehose">AT Firehose <span class="badge" id="firehose-badge">0</span></div> 1309 <div class="section-tab" data-section="tangled">Tangled <span class="badge" id="tangled-badge">0</span></div> 1310 </div> 1311 1312 <div id="section-source" class="section visible"> 1313 <div id="source-content"> 1314 <div class="loading"><span class="dot">✦</span> scanning repos...</div> 1315 </div> 1316 </div> 1317 1318 <div id="section-firehose" class="section"> 1319 <div class="filter-bar" id="firehose-filters"> 1320 <span class="filter-pill active" data-filter="all">All</span> 1321 <span class="filter-pill" data-filter="tape">Tape</span> 1322 <span class="filter-pill" data-filter="mood">Mood</span> 1323 <span class="filter-pill" data-filter="painting">Painting</span> 1324 <span class="filter-pill" data-filter="news">News</span> 1325 <span class="filter-pill" data-filter="piece">Piece</span> 1326 <span class="filter-pill" data-filter="kidlisp">KidLisp</span> 1327 </div> 1328 <div id="firehose-content" class="firehose-feed"> 1329 <div class="loading"><span class="dot">✦</span> connecting to PDS...</div> 1330 </div> 1331 </div> 1332 1333 <div id="section-tangled" class="section"> 1334 <div id="tangled-content"> 1335 <div class="loading"><span class="dot">✦</span> fetching knot activity...</div> 1336 </div> 1337 </div> 1338 1339 <script> 1340 (function() { 1341 const vscode = acquireVsCodeApi(); 1342 1343 // State 1344 let activeSection = 'source'; 1345 let activeFilter = 'all'; 1346 let firehoseEvents = []; 1347 let tangledEvents = []; 1348 let autoscroll = true; 1349 1350 // Tab switching 1351 document.querySelectorAll('.section-tab').forEach(tab => { 1352 tab.addEventListener('click', () => { 1353 document.querySelectorAll('.section-tab').forEach(t => t.classList.remove('active')); 1354 document.querySelectorAll('.section').forEach(s => s.classList.remove('visible')); 1355 tab.classList.add('active'); 1356 const section = tab.getAttribute('data-section'); 1357 activeSection = section; 1358 document.getElementById('section-' + section).classList.add('visible'); 1359 }); 1360 }); 1361 1362 // Filter pills 1363 document.getElementById('firehose-filters').addEventListener('click', (e) => { 1364 const pill = e.target.closest('.filter-pill'); 1365 if (!pill) return; 1366 document.querySelectorAll('#firehose-filters .filter-pill').forEach(p => p.classList.remove('active')); 1367 pill.classList.add('active'); 1368 activeFilter = pill.getAttribute('data-filter'); 1369 renderFirehose(); 1370 }); 1371 1372 // Auto-scroll detection 1373 document.addEventListener('scroll', () => { 1374 const el = document.scrollingElement || document.documentElement; 1375 autoscroll = (el.scrollHeight - el.scrollTop - el.clientHeight) < 50; 1376 }); 1377 1378 function esc(s) { 1379 return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); 1380 } 1381 1382 function timeAgo(ts) { 1383 if (!ts) return ''; 1384 // If it's already relative (like "2 hours ago"), return as-is 1385 if (typeof ts === 'string' && ts.includes('ago')) return ts; 1386 const diff = Date.now() - new Date(ts).getTime(); 1387 if (isNaN(diff)) return ts; 1388 const s = Math.floor(diff / 1000); 1389 if (s < 60) return s + 's ago'; 1390 const m = Math.floor(s / 60); 1391 if (m < 60) return m + 'm ago'; 1392 const h = Math.floor(m / 60); 1393 if (h < 24) return h + 'h ago'; 1394 const d = Math.floor(h / 24); 1395 return d + 'd ago'; 1396 } 1397 1398 function statusLabel(s) { 1399 if (s === '??' || s === '?') return { letter: '?', cls: 'q', title: 'Untracked' }; 1400 const ch = s.charAt(0) === ' ' ? s.charAt(1) : s.charAt(0); 1401 const map = { M: 'Modified', A: 'Added', D: 'Deleted', R: 'Renamed', C: 'Copied', U: 'Unmerged' }; 1402 return { letter: ch, cls: ch, title: map[ch] || ch }; 1403 } 1404 1405 // Render source changes + git log 1406 function renderSource(repos, logs) { 1407 const el = document.getElementById('source-content'); 1408 if (!repos || repos.length === 0) { 1409 el.innerHTML = '<div class="empty">No git repositories detected.</div>'; 1410 document.getElementById('source-badge').textContent = '0'; 1411 return; 1412 } 1413 1414 let totalChanges = 0; 1415 let html = ''; 1416 for (const repo of repos) { 1417 totalChanges += repo.files.length; 1418 html += '<div class="repo">'; 1419 html += '<div class="repo-header">'; 1420 html += '<span>' + esc(repo.name) + '</span>'; 1421 html += '<span class="branch">' + esc(repo.branch) + '</span>'; 1422 html += '<span class="count">' + repo.files.length + '</span>'; 1423 html += '</div>'; 1424 1425 if (repo.error) { 1426 html += '<div class="error">' + esc(repo.error) + '</div>'; 1427 } else if (repo.files.length === 0) { 1428 html += '<div class="empty">Clean</div>'; 1429 } else { 1430 html += '<ul class="file-list">'; 1431 for (const f of repo.files) { 1432 const s = statusLabel(f.status); 1433 const lastSlash = f.file.lastIndexOf('/'); 1434 const dir = lastSlash >= 0 ? f.file.substring(0, lastSlash + 1) : ''; 1435 const name = lastSlash >= 0 ? f.file.substring(lastSlash + 1) : f.file; 1436 html += '<li class="file-item" data-root="' + esc(repo.root) + '" data-file="' + esc(f.file) + '" title="' + esc(s.title + ': ' + f.file) + '">'; 1437 html += '<span class="status ' + s.cls + '">' + s.letter + '</span>'; 1438 html += '<span class="file-path"><span class="file-dir">' + esc(dir) + '</span><span class="file-name">' + esc(name) + '</span></span>'; 1439 html += '</li>'; 1440 } 1441 html += '</ul>'; 1442 } 1443 1444 // Recent commits 1445 const repoLog = logs && logs.find(l => l.name === repo.name); 1446 if (repoLog && repoLog.commits.length > 0) { 1447 html += '<div class="git-log">'; 1448 for (const c of repoLog.commits) { 1449 html += '<div class="log-item">'; 1450 html += '<span class="log-hash">' + esc(c.hash) + '</span>'; 1451 html += '<span class="log-subject">' + esc(c.subject) + '</span>'; 1452 html += '<span class="log-date">' + esc(c.date) + '</span>'; 1453 html += '</div>'; 1454 } 1455 html += '</div>'; 1456 } 1457 1458 html += '</div>'; 1459 } 1460 el.innerHTML = html; 1461 document.getElementById('source-badge').textContent = String(totalChanges); 1462 } 1463 1464 // Render firehose events 1465 function renderFirehose() { 1466 const el = document.getElementById('firehose-content'); 1467 const filtered = activeFilter === 'all' ? firehoseEvents : firehoseEvents.filter(e => e.type === activeFilter); 1468 1469 if (filtered.length === 0) { 1470 el.innerHTML = '<div class="empty">No events yet. Waiting for AT Protocol activity...</div>'; 1471 return; 1472 } 1473 1474 let html = ''; 1475 for (const evt of filtered) { 1476 html += '<div class="feed-item" data-url="' + esc(evt.url || '') + '">'; 1477 html += '<div class="feed-body">'; 1478 html += '<span class="feed-type-badge ' + esc(evt.type) + '">' + esc(evt.type) + '</span>'; 1479 if (evt.handle) html += '<span class="feed-handle">@' + esc(evt.handle) + '</span>'; 1480 html += '<div class="feed-summary">' + esc(evt.summary) + '</div>'; 1481 html += '</div>'; 1482 html += '<span class="feed-time">' + timeAgo(evt.timestamp) + '</span>'; 1483 html += '</div>'; 1484 } 1485 el.innerHTML = html; 1486 document.getElementById('firehose-badge').textContent = String(firehoseEvents.length); 1487 } 1488 1489 // Render Tangled events 1490 function renderTangled() { 1491 const el = document.getElementById('tangled-content'); 1492 if (tangledEvents.length === 0) { 1493 el.innerHTML = '<div class="empty">No Tangled knot activity detected.</div>'; 1494 document.getElementById('tangled-badge').textContent = '0'; 1495 return; 1496 } 1497 1498 let html = ''; 1499 for (const evt of tangledEvents) { 1500 html += '<div class="tangled-item" data-url="' + esc(evt.url || '') + '">'; 1501 if (evt.commitHash) html += '<span class="tangled-hash">' + esc(evt.commitHash) + '</span>'; 1502 html += '<div class="tangled-body">'; 1503 html += '<div class="tangled-msg">' + esc(evt.summary) + '</div>'; 1504 html += '<div class="tangled-meta">'; 1505 if (evt.author) html += esc(evt.author) + ' '; 1506 html += timeAgo(evt.timestamp); 1507 html += '</div>'; 1508 html += '</div>'; 1509 html += '</div>'; 1510 } 1511 el.innerHTML = html; 1512 document.getElementById('tangled-badge').textContent = String(tangledEvents.length); 1513 } 1514 1515 // Click handlers 1516 document.getElementById('source-content').addEventListener('click', (e) => { 1517 const item = e.target.closest('.file-item'); 1518 if (!item) return; 1519 const root = item.getAttribute('data-root'); 1520 const file = item.getAttribute('data-file'); 1521 if (root && file) vscode.postMessage({ command: 'openFile', root, file }); 1522 }); 1523 1524 document.getElementById('firehose-content').addEventListener('click', (e) => { 1525 const item = e.target.closest('.feed-item'); 1526 if (!item) return; 1527 const url = item.getAttribute('data-url'); 1528 if (url) vscode.postMessage({ command: 'openExternal', url }); 1529 }); 1530 1531 document.getElementById('tangled-content').addEventListener('click', (e) => { 1532 const item = e.target.closest('.tangled-item'); 1533 if (!item) return; 1534 const url = item.getAttribute('data-url'); 1535 if (url) vscode.postMessage({ command: 'openExternal', url }); 1536 }); 1537 1538 // Keyboard shortcuts 1539 document.addEventListener('keydown', (e) => { 1540 if (e.key === '1') { document.querySelector('[data-section="source"]').click(); } 1541 if (e.key === '2') { document.querySelector('[data-section="firehose"]').click(); } 1542 if (e.key === '3') { document.querySelector('[data-section="tangled"]').click(); } 1543 }); 1544 1545 // Message handling from extension 1546 window.addEventListener('message', (event) => { 1547 const msg = event.data; 1548 if (msg.command === 'gitStatus') { 1549 renderSource(msg.repos, msg.logs); 1550 } else if (msg.command === 'firehose') { 1551 firehoseEvents = msg.events || []; 1552 renderFirehose(); 1553 } else if (msg.command === 'tangled') { 1554 tangledEvents = msg.events || []; 1555 renderTangled(); 1556 } 1557 }); 1558 1559 // Request initial data 1560 vscode.postMessage({ command: 'requestDashboard' }); 1561 })(); 1562 </script> 1563</body> 1564</html>`; 1565 } 1566 1567 // ✦ Dashboard Panel — git status + AT firehose + Tangled knot 1568 function showWelcomePanel() { 1569 if (welcomePanel) { 1570 welcomePanel.reveal(vscode.ViewColumn.One); 1571 return; 1572 } 1573 1574 welcomePanel = vscode.window.createWebviewPanel( 1575 "aestheticWelcome", 1576 "✦ Dashboard", 1577 vscode.ViewColumn.One, 1578 { enableScripts: true } 1579 ); 1580 1581 welcomePanel.webview.onDidReceiveMessage( 1582 message => { 1583 switch (message.command) { 1584 case 'requestDashboard': 1585 sendDashboardData(); 1586 sendFirehoseData(); 1587 sendTangledData(); 1588 return; 1589 case 'requestGitStatus': 1590 sendDashboardData(); 1591 return; 1592 case 'openFile': 1593 if (message.root && message.file) { 1594 const filePath = path?.join(message.root, message.file); 1595 if (filePath) { 1596 const openPath = vscode.Uri.file(filePath); 1597 vscode.workspace.openTextDocument(openPath).then( 1598 doc => vscode.window.showTextDocument(doc), 1599 () => {} 1600 ); 1601 } 1602 } 1603 return; 1604 case 'openExternal': 1605 if (message.url) { 1606 vscode.env.openExternal(vscode.Uri.parse(message.url)); 1607 } 1608 return; 1609 } 1610 }, 1611 undefined, 1612 context.subscriptions 1613 ); 1614 1615 welcomePanel.onDidDispose(() => { 1616 welcomePanel = null; 1617 if (firehoseInterval) { clearInterval(firehoseInterval); firehoseInterval = undefined; } 1618 if (tangledInterval) { clearInterval(tangledInterval); tangledInterval = undefined; } 1619 }); 1620 1621 welcomePanel.webview.html = getDashboardHtml(); 1622 1623 // Send initial data 1624 setTimeout(() => { 1625 sendDashboardData(); 1626 sendFirehoseData(); 1627 sendTangledData(); 1628 }, 100); 1629 1630 // Poll firehose every 30 seconds, Tangled every 60 seconds 1631 firehoseInterval = setInterval(() => sendFirehoseData(), 30000); 1632 tangledInterval = setInterval(() => sendTangledData(), 60000); 1633 } 1634 1635 let firehoseInterval: NodeJS.Timeout | undefined; 1636 let tangledInterval: NodeJS.Timeout | undefined; 1637 1638 // Refresh welcome panel on theme change 1639 function refreshWelcomePanel() { 1640 if (welcomePanel) { 1641 welcomePanel.webview.html = getDashboardHtml(); 1642 setTimeout(() => { 1643 sendDashboardData(); 1644 sendFirehoseData(); 1645 sendTangledData(); 1646 }, 100); 1647 } 1648 } 1649 1650 // Watch for file changes in both repos to auto-refresh git status 1651 function setupGitWatchers() { 1652 const wsFolder = vscode.workspace.workspaceFolders?.[0]; 1653 if (!wsFolder) return; 1654 1655 let refreshTimer: NodeJS.Timeout | undefined; 1656 function debouncedRefresh() { 1657 if (refreshTimer) clearTimeout(refreshTimer); 1658 refreshTimer = setTimeout(() => sendDashboardData(), 300); 1659 } 1660 1661 const watcher = vscode.workspace.createFileSystemWatcher( 1662 new vscode.RelativePattern(wsFolder, '**/*') 1663 ); 1664 watcher.onDidChange(debouncedRefresh); 1665 watcher.onDidCreate(debouncedRefresh); 1666 watcher.onDidDelete(debouncedRefresh); 1667 context.subscriptions.push(watcher); 1668 1669 context.subscriptions.push( 1670 vscode.workspace.onDidSaveTextDocument(() => debouncedRefresh()) 1671 ); 1672 } 1673 1674 setupGitWatchers(); 1675 1676 // Listen for VS Code theme changes and refresh welcome panel 1677 context.subscriptions.push( 1678 vscode.window.onDidChangeActiveColorTheme(() => { 1679 refreshWelcomePanel(); 1680 }) 1681 ); 1682 1683 context.subscriptions.push( 1684 vscode.commands.registerCommand("aestheticComputer.welcome", showWelcomePanel) 1685 ); 1686 1687 // Show welcome on startup if no editors are open 1688 if (vscode.window.visibleTextEditors.length === 0) { 1689 setTimeout(() => { 1690 if (vscode.window.visibleTextEditors.length === 0 && !welcomePanel) { 1691 showWelcomePanel(); 1692 } 1693 }, 500); 1694 } 1695 1696 // � User Handle Status Bar 1697 let statusBarUser: vscode.StatusBarItem; 1698 1699 function updateUserStatusBar() { 1700 if (!statusBarUser) { 1701 statusBarUser = vscode.window.createStatusBarItem( 1702 vscode.StatusBarAlignment.Right, 1703 50, 1704 ); 1705 statusBarUser.command = "aestheticComputer.login"; 1706 context.subscriptions.push(statusBarUser); 1707 } 1708 1709 // Check for aesthetic session 1710 const aestheticSession = context.globalState.get<any>("aesthetic:session"); 1711 const sotceSession = context.globalState.get<any>("sotce:session"); 1712 1713 if (aestheticSession?.account?.label) { 1714 const label = aestheticSession.account.label; 1715 const displayLabel = label.startsWith("@") ? label : `@${label}`; 1716 statusBarUser.text = `$(account) ${displayLabel}`; 1717 statusBarUser.tooltip = `Logged in to Aesthetic Computer as ${displayLabel}\nClick to manage account`; 1718 statusBarUser.backgroundColor = undefined; 1719 statusBarUser.show(); 1720 } else if (sotceSession?.account?.label) { 1721 const label = sotceSession.account.label; 1722 const displayLabel = label.startsWith("@") ? label : `@${label}`; 1723 statusBarUser.text = `$(account) ${displayLabel}`; 1724 statusBarUser.tooltip = `Logged in to Sotce Net as ${displayLabel}\nClick to manage account`; 1725 statusBarUser.backgroundColor = undefined; 1726 statusBarUser.show(); 1727 } else { 1728 statusBarUser.text = `$(sign-in) Sign In`; 1729 statusBarUser.tooltip = `Sign in to Aesthetic Computer`; 1730 statusBarUser.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground'); 1731 statusBarUser.show(); 1732 } 1733 } 1734 1735 // Initialize user status bar 1736 updateUserStatusBar(); 1737 1738 // 🔥 AC-OS OTA Build Status Bar 1739 // Polls oven.aesthetic.computer/native-build to show live OTA build status. 1740 let statusBarOTA: vscode.StatusBarItem; 1741 let otaPollInterval: NodeJS.Timeout | undefined; 1742 1743 interface OTABuildState { 1744 activeJobId: string | null; 1745 active: { 1746 id: string; 1747 ref: string; 1748 status: string; 1749 stage: string; 1750 percent: number; 1751 elapsedMs: number; 1752 error?: string; 1753 buildName?: string; 1754 commitMsg?: string; 1755 } | null; 1756 recent: Array<{ 1757 id: string; 1758 ref: string; 1759 status: string; 1760 stage: string; 1761 percent: number; 1762 elapsedMs: number; 1763 finishedAt?: string; 1764 error?: string; 1765 buildName?: string; 1766 commitMsg?: string; 1767 flags?: string[]; 1768 }>; 1769 } 1770 1771 const OTA_STAGES: Record<string, { icon: string; label: string; step: number }> = { 1772 'preflight-sync': { icon: '$(repo-sync~spin)', label: 'sync', step: 1 }, 1773 'prune': { icon: '$(trash~spin)', label: 'prune', step: 1 }, 1774 'docker-build': { icon: '$(container~spin)', label: 'docker', step: 2 }, 1775 'binary': { icon: '$(gear~spin)', label: 'binary', step: 3 }, 1776 'initramfs': { icon: '$(package~spin)', label: 'initramfs', step: 4 }, 1777 'kernel': { icon: '$(cpu~spin)', label: 'kernel', step: 5 }, 1778 'smoke-test': { icon: '$(beaker~spin)', label: 'test', step: 6 }, 1779 'upload': { icon: '$(cloud-upload~spin)', label: 'upload', step: 7 }, 1780 'done': { icon: '$(check)', label: 'done', step: 8 }, 1781 }; 1782 const OTA_TOTAL_STEPS = 7; 1783 1784 function getOTAIcon(stage: string, status: string): string { 1785 if (status === 'running') { 1786 return OTA_STAGES[stage]?.icon || '$(sync~spin)'; 1787 } 1788 switch (status) { 1789 case 'success': return '$(check)'; 1790 case 'failed': return '$(error)'; 1791 case 'cancelled': return '$(close)'; 1792 default: return '$(flame)'; 1793 } 1794 } 1795 1796 function getOTAColor(status: string): vscode.ThemeColor | undefined { 1797 switch (status) { 1798 case 'running': return new vscode.ThemeColor('statusBarItem.warningBackground'); 1799 case 'failed': return new vscode.ThemeColor('statusBarItem.errorBackground'); 1800 default: return undefined; 1801 } 1802 } 1803 1804 async function fetchOTAStatus(): Promise<OTABuildState | null> { 1805 try { 1806 const https = await import("https"); 1807 return new Promise((resolve) => { 1808 const req = https.request({ 1809 hostname: 'oven.aesthetic.computer', 1810 path: '/native-build', 1811 method: 'GET', 1812 headers: { 'User-Agent': 'aesthetic-computer-vscode' }, 1813 timeout: 5000, 1814 }, (res: any) => { 1815 let data = ''; 1816 res.on('data', (chunk: string) => data += chunk); 1817 res.on('end', () => { 1818 try { resolve(JSON.parse(data)); } 1819 catch { resolve(null); } 1820 }); 1821 }); 1822 req.on('error', () => resolve(null)); 1823 req.on('timeout', () => { req.destroy(); resolve(null); }); 1824 req.end(); 1825 }); 1826 } catch { 1827 return null; 1828 } 1829 } 1830 1831 async function updateOTAStatusBar() { 1832 if (!statusBarOTA) { 1833 statusBarOTA = vscode.window.createStatusBarItem( 1834 vscode.StatusBarAlignment.Left, 1835 99, 1836 ); 1837 statusBarOTA.command = "aestheticComputer.openOSPage"; 1838 context.subscriptions.push(statusBarOTA); 1839 } 1840 1841 const state = await fetchOTAStatus(); 1842 if (!state) { 1843 statusBarOTA.text = '$(flame) OTA'; 1844 statusBarOTA.tooltip = 'Could not reach oven.aesthetic.computer'; 1845 statusBarOTA.backgroundColor = undefined; 1846 statusBarOTA.show(); 1847 return; 1848 } 1849 1850 if (state.active) { 1851 const a = state.active; 1852 const elapsed = Math.round(a.elapsedMs / 1000); 1853 const mins = Math.floor(elapsed / 60); 1854 const secs = elapsed % 60; 1855 const timeStr = mins > 0 ? `${mins}m${secs}s` : `${secs}s`; 1856 const name = a.buildName || a.ref?.substring(0, 7) || ''; 1857 const variant = a.stage?.startsWith('cl-') ? ' CL' : ' C'; 1858 const rawStage = a.stage?.replace('cl-', '') || ''; 1859 const stageInfo = OTA_STAGES[rawStage]; 1860 const stageLabel = stageInfo?.label || rawStage; 1861 const stepNum = stageInfo?.step || 0; 1862 const progress = a.percent > 0 ? ` ${a.percent}%` : ''; 1863 const stepStr = stepNum > 0 ? ` [${stepNum}/${OTA_TOTAL_STEPS}]` : ''; 1864 statusBarOTA.text = `${getOTAIcon(a.stage, 'running')} ${name}${variant} | ${stageLabel}${progress}${stepStr} | ${timeStr}`; 1865 statusBarOTA.backgroundColor = getOTAColor('running'); 1866 let tip = `AC-OS Building: ${name}\nVariant:${variant}\nStage: ${stageLabel}${progress}${stepStr}\nElapsed: ${timeStr}`; 1867 if (a.commitMsg) tip += `\n${a.commitMsg}`; 1868 if (a.ref) tip += `\nCommit: ${a.ref.substring(0, 11)}`; 1869 statusBarOTA.tooltip = tip; 1870 } else if (state.recent?.length > 0) { 1871 const r = state.recent[0]; 1872 const name = r.buildName || r.ref?.substring(0, 7) || ''; 1873 const elapsed = Math.round(r.elapsedMs / 1000); 1874 const mins = Math.floor(elapsed / 60); 1875 const timeStr = mins > 0 ? `${mins}m${elapsed % 60}s` : `${elapsed}s`; 1876 const time = r.finishedAt ? new Date(r.finishedAt).toLocaleTimeString() : ''; 1877 const icon = getOTAIcon(r.stage, r.status); 1878 statusBarOTA.text = r.status === 'success' 1879 ? `${icon} ${name} | ${timeStr}` 1880 : `${icon} ${name} ${r.status}`; 1881 statusBarOTA.backgroundColor = getOTAColor(r.status); 1882 let tip = `AC-OS: ${r.status}${name}`; 1883 if (r.commitMsg) tip += `\n${r.commitMsg}`; 1884 tip += `\nBuild time: ${timeStr}`; 1885 if (time) tip += ` | Finished: ${time}`; 1886 if (r.error) tip += `\nError: ${r.error}`; 1887 statusBarOTA.tooltip = tip; 1888 } 1889 1890 statusBarOTA.show(); 1891 adjustOTAPollRate(state); 1892 } 1893 1894 let otaPollRate = 30000; 1895 function adjustOTAPollRate(state: OTABuildState | null) { 1896 const isActive = !!state?.active; 1897 const newRate = isActive ? 3000 : 30000; // 3s when building, 30s idle 1898 if (newRate !== otaPollRate) { 1899 otaPollRate = newRate; 1900 startOTAPolling(); 1901 } 1902 } 1903 1904 function startOTAPolling() { 1905 if (otaPollInterval) clearInterval(otaPollInterval); 1906 otaPollInterval = setInterval(() => updateOTAStatusBar(), otaPollRate); 1907 } 1908 1909 updateOTAStatusBar(); 1910 startOTAPolling(); 1911 1912 context.subscriptions.push( 1913 vscode.commands.registerCommand("aestheticComputer.showOTADetails", async () => { 1914 const state = await fetchOTAStatus(); 1915 if (!state) { 1916 vscode.window.showWarningMessage('Could not reach oven.aesthetic.computer'); 1917 return; 1918 } 1919 1920 const items: vscode.QuickPickItem[] = []; 1921 1922 // Active build at top 1923 if (state.active) { 1924 const a = state.active; 1925 const name = a.buildName || a.ref?.substring(0, 7) || 'building'; 1926 items.push({ 1927 label: `$(sync~spin) ${name}`, 1928 description: `${a.stage} ${a.percent}%`, 1929 detail: a.commitMsg || `ref: ${a.ref?.substring(0, 11) || '?'}`, 1930 }); 1931 } 1932 1933 // Recent builds 1934 for (const r of state.recent || []) { 1935 const name = r.buildName || r.ref?.substring(0, 7) || '?'; 1936 const icon = r.status === 'success' ? '$(check)' : r.status === 'failed' ? '$(error)' : '$(close)'; 1937 const elapsed = Math.round(r.elapsedMs / 1000); 1938 const time = r.finishedAt ? new Date(r.finishedAt).toLocaleTimeString() : ''; 1939 items.push({ 1940 label: `${icon} ${name}`, 1941 description: `${r.status}${elapsed}s${time ? ' — ' + time : ''}`, 1942 detail: r.commitMsg || (r.error ? `Error: ${r.error}` : `ref: ${r.ref?.substring(0, 11) || '?'}`), 1943 }); 1944 } 1945 1946 // Action items at bottom 1947 items.push( 1948 { label: '', kind: vscode.QuickPickItemKind.Separator }, 1949 { label: '$(globe) Open Oven Dashboard', description: 'oven.aesthetic.computer' }, 1950 { label: '$(cloud-download) Pull & Flash USB', description: 'ac-os pull' }, 1951 { label: '$(refresh) Refresh', description: '' }, 1952 ); 1953 1954 const pick = await vscode.window.showQuickPick(items, { 1955 title: 'AC-OS OTA Builds', 1956 placeHolder: 'Select a build or action...', 1957 }); 1958 1959 if (pick?.label.includes('Open Oven')) { 1960 vscode.env.openExternal(vscode.Uri.parse('https://oven.aesthetic.computer/native-build')); 1961 } else if (pick?.label.includes('Pull & Flash')) { 1962 const terminal = vscode.window.createTerminal('AC-OS Flash'); 1963 terminal.show(); 1964 terminal.sendText('fedac/native/ac-os pull'); 1965 } else if (pick?.label.includes('Refresh')) { 1966 updateOTAStatusBar(); 1967 } 1968 }) 1969 ); 1970 1971 1972 context.subscriptions.push( 1973 vscode.commands.registerCommand("aestheticComputer.openOSPage", () => { 1974 vscode.env.openExternal(vscode.Uri.parse('https://prompt.ac/os')); 1975 }) 1976 ); 1977 1978 // 🔒 Vault Unlock Status Bar 1979 // Only shown when an aesthetic-computer-vault directory exists in the workspace. 1980 // Click → passphrase input → imports GPG key if missing, warms gpg-agent, 1981 // runs vault-tool.fish unlock + devault.fish. Passphrase persists via SecretStorage. 1982 let statusBarVault: vscode.StatusBarItem | undefined; 1983 1984 function findVaultDir(): string | undefined { 1985 const folders = vscode.workspace.workspaceFolders; 1986 if (!folders || !path || !fs) return undefined; 1987 for (const f of folders) { 1988 const candidate = path.join(f.uri.fsPath, "aesthetic-computer-vault"); 1989 if (fs.existsSync(path.join(candidate, "vault-tool.fish"))) return candidate; 1990 } 1991 return undefined; 1992 } 1993 1994 function findPrivateKeyFile(vaultDir: string): string | undefined { 1995 if (!path || !fs) return undefined; 1996 const root = path.dirname(vaultDir); 1997 const candidates = [ 1998 path.join(root, "jeffrey-private.asc"), 1999 path.join(vaultDir, "gpg", "jeffrey-private.asc"), 2000 ]; 2001 for (const c of candidates) if (fs.existsSync(c)) return c; 2002 return undefined; 2003 } 2004 2005 function isSecretKeyImported(): boolean { 2006 if (!cp) return false; 2007 try { 2008 const r = cp.spawnSync("gpg", ["--list-secret-keys", "mail@aesthetic.computer"], { encoding: "utf8" }); 2009 return r.status === 0 && typeof r.stdout === "string" && r.stdout.includes("mail@aesthetic.computer"); 2010 } catch { return false; } 2011 } 2012 2013 function isVaultUnlocked(vaultDir: string): boolean { 2014 if (!fs || !path) return false; 2015 // Consider unlocked if the home/.ssh/id_rsa plaintext exists (devault has run) 2016 try { return fs.existsSync(path.join(vaultDir, "home/.ssh/id_rsa")); } catch { return false; } 2017 } 2018 2019 async function runVaultUnlock(passphrase: string, vaultDir: string): Promise<string> { 2020 const os = await import("os"); 2021 const gnupgDir = path.join(os.homedir(), ".gnupg"); 2022 fs.mkdirSync(gnupgDir, { recursive: true, mode: 0o700 }); 2023 2024 const agentConf = path.join(gnupgDir, "gpg-agent.conf"); 2025 const existingAgent = fs.existsSync(agentConf) ? fs.readFileSync(agentConf, "utf8") : ""; 2026 if (!existingAgent.includes("allow-loopback-pinentry")) { 2027 fs.appendFileSync(agentConf, (existingAgent.endsWith("\n") || !existingAgent ? "" : "\n") + "allow-loopback-pinentry\n"); 2028 try { cp.execSync("gpgconf --kill gpg-agent", { stdio: "ignore" }); } catch {} 2029 } 2030 const gpgConf = path.join(gnupgDir, "gpg.conf"); 2031 const existingGpg = fs.existsSync(gpgConf) ? fs.readFileSync(gpgConf, "utf8") : ""; 2032 if (!existingGpg.includes("pinentry-mode loopback")) { 2033 fs.appendFileSync(gpgConf, (existingGpg.endsWith("\n") || !existingGpg ? "" : "\n") + "pinentry-mode loopback\n"); 2034 } 2035 2036 if (!isSecretKeyImported()) { 2037 const keyFile = findPrivateKeyFile(vaultDir); 2038 if (!keyFile) throw new Error("No GPG private key to import. Place jeffrey-private.asc in the repo root."); 2039 const imp = cp.spawnSync("gpg", [ 2040 "--batch", "--yes", "--passphrase-fd", "0", "--pinentry-mode", "loopback", "--import", keyFile, 2041 ], { input: passphrase, encoding: "utf8" }); 2042 if (imp.status !== 0) throw new Error(`gpg import failed: ${imp.stderr || imp.stdout}`); 2043 } 2044 2045 // Verify passphrase by decrypting a known vault file to a temp location 2046 const testGpg = path.join(vaultDir, "home/.ssh/id_rsa.gpg"); 2047 if (fs.existsSync(testGpg)) { 2048 const test = cp.spawnSync("gpg", [ 2049 "--batch", "--yes", "--passphrase-fd", "0", "--pinentry-mode", "loopback", "--decrypt", testGpg, 2050 ], { input: passphrase, encoding: "utf8" }); 2051 if (test.status !== 0) throw new Error("Passphrase incorrect (test decrypt failed)."); 2052 } 2053 2054 // Warm gpg-agent cache so vault-tool.fish's batch decrypts don't prompt. 2055 try { 2056 const kg = cp.execSync( 2057 "gpg --with-keygrip --list-secret-keys mail@aesthetic.computer | awk '/Keygrip/ {print $3; exit}'", 2058 { encoding: "utf8" } 2059 ).trim(); 2060 if (kg) { 2061 const hexPass = Buffer.from(passphrase, "utf8").toString("hex").toUpperCase(); 2062 cp.execSync(`gpg-connect-agent "PRESET_PASSPHRASE ${kg} -1 ${hexPass}" /bye`, { stdio: "ignore" }); 2063 } 2064 } catch {} 2065 2066 // Run vault-tool.fish unlock then devault.fish (which distributes secrets to parent repo). 2067 const unlock = cp.spawnSync("fish", [path.join(vaultDir, "vault-tool.fish"), "unlock"], { 2068 encoding: "utf8", cwd: vaultDir, 2069 env: { ...process.env, GPG_TTY: "" }, 2070 }); 2071 if (unlock.status !== 0) throw new Error(`vault unlock failed: ${(unlock.stderr || "") + (unlock.stdout || "")}`.slice(0, 500)); 2072 2073 const devault = cp.spawnSync("fish", [path.join(vaultDir, "devault.fish")], { 2074 encoding: "utf8", cwd: vaultDir, 2075 env: { ...process.env, GPG_TTY: "" }, 2076 }); 2077 if (devault.status !== 0) throw new Error(`devault failed: ${(devault.stderr || "") + (devault.stdout || "")}`.slice(0, 500)); 2078 2079 return "Vault unlocked and distributed."; 2080 } 2081 2082 async function updateVaultStatusBar() { 2083 await _modulesReady; 2084 const vaultDir = findVaultDir(); 2085 if (!vaultDir) { statusBarVault?.hide(); return; } 2086 if (!statusBarVault) { 2087 statusBarVault = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 48); 2088 context.subscriptions.push(statusBarVault); 2089 } 2090 if (isVaultUnlocked(vaultDir)) { 2091 statusBarVault.text = "$(unlock) Vault"; 2092 statusBarVault.tooltip = "Aesthetic Computer vault is unlocked. Click to lock."; 2093 statusBarVault.command = "aestheticComputer.vaultLock"; 2094 statusBarVault.backgroundColor = undefined; 2095 } else { 2096 statusBarVault.text = "$(lock) Vault"; 2097 statusBarVault.tooltip = "Aesthetic Computer vault is locked. Click to unlock."; 2098 statusBarVault.command = "aestheticComputer.vaultUnlock"; 2099 statusBarVault.backgroundColor = new vscode.ThemeColor("statusBarItem.warningBackground"); 2100 } 2101 statusBarVault.show(); 2102 } 2103 2104 context.subscriptions.push( 2105 vscode.commands.registerCommand("aestheticComputer.vaultUnlock", async () => { 2106 await _modulesReady; 2107 const vaultDir = findVaultDir(); 2108 if (!vaultDir) { vscode.window.showErrorMessage("No aesthetic-computer-vault found in workspace."); return; } 2109 2110 let pass = await context.secrets.get("aestheticComputer.vaultPassphrase"); 2111 if (!pass) { 2112 pass = await vscode.window.showInputBox({ 2113 prompt: "Vault passphrase", 2114 password: true, 2115 ignoreFocusOut: true, 2116 placeHolder: "Enter GPG passphrase for mail@aesthetic.computer", 2117 }); 2118 if (!pass) return; 2119 } 2120 2121 try { 2122 const msg = await vscode.window.withProgress({ 2123 location: vscode.ProgressLocation.Notification, 2124 title: "Unlocking vault…", 2125 cancellable: false, 2126 }, async () => runVaultUnlock(pass!, vaultDir)); 2127 await context.secrets.store("aestheticComputer.vaultPassphrase", pass); 2128 vscode.window.showInformationMessage("🔓 " + msg); 2129 } catch (err: any) { 2130 // Clear stored passphrase if it was wrong so the next click re-prompts. 2131 if (String(err?.message || "").includes("Passphrase incorrect")) { 2132 await context.secrets.delete("aestheticComputer.vaultPassphrase"); 2133 } 2134 vscode.window.showErrorMessage("Vault unlock failed: " + (err?.message || err)); 2135 } finally { 2136 updateVaultStatusBar(); 2137 } 2138 }) 2139 ); 2140 2141 context.subscriptions.push( 2142 vscode.commands.registerCommand("aestheticComputer.vaultLock", async () => { 2143 await _modulesReady; 2144 const vaultDir = findVaultDir(); 2145 if (!vaultDir) return; 2146 const choice = await vscode.window.showWarningMessage( 2147 "Lock the vault? This will re-encrypt and shred plaintext secrets.", 2148 { modal: true }, "Lock" 2149 ); 2150 if (choice !== "Lock") return; 2151 try { 2152 await vscode.window.withProgress({ 2153 location: vscode.ProgressLocation.Notification, 2154 title: "Locking vault…", 2155 cancellable: false, 2156 }, async () => { 2157 const r = cp.spawnSync("fish", [path.join(vaultDir, "vault-tool.fish"), "lock"], { 2158 encoding: "utf8", cwd: vaultDir, 2159 }); 2160 if (r.status !== 0) throw new Error((r.stderr || r.stdout || "").slice(0, 500)); 2161 }); 2162 vscode.window.showInformationMessage("🔒 Vault locked."); 2163 } catch (err: any) { 2164 vscode.window.showErrorMessage("Vault lock failed: " + (err?.message || err)); 2165 } finally { 2166 updateVaultStatusBar(); 2167 } 2168 }) 2169 ); 2170 2171 context.subscriptions.push( 2172 vscode.commands.registerCommand("aestheticComputer.vaultForgetPassphrase", async () => { 2173 await context.secrets.delete("aestheticComputer.vaultPassphrase"); 2174 vscode.window.showInformationMessage("Vault passphrase cleared from SecretStorage."); 2175 }) 2176 ); 2177 2178 updateVaultStatusBar(); 2179 2180 context.subscriptions.push( 2181 vscode.commands.registerCommand("aestheticComputer.openWindow", () => { 2182 const panel = vscode.window.createWebviewPanel( 2183 "webView", // Identifies the type of the webview. Used internally 2184 "Aesthetic", // Title of the panel displayed to the user 2185 vscode.ViewColumn.One, // Editor column to show the new webview panel in. 2186 { 2187 enableScripts: true, 2188 enableForms: true, 2189 localResourceRoots: [extContext.extensionUri], 2190 }, // Webview options. 2191 ); 2192 2193 panel.title = "Aesthetic" + (local ? ": 🧑‍🤝‍🧑" : ""); // Update the title if local. 2194 panel.webview.html = getWebViewContent(panel.webview, ""); 2195 webWindow = panel; 2196 2197 panel.onDidDispose( 2198 () => { 2199 webWindow = null; 2200 }, 2201 null, 2202 context.subscriptions, 2203 ); 2204 }), 2205 ); 2206 2207 // 🌈 KidLisp.com Window 2208 context.subscriptions.push( 2209 vscode.commands.registerCommand("aestheticComputer.openKidLispWindow", () => { 2210 // If window already exists, reveal it instead of creating a new one 2211 if (kidlispWindow) { 2212 kidlispWindow.reveal(vscode.ViewColumn.One); 2213 return; 2214 } 2215 2216 const panel = vscode.window.createWebviewPanel( 2217 "kidlispWebView", // Identifies the type of the webview 2218 "KidLisp.com", // Title of the panel displayed to the user 2219 vscode.ViewColumn.One, // Editor column to show the new webview panel in 2220 { 2221 enableScripts: true, 2222 enableForms: true, 2223 localResourceRoots: [extContext.extensionUri], 2224 }, 2225 ); 2226 2227 panel.title = "KidLisp.com" + (local ? " 🧑‍🤝‍🧑" : ""); 2228 panel.webview.html = getKidLispWebViewContent(panel.webview); 2229 kidlispWindow = panel; 2230 2231 // Push current session into the KidLisp iframe 2232 sendKidLispSession(panel.webview); 2233 2234 // Handle messages from the KidLisp webview 2235 panel.webview.onDidReceiveMessage( 2236 async (message) => { 2237 switch (message.type) { 2238 case "vscode-extension:closeAllEditors": 2239 await vscode.commands.executeCommand("workbench.action.closeAllEditors"); 2240 break; 2241 case "vscode-extension:reload": 2242 vscode.commands.executeCommand("workbench.action.reloadWindow"); 2243 break; 2244 case "kidlisp:ready": { 2245 sendKidLispSession(panel.webview); 2246 break; 2247 } 2248 case "kidlisp:login": { 2249 const session = await vscode.authentication.getSession( 2250 "aesthetic", 2251 ["profile"], 2252 { createIfNone: true }, 2253 ); 2254 if (session) { 2255 extContext.globalState.update("aesthetic:session", session); 2256 updateUserStatusBar(); 2257 await sendKidLispSession(panel.webview); 2258 } 2259 break; 2260 } 2261 case "kidlisp:logout": { 2262 const session = await vscode.authentication.getSession( 2263 "aesthetic", 2264 ["profile"], 2265 { silent: true }, 2266 ); 2267 if (session) { 2268 await ap.removeSession(session.id); 2269 extContext.globalState.update("aesthetic:session", undefined); 2270 } 2271 updateUserStatusBar(); 2272 await sendKidLispSession(panel.webview); 2273 break; 2274 } 2275 // Fallback for plain login/logout messages forwarded from the iframe 2276 case "login": { 2277 vscode.commands.executeCommand("aestheticComputer.logIn"); 2278 break; 2279 } 2280 case "logout": { 2281 vscode.commands.executeCommand("aestheticComputer.logOut"); 2282 break; 2283 } 2284 } 2285 }, 2286 undefined, 2287 context.subscriptions, 2288 ); 2289 2290 panel.onDidDispose( 2291 () => { 2292 kidlispWindow = null; 2293 }, 2294 null, 2295 context.subscriptions, 2296 ); 2297 }), 2298 ); 2299 2300 // 🧭 AT Window 2301 context.subscriptions.push( 2302 vscode.commands.registerCommand("aestheticComputer.openAtWindow", () => { 2303 if (atWindow) { 2304 atWindow.reveal(vscode.ViewColumn.One); 2305 return; 2306 } 2307 2308 const panel = vscode.window.createWebviewPanel( 2309 "atWebView", 2310 "AT", 2311 vscode.ViewColumn.One, 2312 { 2313 enableScripts: true, 2314 enableForms: true, 2315 localResourceRoots: [extContext.extensionUri], 2316 }, 2317 ); 2318 2319 panel.title = "AT" + (local ? " 🧑‍🤝‍🧑" : ""); 2320 panel.webview.html = getAtWebViewContent(panel.webview); 2321 atWindow = panel; 2322 2323 panel.onDidDispose( 2324 () => { 2325 atWindow = null; 2326 }, 2327 null, 2328 context.subscriptions, 2329 ); 2330 }), 2331 ); 2332 2333 // 📰 News Window 2334 context.subscriptions.push( 2335 vscode.commands.registerCommand("aestheticComputer.openNewsWindow", () => { 2336 if (newsWindow) { 2337 newsWindow.reveal(vscode.ViewColumn.One); 2338 return; 2339 } 2340 2341 const panel = vscode.window.createWebviewPanel( 2342 "newsWebView", 2343 "News", 2344 vscode.ViewColumn.One, 2345 { 2346 enableScripts: true, 2347 enableForms: true, 2348 localResourceRoots: [extContext.extensionUri], 2349 }, 2350 ); 2351 2352 panel.title = "News" + (local ? " 🧑‍🤝‍🧑" : ""); 2353 panel.webview.html = getNewsWebViewContent(panel.webview); 2354 newsWindow = panel; 2355 2356 sendNewsSession(panel.webview); 2357 2358 panel.webview.onDidReceiveMessage( 2359 async (message) => { 2360 switch (message.type) { 2361 case "news:ready": { 2362 sendNewsSession(panel.webview); 2363 break; 2364 } 2365 case "news:login": { 2366 const session = await vscode.authentication.getSession( 2367 "aesthetic", 2368 ["profile"], 2369 { createIfNone: true }, 2370 ); 2371 if (session) { 2372 extContext.globalState.update("aesthetic:session", session); 2373 updateUserStatusBar(); 2374 await sendNewsSession(panel.webview); 2375 } 2376 break; 2377 } 2378 case "news:logout": { 2379 const session = await vscode.authentication.getSession( 2380 "aesthetic", 2381 ["profile"], 2382 { silent: true }, 2383 ); 2384 if (session) { 2385 await ap.removeSession(session.id); 2386 extContext.globalState.update("aesthetic:session", undefined); 2387 } 2388 updateUserStatusBar(); 2389 await sendNewsSession(panel.webview); 2390 break; 2391 } 2392 case "news:signup": { 2393 // Signup is just login with a hint - Auth0 handles the rest 2394 const session = await vscode.authentication.getSession( 2395 "aesthetic", 2396 ["profile"], 2397 { createIfNone: true }, 2398 ); 2399 if (session) { 2400 extContext.globalState.update("aesthetic:session", session); 2401 updateUserStatusBar(); 2402 await sendNewsSession(panel.webview); 2403 } 2404 break; 2405 } 2406 case "login": { 2407 const session = await vscode.authentication.getSession( 2408 "aesthetic", 2409 ["profile"], 2410 { createIfNone: true }, 2411 ); 2412 if (session) { 2413 extContext.globalState.update("aesthetic:session", session); 2414 updateUserStatusBar(); 2415 await sendNewsSession(panel.webview); 2416 } 2417 break; 2418 } 2419 case "signup": { 2420 const session = await vscode.authentication.getSession( 2421 "aesthetic", 2422 ["profile"], 2423 { createIfNone: true }, 2424 ); 2425 if (session) { 2426 extContext.globalState.update("aesthetic:session", session); 2427 updateUserStatusBar(); 2428 await sendNewsSession(panel.webview); 2429 } 2430 break; 2431 } 2432 case "logout": { 2433 const session = await vscode.authentication.getSession( 2434 "aesthetic", 2435 ["profile"], 2436 { silent: true }, 2437 ); 2438 if (session) { 2439 await ap.removeSession(session.id); 2440 extContext.globalState.update("aesthetic:session", undefined); 2441 } 2442 updateUserStatusBar(); 2443 await sendNewsSession(panel.webview); 2444 break; 2445 } 2446 } 2447 }, 2448 undefined, 2449 context.subscriptions, 2450 ); 2451 2452 panel.onDidDispose( 2453 () => { 2454 newsWindow = null; 2455 }, 2456 null, 2457 context.subscriptions, 2458 ); 2459 }), 2460 ); 2461 2462 // Add definitionProvider to context.subscriptions if necessary 2463 context.subscriptions.push(definitionProvider); 2464 2465 // 🗝️ Authorization 2466 const ap = new AestheticAuthenticationProvider(context, local, "aesthetic"); 2467 const sp = new AestheticAuthenticationProvider(context, local, "sotce"); 2468 2469 context.subscriptions.push(ap); 2470 context.subscriptions.push(sp); 2471 2472 context.subscriptions.push( 2473 vscode.commands.registerCommand("aestheticComputer.logIn", async () => { 2474 const session = await vscode.authentication.getSession( 2475 "aesthetic", 2476 ["profile"], 2477 { createIfNone: true }, 2478 ); 2479 }), 2480 ); 2481 2482 // Alias to match older calls expecting lowercase "login" 2483 context.subscriptions.push( 2484 vscode.commands.registerCommand("aestheticComputer.login", async () => { 2485 await vscode.commands.executeCommand("aestheticComputer.logIn"); 2486 }), 2487 ); 2488 2489 context.subscriptions.push( 2490 vscode.commands.registerCommand("aestheticComputer.logOut", async () => { 2491 const session = await vscode.authentication.getSession( 2492 "aesthetic", 2493 ["profile"], 2494 { silent: true }, 2495 ); 2496 if (session) { 2497 await ap.removeSession(session.id); 2498 vscode.window.showInformationMessage("🟪 You have been logged out."); 2499 } else { 2500 vscode.window.showInformationMessage("No active session found."); 2501 } 2502 }), 2503 ); 2504 2505 // Alias to match older calls expecting lowercase "logout" 2506 context.subscriptions.push( 2507 vscode.commands.registerCommand("aestheticComputer.logout", async () => { 2508 await vscode.commands.executeCommand("aestheticComputer.logOut"); 2509 }), 2510 ); 2511 2512 context.subscriptions.push( 2513 vscode.commands.registerCommand( 2514 "aestheticComputer.sotceLogIn", 2515 async () => { 2516 const session = await vscode.authentication.getSession( 2517 "sotce", 2518 ["profile"], 2519 { createIfNone: true }, 2520 ); 2521 }, 2522 ), 2523 ); 2524 2525 context.subscriptions.push( 2526 vscode.commands.registerCommand( 2527 "aestheticComputer.sotceLogOut", 2528 async () => { 2529 const session = await vscode.authentication.getSession( 2530 "sotce", 2531 ["profile"], 2532 { silent: true }, 2533 ); 2534 if (session) { 2535 await sp.removeSession(session.id); 2536 vscode.window.showInformationMessage("🪷 You have been logged out."); 2537 } else { 2538 vscode.window.showInformationMessage("No active session found."); 2539 } 2540 }, 2541 ), 2542 ); 2543 2544 const getSession = async (tenant: string) => { 2545 const session = await vscode.authentication.getSession( 2546 tenant, 2547 ["profile"], 2548 { silent: true }, 2549 ); 2550 2551 if (session) { 2552 vscode.window.showInformationMessage( 2553 `👋 Welcome back to ${ 2554 tenant === "aesthetic" ? "Aesthetic Computer" : "Sotce Net" 2555 }! (${session.account.label})`, 2556 ); 2557 context.globalState.update(`${tenant}:session`, session); 2558 } else { 2559 context.globalState.update(`${tenant}:session`, undefined); 2560 // console.log("😀 Erased session!"); 2561 } 2562 2563 // Update user status bar after session change 2564 updateUserStatusBar(); 2565 2566 return session; 2567 }; 2568 2569 context.subscriptions.push( 2570 vscode.authentication.onDidChangeSessions(async (e) => { 2571 // console.log("🏃 Sessions changed:", e); 2572 if (e.provider.id === "aesthetic" || e.provider.id === "sotce") { 2573 await getSession(e.provider.id); 2574 provider.refreshWebview(); 2575 refreshWebWindow(); 2576 refreshKidLispWindow(); 2577 refreshNewsWindow(); 2578 sendKidLispSession(); 2579 sendNewsSession(); 2580 updateUserStatusBar(); 2581 } 2582 }), 2583 ); 2584 2585 // GUI 2586 2587 provider = new AestheticViewProvider(); 2588 2589 // Connect to session server for jump commands 2590 provider.connectToSessionServer(); 2591 2592 context.subscriptions.push( 2593 vscode.window.registerWebviewViewProvider( 2594 AestheticViewProvider.viewType, 2595 provider, 2596 ), 2597 ); 2598 2599 // 🧩 Piece Running 2600 2601 // Send piece code through the code channel. 2602 function upload() { 2603 let editor = vscode.window.activeTextEditor; 2604 if (!editor) { 2605 return; 2606 } 2607 2608 if (local) { 2609 // console.log("😊 Skipping `/run` api endpoint. (In local mode.)"); 2610 return; 2611 } 2612 2613 let source = editor.document.getText(); 2614 const piece = editor.document.fileName 2615 .split(/\/|\\/) // Split on both forward slash and backslash 2616 .slice(-1)[0] 2617 .replace(".mjs", ""); 2618 2619 // 📓 The `local` won't work due to VSCode's Proxy, but the option 2620 // is here just in case it's ever possible again. 2621 const host = local === false ? "aesthetic.computer" : "localhost:8888"; 2622 2623 let url = `https://${host}/run`; 2624 2625 vscode.window.showInformationMessage(`🧩 ${piece}`); 2626 2627 fetch(url, { 2628 method: "POST", 2629 body: JSON.stringify({ piece, source, codeChannel }), 2630 headers: { "Content-Type": "application/json" }, 2631 }) 2632 .then((res) => res.text()) // Convert the response to text 2633 .then((text) => { 2634 // Now 'text' is a string that can be used in showInformationMessage 2635 // vscode.window.showInformationMessage(`🧩 \`${piece}\``); 2636 }) 2637 .catch((error) => { 2638 // If you catch an error, make sure to convert it to a string if it isn't already 2639 console.error(error); 2640 vscode.window.showInformationMessage("🔴" + "Piece error."); 2641 }); 2642 } 2643 2644 context.subscriptions.push( 2645 vscode.commands.registerCommand("aestheticComputer.runPiece", () => { 2646 upload(); 2647 }), 2648 vscode.commands.registerCommand("aestheticComputer.localServer", () => { 2649 local = !local; 2650 context.globalState.update("aesthetic:local", local); 2651 2652 // Start or stop local server checking 2653 if (local) { 2654 localServerAvailable = false; // Reset until we confirm 2655 startLocalServerCheck(); 2656 } else { 2657 stopLocalServerCheck(); 2658 localServerAvailable = false; 2659 } 2660 2661 // Refresh the webview with the new local state 2662 provider.refreshWebview(); 2663 refreshWebWindow(); 2664 refreshKidLispWindow(); 2665 refreshNewsWindow(); 2666 refreshWelcomePanel(); // Also refresh welcome panel for dev mode 2667 vscode.window.showInformationMessage( 2668 `💻 Local Development: ${local ? "Enabled" : "Disabled"}`, 2669 ); 2670 }), 2671 vscode.commands.registerCommand("aestheticComputer.clearSlug", () => { 2672 // Clear the stored slug data 2673 context.globalState.update("panel:slug", ""); 2674 // Refresh the webview to reflect the cleared state 2675 provider.refreshWebview(); 2676 refreshWebWindow(); 2677 vscode.window.showInformationMessage( 2678 "🧹 Slug data cleared successfully!", 2679 ); 2680 }), 2681 vscode.commands.registerCommand("aestheticComputer.closeAllEditors", async () => { 2682 await vscode.commands.executeCommand("workbench.action.closeAllEditors"); 2683 }), 2684 ); 2685 2686 // Automatically re-run the piece when saving any .mjs file. 2687 vscode.workspace.onDidSaveTextDocument((document) => { 2688 function mjsOrLisp(path: string) { 2689 return path.endsWith(".mjs") || path.endsWith(".lisp"); 2690 } 2691 2692 if (vscode.window.activeTextEditor?.document === document) { 2693 // console.log("🔩 File path:", document.uri.fsPath); 2694 const inMonoRepo = 2695 document.uri.fsPath.indexOf("aesthetic-computer/system") > -1; 2696 const inDisks = 2697 document.uri.fsPath.indexOf( 2698 "aesthetic-computer/system/public/aesthetic.computer/disks", 2699 ) > -1; 2700 2701 if (inMonoRepo) { 2702 if (inDisks && mjsOrLisp(document.uri.fsPath)) { 2703 // console.log("🟡 Loading piece...", document.uri.fsPath); 2704 vscode.commands.executeCommand("aestheticComputer.runPiece"); 2705 } 2706 } else if (mjsOrLisp(document.uri.fsPath)) { 2707 // console.log("🟡 Loading piece...", document.uri.fsPath); 2708 vscode.commands.executeCommand("aestheticComputer.runPiece"); 2709 } 2710 } 2711 }); 2712 2713 // 🌐 CDP Command Server — local HTTP endpoint for test harnesses and automation 2714 if (http) { 2715 const CDP_PORT = 19998; 2716 const cdpServer = http.createServer(async (req: any, res: any) => { 2717 res.setHeader("Access-Control-Allow-Origin", "*"); 2718 res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); 2719 res.setHeader("Access-Control-Allow-Headers", "Content-Type"); 2720 if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; } 2721 2722 if (req.url === "/status") { 2723 res.writeHead(200, { "Content-Type": "application/json" }); 2724 res.end(JSON.stringify({ ok: true, extension: "aesthetic-computer" })); 2725 return; 2726 } 2727 2728 if (req.url === "/command" && req.method === "POST") { 2729 const body: Buffer[] = []; 2730 req.on("data", (chunk: Buffer) => body.push(chunk)); 2731 req.on("end", async () => { 2732 try { 2733 const { command, args } = JSON.parse(Buffer.concat(body).toString()); 2734 const result = await vscode.commands.executeCommand(command, ...(args || [])); 2735 res.writeHead(200, { "Content-Type": "application/json" }); 2736 res.end(JSON.stringify({ ok: true, result: result ?? null })); 2737 } catch (e: any) { 2738 res.writeHead(500, { "Content-Type": "application/json" }); 2739 res.end(JSON.stringify({ ok: false, error: e.message })); 2740 } 2741 }); 2742 return; 2743 } 2744 2745 res.writeHead(404); 2746 res.end("Not found"); 2747 }); 2748 2749 cdpServer.listen(CDP_PORT, "127.0.0.1", () => { 2750 console.log(`🌐 CDP command server listening on http://127.0.0.1:${CDP_PORT}`); 2751 }); 2752 cdpServer.on("error", (e: any) => { 2753 if (e.code === "EADDRINUSE") { 2754 console.log(`🌐 CDP command server port ${CDP_PORT} already in use, skipping`); 2755 } 2756 }); 2757 context.subscriptions.push({ dispose: () => cdpServer.close() }); 2758 } 2759} 2760 2761// 📓 Documentation 2762 2763// This is just for top-level functions and maybe something at the very top? 2764class AestheticCodeLensProvider implements vscode.CodeLensProvider { 2765 provideCodeLenses( 2766 document: vscode.TextDocument, 2767 // token: vscode.CancellationToken, 2768 ): vscode.CodeLens[] { 2769 let codeLenses: vscode.CodeLens[] = []; 2770 2771 function escapeRegExp(word: string) { 2772 return word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); 2773 } 2774 2775 if (document.lineCount > 500 || !docs) return codeLenses; // Don't compute for large documents. 2776 2777 const escapedWords = keys(docs.api.structure) 2778 .map((word) => "function " + word) 2779 .map(escapeRegExp); 2780 const regex = new RegExp(`\\b(${escapedWords.join("|")})\\b`, "gi"); 2781 2782 for (let i = 0; i < document.lineCount; i++) { 2783 const line = document.lineAt(i); 2784 let matches; 2785 while ((matches = regex.exec(line.text)) !== null) { 2786 const word = matches[0]; 2787 const range = new vscode.Range( 2788 i, 2789 matches.index, 2790 i, 2791 matches.index + word.length, 2792 ); 2793 const docKey = word.toLowerCase().replace("function ", ""); 2794 2795 const command = { 2796 title: docs.api.structure[docKey].label, 2797 command: "aestheticComputer.openDoc", 2798 arguments: [docKey], 2799 }; 2800 codeLenses.push(new vscode.CodeLens(range, command)); 2801 } 2802 } 2803 2804 return codeLenses; 2805 } 2806} 2807 2808// 🪟 Panel Rendering 2809class AestheticViewProvider implements vscode.WebviewViewProvider { 2810 public static readonly viewType = "aestheticComputer.sidebarView"; 2811 private _view?: vscode.WebviewView; 2812 private ws?: any; 2813 2814 constructor() {} 2815 2816 // Method to send message to the webview 2817 public sendMessageToWebview(message: any) { 2818 if (this._view && this._view.webview) { 2819 this._view.webview.postMessage(message); 2820 } 2821 } 2822 2823 // Connect to session server as WebSocket client 2824 public connectToSessionServer() { 2825 const WebSocket = require('ws'); 2826 const url = local ? "wss://localhost:8889" : "wss://aesthetic.computer"; 2827 2828 this.ws = new WebSocket(url, { 2829 rejectUnauthorized: false 2830 }); 2831 2832 this.ws.on("open", () => { 2833 console.log("✅ Connected to session server"); 2834 // Identify as VSCode extension 2835 this.ws.send(JSON.stringify({ 2836 type: "identify", 2837 content: { type: "vscode" } 2838 })); 2839 }); 2840 2841 this.ws.on("message", (data: any) => { 2842 try { 2843 const msg = JSON.parse(data.toString()); 2844 2845 if (msg.type === "vscode:jump" && msg.content?.piece) { 2846 console.log("🎯 Jump command received:", msg.content.piece); 2847 this.handleJump(msg.content.piece); 2848 } 2849 } catch (err) { 2850 console.error("Failed to parse message:", err); 2851 } 2852 }); 2853 2854 this.ws.on("close", () => { 2855 console.log("❌ Disconnected from session server"); 2856 // Reconnect after 5 seconds 2857 setTimeout(() => this.connectToSessionServer(), 5000); 2858 }); 2859 2860 this.ws.on("error", (err: any) => { 2861 console.error("WebSocket error:", err.message); 2862 }); 2863 } 2864 2865 // Handle jump command by updating slug and showing panel 2866 private async handleJump(piece: string) { 2867 // Update the stored slug BEFORE showing the panel 2868 // This ensures the panel loads with the correct URL immediately 2869 await extContext.globalState.update("panel:slug", piece); 2870 2871 // If panel is hidden, show it (it will load with the new slug) 2872 if (!this._view?.visible) { 2873 await vscode.commands.executeCommand( 2874 "aestheticComputer.sidebarView.focus" 2875 ); 2876 // The panel will initialize with the updated slug from globalState 2877 } else { 2878 // Panel is visible, send message to navigate 2879 this.sendMessageToWebview({ type: "jump", piece }); 2880 } 2881 } 2882 2883 public refreshWebview(): void { 2884 if (this._view) { 2885 const slug = extContext.globalState.get("panel:slug", ""); 2886 // if (slug) console.log("🪱 Loading slug:", slug); 2887 this._view.title = slug + (local ? " 🧑‍🤝‍🧑" : ""); 2888 this._view.webview.html = getWebViewContent(this._view.webview, slug); 2889 } 2890 } 2891 2892 public resolveWebviewView( 2893 webviewView: vscode.WebviewView, 2894 context: vscode.WebviewViewResolveContext<unknown>, 2895 _token: vscode.CancellationToken, 2896 ): void { 2897 this._view = webviewView; 2898 2899 const slug = extContext.globalState.get("panel:slug", ""); 2900 // if (slug) console.log("🪱 Loading slug:", slug); 2901 2902 this._view.title = slug + (local ? " 🧑‍🤝‍🧑" : ""); 2903 2904 // Set retainContextWhenHidden to true 2905 this._view.webview.options = { 2906 enableScripts: true, 2907 enableForms: true, 2908 localResourceRoots: [extContext.extensionUri], 2909 }; 2910 2911 webviewView.webview.html = getWebViewContent(this._view.webview, slug); 2912 2913 webviewView.webview.onDidReceiveMessage(async (data) => { 2914 switch (data.type) { 2915 case "url:updated": { 2916 // console.log("😫 Slug updated...", data.slug); 2917 extContext.globalState.update("panel:slug", data.slug); 2918 webviewView.title = data.slug + (local ? " 🧑‍🤝‍🧑" : ""); 2919 break; 2920 } 2921 case "clipboard:copy": { 2922 vscode.env.clipboard.writeText(data.value).then(() => { 2923 // console.log("📋 Copied text to clipboard!"); 2924 webviewView.webview.postMessage({ 2925 type: "clipboard:copy:confirmation", 2926 }); 2927 }); 2928 break; 2929 } 2930 case "publish": 2931 if (data.url) vscode.env.openExternal(vscode.Uri.parse(data.url)); 2932 break; 2933 case "setCode": 2934 codeChannel = data.value; 2935 // const currentTitle = webviewView.title; 2936 // webviewView.title = currentTitle?.split(" · ")[0] + " · " + codeChannel; 2937 // ^ Disabled because it's always rendered uppercase. 24.01.27.17.26 2938 break; 2939 case "vscode-extension:reload": { 2940 vscode.commands.executeCommand("workbench.action.reloadWindow"); 2941 break; 2942 } 2943 case "vscode-extension:closeAllEditors": { 2944 await vscode.commands.executeCommand("workbench.action.closeAllEditors"); 2945 break; 2946 } 2947 case "vscode-extension:defocus": { 2948 const editor = vscode.window.activeTextEditor; 2949 if (editor) { 2950 await vscode.window.showTextDocument( 2951 editor.document, 2952 editor.viewColumn, 2953 ); 2954 } 2955 break; 2956 break; 2957 } 2958 case "openDocs": { 2959 // console.log("🏃 Opening docs..."); 2960 vscode.commands.executeCommand("aestheticComputer.openDoc"); 2961 break; 2962 } 2963 case "openSource": { 2964 // console.log("📃 Opening a new source file...", data); 2965 // const tempUri = document.uri.with({ path: document.uri.path + '.mjs' }); 2966 vscode.workspace 2967 .openTextDocument({ 2968 content: data.source, 2969 // language: "javascript", 2970 }) 2971 .then((document) => { 2972 vscode.window 2973 .showTextDocument(document, { preview: false }) 2974 .then(() => { 2975 return vscode.window.showInformationMessage( 2976 "💾 Save this code with an `.mjs` extension to run it on Aesthetic Computer", 2977 { modal: true }, 2978 ); 2979 }) 2980 .then(() => { 2981 if (fs && path) { 2982 const defaultUri = vscode.Uri.file( 2983 path.join(vscode.workspace.rootPath || "", data.title), 2984 ); 2985 return vscode.window.showSaveDialog({ 2986 filters: { 2987 "JavaScript Module": ["mjs"], 2988 }, 2989 defaultUri: defaultUri, 2990 saveLabel: "Save As", 2991 }); 2992 } else { 2993 return; 2994 } 2995 }) 2996 .then((fileUri) => { 2997 if (fileUri) { 2998 // Read the content of the current document 2999 const content = document.getText(); 3000 3001 // Write the content to the new file 3002 fs.writeFile(fileUri.fsPath, content, (err: any) => { 3003 if (err) { 3004 vscode.window.showErrorMessage( 3005 "Failed to save file: " + err.message, 3006 ); 3007 // return; 3008 return Promise.resolve(null); 3009 } 3010 3011 // Close the current editor 3012 vscode.commands 3013 .executeCommand( 3014 "workbench.action.revertAndCloseActiveEditor", 3015 ) 3016 .then(() => { 3017 // Open the saved file in the editor 3018 vscode.workspace 3019 .openTextDocument(fileUri) 3020 .then((doc) => { 3021 vscode.window.showTextDocument(doc, { 3022 preview: false, 3023 }); 3024 // vscode.window.showInformationMessage( 3025 // "File saved at: " + fileUri.fsPath, 3026 // ); 3027 }); 3028 }); 3029 }); 3030 } 3031 }); 3032 }); 3033 break; 3034 } 3035 case "runPiece": { 3036 console.log("🏃 Running piece..."); 3037 vscode.commands.executeCommand("aestheticComputer.runPiece"); 3038 break; 3039 } 3040 case "login": { 3041 console.log("📂 Logging in..."); 3042 const command = data.tenant === "sotce" ? "sotceLogIn" : "logIn"; 3043 vscode.commands.executeCommand(`aestheticComputer.${command}`); 3044 break; 3045 } 3046 case "logout": { 3047 console.log("🚪 Logging out..."); 3048 const command = data.tenant === "sotce" ? "sotceLogOut" : "logOut"; 3049 vscode.commands.executeCommand(`aestheticComputer.${command}`); 3050 break; 3051 } 3052 case "openExternal": { 3053 console.log("🌐 Opening external URL:", data.url); 3054 if (data.url) { 3055 vscode.env.openExternal(vscode.Uri.parse(data.url)); 3056 } 3057 break; 3058 } 3059 } 3060 }); 3061 3062 webviewView.onDidChangeVisibility(() => { 3063 if (!webviewView.visible) { 3064 // console.log("🔴 Panel hidden."); 3065 // Perform any cleanup or state update here when the view is hidden 3066 const slug = extContext.globalState.get("panel:slug", ""); 3067 // if (slug) console.log("🪱 Slug:", slug); 3068 webviewView.title = slug + (local ? " 🧑‍🤝‍🧑" : ""); 3069 webviewView.webview.html = getWebViewContent(webviewView.webview, slug); 3070 } else { 3071 // console.log("🟢 Panel open."); 3072 // Send focus event to webview so prompt can be activated 3073 webviewView.webview.postMessage({ type: "aesthetic-parent:focused" }); 3074 } 3075 }); 3076 } 3077} 3078 3079// 📚 Library 3080 3081function getNonce(): string { 3082 let text = ""; 3083 const possible = 3084 "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; 3085 for (let i = 0; i < 32; i++) { 3086 text += possible.charAt(Math.floor(Math.random() * possible.length)); 3087 } 3088 return text; 3089} 3090 3091function refreshWebWindow() { 3092 if (webWindow) { 3093 const slug = extContext.globalState.get("panel:slug", ""); 3094 // if (slug) console.log("🪱 Loading slug:", slug); 3095 3096 webWindow.title = "Aesthetic: " + slug + (local ? " 🧑‍🤝‍🧑" : ""); // Update the title if local. 3097 3098 webWindow.webview.html = getWebViewContent(webWindow.webview, slug); 3099 } 3100} 3101 3102function refreshKidLispWindow() { 3103 if (kidlispWindow) { 3104 kidlispWindow.title = "KidLisp.com" + (local ? " 🧑‍🤝‍🧑" : ""); 3105 kidlispWindow.webview.html = getKidLispWebViewContent(kidlispWindow.webview); 3106 } 3107} 3108 3109function refreshAtWindow() { 3110 if (atWindow) { 3111 atWindow.title = "AT" + (local ? " 🧑‍🤝‍🧑" : ""); 3112 atWindow.webview.html = getAtWebViewContent(atWindow.webview); 3113 } 3114} 3115 3116function refreshNewsWindow() { 3117 if (newsWindow) { 3118 newsWindow.title = "News" + (local ? " 🧑‍🤝‍🧑" : ""); 3119 newsWindow.webview.html = getNewsWebViewContent(newsWindow.webview); 3120 } 3121} 3122 3123async function sendKidLispSession(target?: vscode.Webview) { 3124 const webview = target || kidlispWindow?.webview; 3125 if (!webview) return; 3126 3127 let session: vscode.AuthenticationSession | null = null; 3128 try { 3129 session = await vscode.authentication.getSession( 3130 "aesthetic", 3131 ["profile"], 3132 { silent: true }, 3133 ); 3134 } catch (e) { 3135 console.log("🔴 Unable to fetch KidLisp session:", e); 3136 } 3137 3138 webview.postMessage({ type: "setSession", tenant: "aesthetic", session }); 3139} 3140 3141async function sendNewsSession(target?: vscode.Webview) { 3142 const webview = target || newsWindow?.webview; 3143 if (!webview) return; 3144 3145 let session: vscode.AuthenticationSession | null = null; 3146 try { 3147 session = await vscode.authentication.getSession( 3148 "aesthetic", 3149 ["profile"], 3150 { silent: true }, 3151 ); 3152 } catch (e) { 3153 console.log("🔴 Unable to fetch News session:", e); 3154 } 3155 3156 webview.postMessage({ type: "setSession", tenant: "aesthetic", session }); 3157} 3158 3159function getWebViewContent(webview: any, slug: string) { 3160 const scriptUri = webview.asWebviewUri( 3161 vscode.Uri.joinPath(extContext.extensionUri, "embedded.js"), 3162 ); 3163 3164 const nonce = getNonce(); 3165 const styleUri = webview.asWebviewUri( 3166 vscode.Uri.joinPath(extContext.extensionUri, "main.css"), 3167 ); 3168 3169 const resetStyleUri = webview.asWebviewUri( 3170 vscode.Uri.joinPath(extContext.extensionUri, "reset.css"), 3171 ); 3172 3173 const vscodeStyleUri = webview.asWebviewUri( 3174 vscode.Uri.joinPath(extContext.extensionUri, "vscode.css"), 3175 ); 3176 3177 // Get proper URI for background image 3178 const purplePalsUri = webview.asWebviewUri( 3179 vscode.Uri.joinPath(extContext.extensionUri, "resources", "purple-pals.svg"), 3180 ); 3181 3182 const sessionAesthetic = extContext.globalState.get( 3183 "aesthetic:session", 3184 undefined, 3185 ); 3186 3187 const sessionSotce = extContext.globalState.get("sotce:session", undefined); 3188 // console.log("🟪 Aesthetic:", sessionAesthetic, "🪷 Sotce:", sessionSotce); 3189 3190 // console.log("🪱 Slug:", slug); 3191 let hashFragment = ""; 3192 let pathPart = slug || ""; 3193 3194 // Encode # as %23 in kidlisp code to prevent URL fragment interpretation 3195 // This handles cases like "(stamp #wNb 0 0)" in the path 3196 if (pathPart && !pathPart.startsWith("#")) { 3197 // Only encode # if it's not already a hash fragment (painting code) 3198 pathPart = pathPart.replace(/#/g, "%23"); 3199 } 3200 3201 if (pathPart.startsWith("#")) { 3202 hashFragment = pathPart; 3203 pathPart = ""; 3204 } 3205 3206 const hasQuery = pathPart.includes("?"); 3207 let param = pathPart; 3208 3209 if (param) { 3210 param += hasQuery ? "&vscode=true" : "?vscode=true"; 3211 } else { 3212 param = "?vscode=true"; 3213 } 3214 3215 [sessionAesthetic, sessionSotce].forEach((session, index) => { 3216 const paramBase = `&session-${index === 0 ? "aesthetic" : "sotce"}=`; 3217 3218 if (typeof session === "object") { 3219 // Logged in. 3220 if (keys(session)?.length > 0) { 3221 const base64EncodedSession = btoa(JSON.stringify(session)); 3222 param += paramBase + encodeURIComponent(base64EncodedSession); 3223 } 3224 } else { 3225 // Logged out. 3226 param += paramBase + "null"; 3227 } 3228 }); 3229 3230 // param = "?clearSession=true"; Probably never needed. 3231 3232 // Determine the iframe URL based on environment 3233 let iframeUrl; 3234 let iframeProtocol = "https://"; 3235 if (isCodespaces && codespaceName && codespacesDomain) { 3236 // In Codespaces, always use the forwarded URL (without protocol, added later) 3237 iframeUrl = `${codespaceName}-8888.${codespacesDomain}`; 3238 } else { 3239 // On local laptop, use localhost if local mode is enabled 3240 iframeUrl = local ? "localhost:8888" : "aesthetic.computer"; 3241 } 3242 3243 // Build CSP frame-src and child-src based on environment 3244 let cspFrameSrc = "frame-src https://aesthetic.computer https://hi.aesthetic.computer https://aesthetic.local:8888 https://localhost:8888 https://sotce.net https://hi.sotce.net https://sotce.local:8888"; 3245 let cspChildSrc = "child-src https://aesthetic.computer https://aesthetic.local:8888 https://sotce.net https://sotce.local:8888 https://localhost:8888"; 3246 3247 if (isCodespaces && codespacesDomain) { 3248 // Use wildcard for any codespace in this domain 3249 const codespaceWildcard = `https://*.${codespacesDomain}`; 3250 cspFrameSrc += ` ${codespaceWildcard}`; 3251 cspChildSrc += ` ${codespaceWildcard}`; 3252 } 3253 3254 // Show waiting UI if local mode is enabled but server isn't available yet 3255 if (local && !localServerAvailable && !isCodespaces) { 3256 // Theme-aware colors for waiting screen 3257 const themeKind = vscode.window.activeColorTheme.kind; 3258 const isDark = themeKind === 2 || themeKind === 3; // Dark or HighContrast 3259 const bg = isDark ? '#181318' : '#fcf7c5'; 3260 const textColor = isDark ? '#ffffffcc' : '#3b2a1a'; 3261 const titleColor = isDark ? '#a87090' : '#387adf'; 3262 const subtitleBg = isDark ? '#101010' : '#fff9f0'; 3263 const subtitleBorder = isDark ? '#483848' : '#e2d4c3'; 3264 const subtitleColor = isDark ? '#b0a0a8' : '#6b4a2e'; 3265 const codeColor = isDark ? '#70c070' : '#006400'; 3266 3267 return `<!DOCTYPE html> 3268 <html lang="en"> 3269 <head> 3270 <meta charset="UTF-8"> 3271 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 3272 <link href="${styleUri}" rel="stylesheet"> 3273 <link href="${resetStyleUri}" rel="stylesheet"> 3274 <link href="${vscodeStyleUri}" rel="stylesheet"> 3275 <title>aesthetic.computer</title> 3276 <style> 3277 body { 3278 display: flex; 3279 flex-direction: column; 3280 align-items: center; 3281 justify-content: center; 3282 height: 100vh; 3283 margin: 0; 3284 background: ${bg}; 3285 color: ${textColor}; 3286 font-family: system-ui, -apple-system, sans-serif; 3287 } 3288 .waiting { 3289 text-align: center; 3290 } 3291 .plug { 3292 font-size: 64px; 3293 margin-bottom: 24px; 3294 animation: wiggle 2s ease-in-out infinite; 3295 } 3296 .title { 3297 font-size: 20px; 3298 font-weight: 500; 3299 color: ${titleColor}; 3300 margin-bottom: 12px; 3301 letter-spacing: 0.5px; 3302 } 3303 .subtitle { 3304 font-size: 14px; 3305 color: ${subtitleColor}; 3306 font-family: monospace; 3307 background: ${subtitleBg}; 3308 padding: 8px 16px; 3309 border-radius: 4px; 3310 border: 1px solid ${subtitleBorder}; 3311 } 3312 .subtitle code { 3313 color: ${codeColor}; 3314 font-weight: 600; 3315 } 3316 .dots::after { 3317 content: ''; 3318 animation: dots 1.5s steps(4, end) infinite; 3319 } 3320 @keyframes dots { 3321 0%, 20% { content: ''; } 3322 40% { content: '.'; } 3323 60% { content: '..'; } 3324 80%, 100% { content: '...'; } 3325 } 3326 @keyframes wiggle { 3327 0%, 100% { transform: rotate(-5deg); } 3328 50% { transform: rotate(5deg); } 3329 } 3330 </style> 3331 </head> 3332 <body> 3333 <div class="waiting"> 3334 <div class="plug">🔌</div> 3335 <div class="title">Waiting for local server<span class="dots"></span></div> 3336 <div class="subtitle">Run <code>ac-site</code> to start localhost:8888</div> 3337 </div> 3338 </body> 3339 </html>`; 3340 } 3341 3342 return `<!DOCTYPE html> 3343 <html lang="en"> 3344 <head> 3345 <meta charset="UTF-8"> 3346 <meta http-equiv="Content-Security-Policy" content="default-src 'none'; ${cspFrameSrc}; ${cspChildSrc}; style-src ${ 3347 webview.cspSource 3348 } 'unsafe-inline'; script-src 'nonce-${nonce}'; media-src *; img-src ${webview.cspSource} data:;"> 3349 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 3350 <link href="${styleUri}" rel="stylesheet"> 3351 <link href="${resetStyleUri}" rel="stylesheet"> 3352 <link href="${vscodeStyleUri}" rel="stylesheet"> 3353 <title>aesthetic.computer</title> 3354 </head> 3355 <body> 3356 <iframe id="aesthetic" sandbox="allow-scripts allow-same-origin allow-modals allow-popups allow-popups-to-escape-sandbox allow-presentation allow-pointer-lock" allow="clipboard-write; clipboard-read" src="${iframeProtocol}${iframeUrl}/${param}${hashFragment}" border="none"></iframe> 3357 <script nonce="${nonce}" src="${scriptUri}"></script> 3358 </body> 3359 </html>`; 3360} 3361 3362 // 📰 News WebView Content 3363 function getNewsWebViewContent(webview: any) { 3364 const nonce = getNonce(); 3365 3366 // Detect VS Code theme for News waiting screen 3367 const themeKind = vscode.window.activeColorTheme.kind; 3368 const isDark = themeKind === 2 || themeKind === 3; // Dark or HighContrast 3369 3370 const styleUri = webview.asWebviewUri( 3371 vscode.Uri.joinPath(extContext.extensionUri, "main.css"), 3372 ); 3373 3374 const resetStyleUri = webview.asWebviewUri( 3375 vscode.Uri.joinPath(extContext.extensionUri, "reset.css"), 3376 ); 3377 3378 const vscodeStyleUri = webview.asWebviewUri( 3379 vscode.Uri.joinPath(extContext.extensionUri, "vscode.css"), 3380 ); 3381 3382 if (local && !localServerAvailable && !isCodespaces) { 3383 // Theme-aware colors for waiting screen 3384 const bg = isDark ? '#1a1a1a' : '#f7f1e1'; 3385 const textColor = isDark ? '#d4d4d4' : '#3b2a1a'; 3386 const titleColor = isDark ? '#ff69b4' : '#a85a2a'; 3387 const subtitleBg = isDark ? '#252525' : '#fff9f0'; 3388 const subtitleBorder = isDark ? '#404040' : '#e2d4c3'; 3389 const subtitleColor = isDark ? '#a0a0a0' : '#6b4a2e'; 3390 const codeColor = isDark ? '#ff69b4' : '#a85a2a'; 3391 3392 return `<!DOCTYPE html> 3393 <html lang="en"> 3394 <head> 3395 <meta charset="UTF-8"> 3396 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 3397 <link href="${styleUri}" rel="stylesheet"> 3398 <link href="${resetStyleUri}" rel="stylesheet"> 3399 <link href="${vscodeStyleUri}" rel="stylesheet"> 3400 <title>News</title> 3401 <style> 3402 body { 3403 display: flex; 3404 flex-direction: column; 3405 align-items: center; 3406 justify-content: center; 3407 height: 100vh; 3408 margin: 0; 3409 background: ${bg}; 3410 color: ${textColor}; 3411 font-family: system-ui, -apple-system, sans-serif; 3412 } 3413 .waiting { text-align: center; } 3414 .plug { font-size: 64px; margin-bottom: 24px; animation: wiggle 2s ease-in-out infinite; } 3415 .title { font-size: 20px; font-weight: 600; color: ${titleColor}; margin-bottom: 12px; letter-spacing: 0.5px; } 3416 .subtitle { 3417 font-size: 14px; 3418 color: ${subtitleColor}; 3419 font-family: monospace; 3420 background: ${subtitleBg}; 3421 padding: 8px 16px; 3422 border-radius: 4px; 3423 border: 1px solid ${subtitleBorder}; 3424 } 3425 .subtitle code { color: ${codeColor}; font-weight: 600; } 3426 .dots::after { content: ''; animation: dots 1.5s steps(4, end) infinite; } 3427 @keyframes dots { 0%, 20% { content: ''; } 40% { content: '.'; } 60% { content: '..'; } 80%, 100% { content: '...'; } } 3428 @keyframes wiggle { 0%, 100% { transform: rotate(-5deg); } 50% { transform: rotate(5deg); } } 3429 </style> 3430 </head> 3431 <body> 3432 <div class="waiting"> 3433 <div class="plug">📰</div> 3434 <div class="title">Waiting for local server<span class="dots"></span></div> 3435 <div class="subtitle">Run <code>ac-site</code> to start localhost:8888</div> 3436 </div> 3437 </body> 3438 </html>`; 3439 } 3440 3441 let iframeUrl; 3442 let iframeProtocol = "https://"; 3443 let path = ""; 3444 if (isCodespaces && codespaceName && codespacesDomain) { 3445 iframeUrl = `${codespaceName}-8888.${codespacesDomain}`; 3446 path = "/news.aesthetic.computer"; 3447 } else if (local) { 3448 iframeUrl = "localhost:8888"; 3449 path = "/news.aesthetic.computer"; 3450 } else { 3451 iframeUrl = "news.aesthetic.computer"; 3452 path = ""; 3453 } 3454 3455 let cspFrameSrc = "frame-src https://news.aesthetic.computer https://localhost:8888 https://www.youtube.com https://youtube.com https://www.youtube-nocookie.com"; 3456 let cspChildSrc = "child-src https://news.aesthetic.computer https://localhost:8888 https://www.youtube.com https://youtube.com https://www.youtube-nocookie.com"; 3457 if (isCodespaces && codespacesDomain) { 3458 const codespaceWildcard = `https://*.${codespacesDomain}`; 3459 cspFrameSrc += ` ${codespaceWildcard}`; 3460 cspChildSrc += ` ${codespaceWildcard}`; 3461 } 3462 3463 const sessionAesthetic = extContext.globalState.get( 3464 "aesthetic:session", 3465 undefined, 3466 ); 3467 3468 let param = "?vscode=true"; 3469 if (sessionAesthetic && typeof sessionAesthetic === "object") { 3470 try { 3471 const encoded = Buffer.from(JSON.stringify(sessionAesthetic)).toString( 3472 "base64", 3473 ); 3474 param += `&session-aesthetic=${encodeURIComponent(encoded)}`; 3475 } catch (e) { 3476 console.log("🔴 Failed to encode session for News webview:", e); 3477 param += "&session-aesthetic=null"; 3478 } 3479 } else { 3480 param += "&session-aesthetic=null"; 3481 } 3482 3483 return `<!DOCTYPE html> 3484 <html lang="en"> 3485 <head> 3486 <meta charset="UTF-8"> 3487 <meta http-equiv="Content-Security-Policy" content="default-src 'none'; ${cspFrameSrc}; ${cspChildSrc}; style-src ${webview.cspSource} 'unsafe-inline'; script-src 'nonce-${nonce}'; media-src *; img-src ${webview.cspSource} data:;"> 3488 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 3489 <link href="${styleUri}" rel="stylesheet"> 3490 <link href="${resetStyleUri}" rel="stylesheet"> 3491 <link href="${vscodeStyleUri}" rel="stylesheet"> 3492 <title>News</title> 3493 <style> 3494 body { margin: 0; padding: 0; overflow: hidden; } 3495 iframe#news { width: 100vw; height: 100vh; border: none; } 3496 </style> 3497 </head> 3498 <body> 3499 <iframe id="news" class="visible" sandbox="allow-scripts allow-same-origin allow-modals allow-popups allow-popups-to-escape-sandbox allow-forms allow-presentation" allow="clipboard-write; clipboard-read; autoplay; encrypted-media; fullscreen; accelerometer; gyroscope; picture-in-picture" src="${iframeProtocol}${iframeUrl}${path}${param}"></iframe> 3500 <script nonce="${nonce}"> 3501 const vscode = acquireVsCodeApi(); 3502 const newsIframe = document.getElementById('news'); 3503 3504 window.addEventListener('message', (event) => { 3505 if (event.data?.type === 'setSession') { 3506 newsIframe?.contentWindow?.postMessage(event.data, '*'); 3507 } 3508 // Forward news: prefixed messages and login/logout/signup to extension 3509 if (event.data?.type && ( 3510 event.data.type.startsWith('vscode-extension:') || 3511 event.data.type.startsWith('news:') || 3512 event.data.type === 'login' || 3513 event.data.type === 'logout' || 3514 event.data.type === 'signup' || 3515 event.data.type === 'openExternal' 3516 )) { 3517 vscode.postMessage(event.data); 3518 } 3519 }); 3520 3521 newsIframe?.addEventListener('load', () => { 3522 vscode.postMessage({ type: 'news:ready' }); 3523 }); 3524 </script> 3525 </body> 3526 </html>`; 3527 } 3528 3529// 🧭 AT WebView Content 3530function getAtWebViewContent(webview: any) { 3531 const nonce = getNonce(); 3532 3533 const styleUri = webview.asWebviewUri( 3534 vscode.Uri.joinPath(extContext.extensionUri, "main.css"), 3535 ); 3536 3537 const resetStyleUri = webview.asWebviewUri( 3538 vscode.Uri.joinPath(extContext.extensionUri, "reset.css"), 3539 ); 3540 3541 const vscodeStyleUri = webview.asWebviewUri( 3542 vscode.Uri.joinPath(extContext.extensionUri, "vscode.css"), 3543 ); 3544 3545 let iframeUrl; 3546 let iframeProtocol = "https://"; 3547 if (isCodespaces && codespaceName && codespacesDomain) { 3548 iframeUrl = `${codespaceName}-4177.${codespacesDomain}`; 3549 } else if (local) { 3550 iframeUrl = "localhost:4177"; 3551 iframeProtocol = "http://"; 3552 } else { 3553 iframeUrl = "at.aesthetic.computer"; 3554 } 3555 3556 let cspFrameSrc = "frame-src https://at.aesthetic.computer http://localhost:4177"; 3557 let cspChildSrc = "child-src https://at.aesthetic.computer http://localhost:4177"; 3558 3559 if (isCodespaces && codespacesDomain) { 3560 const codespaceWildcard = `https://*.${codespacesDomain}`; 3561 cspFrameSrc += ` ${codespaceWildcard}`; 3562 cspChildSrc += ` ${codespaceWildcard}`; 3563 } 3564 3565 const path = local ? "/user.html" : "/"; 3566 const param = local ? "?handle=art.at.aesthetic.computer&vscode=true" : "?vscode=true"; 3567 3568 return `<!DOCTYPE html> 3569 <html lang="en"> 3570 <head> 3571 <meta charset="UTF-8"> 3572 <meta http-equiv="Content-Security-Policy" content="default-src 'none'; ${cspFrameSrc}; ${cspChildSrc}; style-src ${webview.cspSource} 'unsafe-inline'; script-src 'nonce-${nonce}'; media-src *; img-src ${webview.cspSource} data:;"> 3573 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 3574 <link href="${styleUri}" rel="stylesheet"> 3575 <link href="${resetStyleUri}" rel="stylesheet"> 3576 <link href="${vscodeStyleUri}" rel="stylesheet"> 3577 <title>AT</title> 3578 <style> 3579 body { 3580 margin: 0; 3581 padding: 0; 3582 overflow: hidden; 3583 } 3584 iframe#at { 3585 width: 100vw; 3586 height: 100vh; 3587 border: none; 3588 } 3589 </style> 3590 </head> 3591 <body> 3592 <iframe id="at" class="visible" sandbox="allow-scripts allow-same-origin allow-modals allow-popups allow-popups-to-escape-sandbox allow-forms allow-presentation" allow="clipboard-write; clipboard-read" src="${iframeProtocol}${iframeUrl}${path}${param}" border="none"></iframe> 3593 </body> 3594 </html>`; 3595} 3596 3597// 🌈 KidLisp.com WebView Content 3598function getKidLispWebViewContent(webview: any) { 3599 const nonce = getNonce(); 3600 3601 // Detect VS Code theme for KidLisp waiting screen 3602 const themeKind = vscode.window.activeColorTheme.kind; 3603 const isDark = themeKind === 2 || themeKind === 3; // Dark or HighContrast 3604 3605 const styleUri = webview.asWebviewUri( 3606 vscode.Uri.joinPath(extContext.extensionUri, "main.css"), 3607 ); 3608 3609 const resetStyleUri = webview.asWebviewUri( 3610 vscode.Uri.joinPath(extContext.extensionUri, "reset.css"), 3611 ); 3612 3613 const vscodeStyleUri = webview.asWebviewUri( 3614 vscode.Uri.joinPath(extContext.extensionUri, "vscode.css"), 3615 ); 3616 3617 // Show waiting UI if local mode is enabled but server isn't available yet 3618 if (local && !localServerAvailable && !isCodespaces) { 3619 // Theme-aware colors 3620 const bg = isDark ? 'linear-gradient(135deg, #181318 0%, #141214 100%)' : 'linear-gradient(135deg, #fffacd 0%, #fff9c0 100%)'; 3621 const textColor = isDark ? '#ffffffcc' : '#333'; 3622 const accentColor = isDark ? '#ff69b4' : '#9370DB'; 3623 const subtitleBg = isDark ? '#1a1a1a' : '#fff'; 3624 const subtitleBorder = isDark ? '#483848' : '#e0d8a8'; 3625 const subtitleColor = isDark ? '#b0a0a8' : '#666'; 3626 3627 return `<!DOCTYPE html> 3628 <html lang="en"> 3629 <head> 3630 <meta charset="UTF-8"> 3631 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 3632 <link href="${styleUri}" rel="stylesheet"> 3633 <link href="${resetStyleUri}" rel="stylesheet"> 3634 <link href="${vscodeStyleUri}" rel="stylesheet"> 3635 <title>KidLisp.com</title> 3636 <style> 3637 body { 3638 display: flex; 3639 flex-direction: column; 3640 align-items: center; 3641 justify-content: center; 3642 height: 100vh; 3643 margin: 0; 3644 background: ${bg}; 3645 color: ${textColor}; 3646 font-family: system-ui, -apple-system, sans-serif; 3647 } 3648 .waiting { 3649 text-align: center; 3650 } 3651 .plug { 3652 font-size: 64px; 3653 margin-bottom: 24px; 3654 animation: wiggle 2s ease-in-out infinite; 3655 } 3656 .title { 3657 font-size: 20px; 3658 font-weight: 500; 3659 color: ${accentColor}; 3660 margin-bottom: 12px; 3661 letter-spacing: 0.5px; 3662 } 3663 .subtitle { 3664 font-size: 14px; 3665 color: ${subtitleColor}; 3666 font-family: monospace; 3667 background: ${subtitleBg}; 3668 padding: 8px 16px; 3669 border-radius: 4px; 3670 border: 1px solid ${subtitleBorder}; 3671 } 3672 .subtitle code { 3673 color: ${accentColor}; 3674 font-weight: 600; 3675 } 3676 .dots::after { 3677 content: ''; 3678 animation: dots 1.5s steps(4, end) infinite; 3679 } 3680 @keyframes dots { 3681 0%, 20% { content: ''; } 3682 40% { content: '.'; } 3683 60% { content: '..'; } 3684 80%, 100% { content: '...'; } 3685 } 3686 @keyframes wiggle { 3687 0%, 100% { transform: rotate(-5deg); } 3688 50% { transform: rotate(5deg); } 3689 } 3690 </style> 3691 </head> 3692 <body> 3693 <div class="waiting"> 3694 <div class="plug">🌈</div> 3695 <div class="title">Waiting for local server<span class="dots"></span></div> 3696 <div class="subtitle">Run <code>ac-site</code> to start localhost:8888</div> 3697 </div> 3698 </body> 3699 </html>`; 3700 } 3701 3702 // Determine the iframe URL based on environment 3703 let iframeUrl; 3704 let iframeProtocol = "https://"; 3705 if (isCodespaces && codespaceName && codespacesDomain) { 3706 iframeUrl = `${codespaceName}-8888.${codespacesDomain}`; 3707 } else { 3708 iframeUrl = local ? "localhost:8888" : "aesthetic.computer"; 3709 } 3710 3711 // Build CSP for kidlisp.com 3712 let cspFrameSrc = "frame-src https://aesthetic.computer https://localhost:8888"; 3713 let cspChildSrc = "child-src https://aesthetic.computer https://localhost:8888"; 3714 3715 if (isCodespaces && codespacesDomain) { 3716 const codespaceWildcard = `https://*.${codespacesDomain}`; 3717 cspFrameSrc += ` ${codespaceWildcard}`; 3718 cspChildSrc += ` ${codespaceWildcard}`; 3719 } 3720 3721 // Encode current session for kidlisp.com so it can hydrate without another login 3722 const sessionAesthetic = extContext.globalState.get( 3723 "aesthetic:session", 3724 undefined, 3725 ); 3726 3727 let param = "?vscode=true"; 3728 if (sessionAesthetic && typeof sessionAesthetic === "object") { 3729 try { 3730 const encoded = Buffer.from(JSON.stringify(sessionAesthetic)).toString( 3731 "base64", 3732 ); 3733 param += `&session-aesthetic=${encodeURIComponent(encoded)}`; 3734 } catch (e) { 3735 console.log("🔴 Failed to encode session for KidLisp webview:", e); 3736 param += "&session-aesthetic=null"; 3737 } 3738 } else { 3739 param += "&session-aesthetic=null"; 3740 } 3741 3742 return `<!DOCTYPE html> 3743 <html lang="en"> 3744 <head> 3745 <meta charset="UTF-8"> 3746 <meta http-equiv="Content-Security-Policy" content="default-src 'none'; ${cspFrameSrc}; ${cspChildSrc}; style-src ${webview.cspSource} 'unsafe-inline'; script-src 'nonce-${nonce}'; media-src *; img-src ${webview.cspSource} data:;"> 3747 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 3748 <link href="${styleUri}" rel="stylesheet"> 3749 <link href="${resetStyleUri}" rel="stylesheet"> 3750 <link href="${vscodeStyleUri}" rel="stylesheet"> 3751 <title>KidLisp.com</title> 3752 <style> 3753 body { 3754 margin: 0; 3755 padding: 0; 3756 overflow: hidden; 3757 } 3758 iframe#kidlisp { 3759 width: 100vw; 3760 height: 100vh; 3761 border: none; 3762 background: linear-gradient(135deg, #fffacd 0%, #fff9c0 100%); 3763 } 3764 </style> 3765 </head> 3766 <body> 3767 <iframe id="kidlisp" class="visible" sandbox="allow-scripts allow-same-origin allow-modals allow-popups allow-popups-to-escape-sandbox allow-presentation" allow="clipboard-write; clipboard-read" src="${iframeProtocol}${iframeUrl}/kidlisp.com${param}" border="none"></iframe> 3768 <script nonce="${nonce}"> 3769 const vscode = acquireVsCodeApi(); 3770 const kidlispIframe = document.getElementById('kidlisp'); 3771 3772 // Forward messages from extension to the iframe (sessions, etc.) 3773 window.addEventListener('message', (event) => { 3774 if (event.data?.type === 'setSession') { 3775 kidlispIframe?.contentWindow?.postMessage(event.data, '*'); 3776 } 3777 if (event.data && event.data.type && (event.data.type.startsWith('vscode-extension:') || event.data.type.startsWith('kidlisp:'))) { 3778 vscode.postMessage(event.data); 3779 } 3780 }); 3781 3782 kidlispIframe?.addEventListener('load', () => { 3783 vscode.postMessage({ type: 'kidlisp:ready' }); 3784 }); 3785 </script> 3786 </body> 3787 </html>`; 3788} 3789 3790export { activate, AestheticViewProvider };