From ad8a9513d9a39b50765679dc3fac0dd3f98c2fac Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Tue, 30 Dec 2025 16:17:08 +0000 Subject: [PATCH] feat(editor): add modal prompts for file/folder creation, improve Monaco editor reactivity and add TypeScript IntelliSense support --- changelog.md | 9 + ts_web/00_commitinfo_data.ts | 2 +- .../dees-editor-filetree.ts | 62 ++++- .../dees-editor-monaco/dees-editor-monaco.ts | 45 +++- .../dees-editor-workspace.ts | 135 ++++++++++- .../dees-editor-workspace/index.ts | 1 + .../typescript-intellisense.ts | 220 ++++++++++++++++++ 7 files changed, 467 insertions(+), 7 deletions(-) create mode 100644 ts_web/elements/00group-editor/dees-editor-workspace/typescript-intellisense.ts diff --git a/changelog.md b/changelog.md index d50c481..de0bbeb 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,14 @@ # Changelog +## 2025-12-30 - 3.14.0 - feat(editor) +add modal prompts for file/folder creation, improve Monaco editor reactivity and add TypeScript IntelliSense support + +- Replace window.prompt for new file/folder with DeesModal + DeesInputText (showInputModal) to provide a focused modal input UX. +- Monaco editor: add language property, handle external content updates without emitting change events (isUpdatingFromExternal), dispatch 'content-change' events, and apply language changes at runtime. +- Add TypeScriptIntelliSenseManager to load .d.ts/type packages from the virtual filesystem (/node_modules), parse imports, load @types fallbacks, and add file models to Monaco for cross-file IntelliSense. +- Workspace demo now mounts an initial TypeScript project and exposes initializationPromise to wait for external setup; workspace initializes IntelliSense and processes content changes to keep types up to date. +- Export typescript-intellisense from workspace index so the manager is available to consumers. + ## 2025-12-30 - 3.13.1 - fix(webcontainer) prevent double initialization and race conditions when booting WebContainer and loading editor workspace/file tree diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index ac4f29d..bf53f96 100644 --- a/ts_web/00_commitinfo_data.ts +++ b/ts_web/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@design.estate/dees-catalog', - version: '3.13.1', + version: '3.14.0', description: 'A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.' } diff --git a/ts_web/elements/00group-editor/dees-editor-filetree/dees-editor-filetree.ts b/ts_web/elements/00group-editor/dees-editor-filetree/dees-editor-filetree.ts index 714a8fa..f7c3c51 100644 --- a/ts_web/elements/00group-editor/dees-editor-filetree/dees-editor-filetree.ts +++ b/ts_web/elements/00group-editor/dees-editor-filetree/dees-editor-filetree.ts @@ -14,6 +14,9 @@ import type { IExecutionEnvironment, IFileEntry } from '../../00group-runtime/in import '../../dees-icon/dees-icon.js'; import '../../dees-contextmenu/dees-contextmenu.js'; import { DeesContextmenu } from '../../dees-contextmenu/dees-contextmenu.js'; +import { DeesModal } from '../../dees-modal/dees-modal.js'; +import '../../00group-input/dees-input-text/dees-input-text.js'; +import { DeesInputText } from '../../00group-input/dees-input-text/dees-input-text.js'; declare global { interface HTMLElementTagNameMap { @@ -411,8 +414,60 @@ export class DeesEditorFiletree extends DeesElement { await DeesContextmenu.openContextMenuWithOptions(e, menuItems); } + private async showInputModal(options: { + heading: string; + label: string; + }): Promise { + return new Promise(async (resolve) => { + let inputValue = ''; + + const modal = await DeesModal.createAndShow({ + heading: options.heading, + width: 'small', + content: html` + { + inputValue = (e.target as DeesInputText).value; + }} + > + `, + menuOptions: [ + { + name: 'Cancel', + action: async (modalRef) => { + await modalRef.destroy(); + resolve(null); + }, + }, + { + name: 'Create', + action: async (modalRef) => { + await modalRef.destroy(); + resolve(inputValue.trim() || null); + }, + }, + ], + }); + + // Focus the input after modal renders + await modal.updateComplete; + const contentEl = modal.shadowRoot?.querySelector('.modal .content'); + if (contentEl) { + const inputElement = contentEl.querySelector('dees-input-text') as DeesInputText | null; + if (inputElement) { + await inputElement.updateComplete; + inputElement.focus(); + } + } + }); + } + private async createNewFile(parentPath: string) { - const fileName = prompt('Enter file name:'); + const fileName = await this.showInputModal({ + heading: 'New File', + label: 'File name', + }); if (!fileName || !this.executionEnvironment) return; const newPath = parentPath === '/' ? `/${fileName}` : `${parentPath}/${fileName}`; @@ -432,7 +487,10 @@ export class DeesEditorFiletree extends DeesElement { } private async createNewFolder(parentPath: string) { - const folderName = prompt('Enter folder name:'); + const folderName = await this.showInputModal({ + heading: 'New Folder', + label: 'Folder name', + }); if (!folderName || !this.executionEnvironment) return; const newPath = parentPath === '/' ? `/${folderName}` : `${parentPath}/${folderName}`; diff --git a/ts_web/elements/00group-editor/dees-editor-monaco/dees-editor-monaco.ts b/ts_web/elements/00group-editor/dees-editor-monaco/dees-editor-monaco.ts index 5e0f944..51c134c 100644 --- a/ts_web/elements/00group-editor/dees-editor-monaco/dees-editor-monaco.ts +++ b/ts_web/elements/00group-editor/dees-editor-monaco/dees-editor-monaco.ts @@ -29,13 +29,17 @@ export class DeesEditorMonaco extends DeesElement { // INSTANCE public editorDeferred = domtools.plugins.smartpromise.defer(); - public language = 'typescript'; @property({ type: String }) accessor content = "function hello() {\n\talert('Hello world!');\n}"; + @property({ + type: String + }) + accessor language = 'typescript'; + @property({ type: Object }) @@ -47,6 +51,7 @@ export class DeesEditorMonaco extends DeesElement { accessor wordWrap: monaco.editor.IStandaloneEditorConstructionOptions['wordWrap'] = 'off'; private monacoThemeSubscription: domtools.plugins.smartrx.rxjs.Subscription | null = null; + private isUpdatingFromExternal: boolean = false; constructor() { super(); @@ -138,11 +143,47 @@ export class DeesEditorMonaco extends DeesElement { // editor is setup let do the rest const editor = await this.editorDeferred.promise; editor.onDidChangeModelContent(async eventArg => { - this.contentSubject.next(editor.getValue()); + // Don't emit events when we're programmatically updating the content + if (this.isUpdatingFromExternal) return; + + const value = editor.getValue(); + this.contentSubject.next(value); + this.dispatchEvent(new CustomEvent('content-change', { + detail: value, + bubbles: true, + composed: true, + })); }); this.contentSubject.next(editor.getValue()); } + public async updated(changedProperties: Map): Promise { + super.updated(changedProperties); + + // Handle content changes + if (changedProperties.has('content')) { + const editor = await this.editorDeferred.promise; + const currentValue = editor.getValue(); + if (currentValue !== this.content) { + this.isUpdatingFromExternal = true; + editor.setValue(this.content); + this.isUpdatingFromExternal = false; + } + } + + // Handle language changes + if (changedProperties.has('language')) { + const editor = await this.editorDeferred.promise; + const model = editor.getModel(); + if (model) { + const monacoInstance = (window as any).monaco; + if (monacoInstance) { + monacoInstance.editor.setModelLanguage(model, this.language); + } + } + } + } + public async disconnectedCallback(): Promise { await super.disconnectedCallback(); if (this.monacoThemeSubscription) { diff --git a/ts_web/elements/00group-editor/dees-editor-workspace/dees-editor-workspace.ts b/ts_web/elements/00group-editor/dees-editor-workspace/dees-editor-workspace.ts index b7743a7..ba237b8 100644 --- a/ts_web/elements/00group-editor/dees-editor-workspace/dees-editor-workspace.ts +++ b/ts_web/elements/00group-editor/dees-editor-workspace/dees-editor-workspace.ts @@ -12,11 +12,14 @@ import * as domtools from '@design.estate/dees-domtools'; import { themeDefaultStyles } from '../../00theme.js'; import type { IExecutionEnvironment } from '../../00group-runtime/index.js'; import { WebContainerEnvironment } from '../../00group-runtime/index.js'; +import type { FileSystemTree } from '@webcontainer/api'; import '../dees-editor-monaco/dees-editor-monaco.js'; import '../dees-editor-filetree/dees-editor-filetree.js'; +import { DeesEditorFiletree } from '../dees-editor-filetree/dees-editor-filetree.js'; import '../../dees-terminal/dees-terminal.js'; import '../../dees-icon/dees-icon.js'; import { DeesEditorMonaco } from '../dees-editor-monaco/dees-editor-monaco.js'; +import { TypeScriptIntelliSenseManager } from './typescript-intellisense.js'; declare global { interface HTMLElementTagNameMap { @@ -35,9 +38,108 @@ interface IOpenFile { export class DeesEditorWorkspace extends DeesElement { public static demo = () => { const env = new WebContainerEnvironment(); + + // Mount initial TypeScript project files + const mountPromise = (async () => { + await env.init(); + + const fileTree: FileSystemTree = { + 'package.json': { + file: { + contents: JSON.stringify( + { + name: 'demo-project', + version: '1.0.0', + type: 'module', + scripts: { + build: 'tsc', + dev: 'tsc --watch', + }, + devDependencies: { + typescript: '^5.0.0', + }, + }, + null, + 2 + ), + }, + }, + 'tsconfig.json': { + file: { + contents: JSON.stringify( + { + compilerOptions: { + target: 'ES2022', + module: 'NodeNext', + moduleResolution: 'NodeNext', + strict: true, + outDir: './dist', + rootDir: './src', + declaration: true, + }, + include: ['src/**/*'], + }, + null, + 2 + ), + }, + }, + src: { + directory: { + 'index.ts': { + file: { + contents: `// Main entry point +import { greet, formatName } from './utils.js'; + +const name = formatName('World'); +console.log(greet(name)); + +// Example async function +async function main() { + const result = await Promise.resolve('Hello from async!'); + console.log(result); +} + +main(); +`, + }, + }, + 'utils.ts': { + file: { + contents: `// Utility functions + +export interface IUser { + firstName: string; + lastName: string; +} + +export function greet(name: string): string { + return \`Hello, \${name}!\`; +} + +export function formatName(name: string): string { + return name.trim().toUpperCase(); +} + +export function createUser(firstName: string, lastName: string): IUser { + return { firstName, lastName }; +} +`, + }, + }, + }, + }, + }; + + await env.mount(fileTree); + })(); + return html`
- +
`; }; @@ -46,6 +148,9 @@ export class DeesEditorWorkspace extends DeesElement { @property({ type: Object }) accessor executionEnvironment: IExecutionEnvironment | null = null; + @property({ attribute: false }) + accessor initializationPromise: Promise | null = null; + @property({ type: Boolean }) accessor showFileTree: boolean = true; @@ -75,6 +180,7 @@ export class DeesEditorWorkspace extends DeesElement { private editorElement: DeesEditorMonaco | null = null; private initializationStarted: boolean = false; + private intelliSenseManager: TypeScriptIntelliSenseManager | null = null; public static styles = [ themeDefaultStyles, @@ -450,9 +556,14 @@ export class DeesEditorWorkspace extends DeesElement { this.isInitializing = true; try { - if (!this.executionEnvironment.ready) { + // Wait for any external initialization (e.g., file mounting) + if (this.initializationPromise) { + await this.initializationPromise; + } else if (!this.executionEnvironment.ready) { await this.executionEnvironment.init(); } + // Initialize IntelliSense after workspace is ready + await this.initializeIntelliSense(); } catch (error) { console.error('Failed to initialize workspace:', error); // Reset flag to allow retry @@ -462,6 +573,20 @@ export class DeesEditorWorkspace extends DeesElement { } } + private async initializeIntelliSense(): Promise { + if (!this.executionEnvironment) return; + + // Wait for Monaco to be available globally + const monacoInstance = (window as any).monaco; + if (!monacoInstance) { + console.warn('Monaco not loaded, IntelliSense disabled'); + return; + } + + this.intelliSenseManager = new TypeScriptIntelliSenseManager(); + await this.intelliSenseManager.init(monacoInstance, this.executionEnvironment); + } + private async handleFileSelect(e: CustomEvent<{ path: string; name: string }>) { const { path, name } = e.detail; await this.openFile(path, name); @@ -537,6 +662,12 @@ export class DeesEditorWorkspace extends DeesElement { { ...file, content: newContent, modified: true }, ...this.openFiles.slice(fileIndex + 1), ]; + + // Process content for IntelliSense (TypeScript/JavaScript files) + const language = this.getLanguageFromPath(this.activeFilePath); + if (this.intelliSenseManager && (language === 'typescript' || language === 'javascript')) { + this.intelliSenseManager.processContentChange(newContent); + } } } diff --git a/ts_web/elements/00group-editor/dees-editor-workspace/index.ts b/ts_web/elements/00group-editor/dees-editor-workspace/index.ts index f2fca34..ab7e62f 100644 --- a/ts_web/elements/00group-editor/dees-editor-workspace/index.ts +++ b/ts_web/elements/00group-editor/dees-editor-workspace/index.ts @@ -1 +1,2 @@ export * from './dees-editor-workspace.js'; +export * from './typescript-intellisense.js'; diff --git a/ts_web/elements/00group-editor/dees-editor-workspace/typescript-intellisense.ts b/ts_web/elements/00group-editor/dees-editor-workspace/typescript-intellisense.ts new file mode 100644 index 0000000..16fa785 --- /dev/null +++ b/ts_web/elements/00group-editor/dees-editor-workspace/typescript-intellisense.ts @@ -0,0 +1,220 @@ +import type * as monaco from 'monaco-editor'; +import type { IExecutionEnvironment } from '../../00group-runtime/index.js'; + +/** + * Manages TypeScript IntelliSense by loading type definitions + * from the virtual filesystem into Monaco. + */ +export class TypeScriptIntelliSenseManager { + private loadedLibs: Set = new Set(); + private monacoInstance: typeof monaco | null = null; + private executionEnvironment: IExecutionEnvironment | null = null; + + /** + * Initialize with Monaco and execution environment + */ + public async init( + monacoInst: typeof monaco, + env: IExecutionEnvironment + ): Promise { + this.monacoInstance = monacoInst; + this.executionEnvironment = env; + this.configureCompilerOptions(); + } + + private configureCompilerOptions(): void { + if (!this.monacoInstance) return; + + this.monacoInstance.languages.typescript.typescriptDefaults.setCompilerOptions({ + target: this.monacoInstance.languages.typescript.ScriptTarget.ES2020, + module: this.monacoInstance.languages.typescript.ModuleKind.ESNext, + moduleResolution: this.monacoInstance.languages.typescript.ModuleResolutionKind.NodeJs, + allowSyntheticDefaultImports: true, + esModuleInterop: true, + strict: true, + noEmit: true, + allowJs: true, + checkJs: false, + lib: ['es2020', 'dom', 'dom.iterable'], + }); + + this.monacoInstance.languages.typescript.typescriptDefaults.setDiagnosticsOptions({ + noSemanticValidation: false, + noSyntaxValidation: false, + }); + } + + /** + * Parse imports from TypeScript/JavaScript content + */ + public parseImports(content: string): string[] { + const imports: string[] = []; + + // Match ES6 imports: import { x } from 'package' or import 'package' + const importRegex = /import\s+(?:[\w*{}\s,]+from\s+)?['"]([^'"]+)['"]/g; + let match: RegExpExecArray | null; + + while ((match = importRegex.exec(content)) !== null) { + const importPath = match[1]; + // Only process non-relative imports (npm packages) + if (!importPath.startsWith('.') && !importPath.startsWith('/')) { + const packageName = importPath.startsWith('@') + ? importPath.split('/').slice(0, 2).join('/') // @scope/package + : importPath.split('/')[0]; // package + imports.push(packageName); + } + } + + // Match require calls: require('package') + const requireRegex = /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g; + while ((match = requireRegex.exec(content)) !== null) { + const importPath = match[1]; + if (!importPath.startsWith('.') && !importPath.startsWith('/')) { + const packageName = importPath.startsWith('@') + ? importPath.split('/').slice(0, 2).join('/') + : importPath.split('/')[0]; + imports.push(packageName); + } + } + + return [...new Set(imports)]; + } + + /** + * Load type definitions for a package from virtual FS + */ + public async loadTypesForPackage(packageName: string): Promise { + if (!this.monacoInstance || !this.executionEnvironment) return; + if (this.loadedLibs.has(packageName)) return; + + try { + const typesLoaded = await this.tryLoadPackageTypes(packageName); + if (!typesLoaded) { + await this.tryLoadAtTypesPackage(packageName); + } + this.loadedLibs.add(packageName); + } catch (error) { + console.warn(`Failed to load types for ${packageName}:`, error); + } + } + + private async tryLoadPackageTypes(packageName: string): Promise { + if (!this.executionEnvironment || !this.monacoInstance) return false; + + const basePath = `/node_modules/${packageName}`; + + try { + // Check package.json for types field + const packageJsonPath = `${basePath}/package.json`; + if (await this.executionEnvironment.exists(packageJsonPath)) { + const packageJson = JSON.parse( + await this.executionEnvironment.readFile(packageJsonPath) + ); + + const typesPath = packageJson.types || packageJson.typings; + if (typesPath) { + const fullTypesPath = `${basePath}/${typesPath}`; + if (await this.executionEnvironment.exists(fullTypesPath)) { + const content = await this.executionEnvironment.readFile(fullTypesPath); + this.monacoInstance.languages.typescript.typescriptDefaults.addExtraLib( + content, + `file://${fullTypesPath}` + ); + return true; + } + } + } + + // Try common locations + const commonPaths = [ + `${basePath}/index.d.ts`, + `${basePath}/dist/index.d.ts`, + `${basePath}/lib/index.d.ts`, + ]; + + for (const dtsPath of commonPaths) { + if (await this.executionEnvironment.exists(dtsPath)) { + const content = await this.executionEnvironment.readFile(dtsPath); + this.monacoInstance.languages.typescript.typescriptDefaults.addExtraLib( + content, + `file://${dtsPath}` + ); + return true; + } + } + + return false; + } catch { + return false; + } + } + + private async tryLoadAtTypesPackage(packageName: string): Promise { + if (!this.executionEnvironment || !this.monacoInstance) return false; + + // Handle scoped packages: @scope/package -> @types/scope__package + const typesPackageName = packageName.startsWith('@') + ? `@types/${packageName.slice(1).replace('/', '__')}` + : `@types/${packageName}`; + + const basePath = `/node_modules/${typesPackageName}`; + + try { + const indexPath = `${basePath}/index.d.ts`; + if (await this.executionEnvironment.exists(indexPath)) { + const content = await this.executionEnvironment.readFile(indexPath); + this.monacoInstance.languages.typescript.typescriptDefaults.addExtraLib( + content, + `file://${indexPath}` + ); + return true; + } + return false; + } catch { + return false; + } + } + + /** + * Process content change and load types for any new imports + */ + public async processContentChange(content: string): Promise { + const imports = this.parseImports(content); + for (const packageName of imports) { + await this.loadTypesForPackage(packageName); + } + } + + /** + * Add a file model to Monaco for cross-file IntelliSense + */ + public addFileModel(path: string, content: string): void { + if (!this.monacoInstance) return; + + const uri = this.monacoInstance.Uri.parse(`file://${path}`); + const existingModel = this.monacoInstance.editor.getModel(uri); + + if (existingModel) { + existingModel.setValue(content); + } else { + const language = this.getLanguageFromPath(path); + this.monacoInstance.editor.createModel(content, language, uri); + } + } + + private getLanguageFromPath(path: string): string { + const ext = path.split('.').pop()?.toLowerCase(); + switch (ext) { + case 'ts': + case 'tsx': + return 'typescript'; + case 'js': + case 'jsx': + return 'javascript'; + case 'json': + return 'json'; + default: + return 'plaintext'; + } + } +}