feat(editor): add modal prompts for file/folder creation, improve Monaco editor reactivity and add TypeScript IntelliSense support

This commit is contained in:
2025-12-30 16:17:08 +00:00
parent 339b0e784d
commit ad8a9513d9
7 changed files with 467 additions and 7 deletions

View File

@@ -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<string | null> {
return new Promise(async (resolve) => {
let inputValue = '';
const modal = await DeesModal.createAndShow({
heading: options.heading,
width: 'small',
content: html`
<dees-input-text
.label=${options.label}
@changeSubject=${(e: CustomEvent) => {
inputValue = (e.target as DeesInputText).value;
}}
></dees-input-text>
`,
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}`;

View File

@@ -29,13 +29,17 @@ export class DeesEditorMonaco extends DeesElement {
// INSTANCE
public editorDeferred = domtools.plugins.smartpromise.defer<monaco.editor.IStandaloneCodeEditor>();
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<string, any>): Promise<void> {
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<void> {
await super.disconnectedCallback();
if (this.monacoThemeSubscription) {

View File

@@ -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`
<div style="width: 100%; height: 600px; position: relative;">
<dees-editor-workspace .executionEnvironment=${env}></dees-editor-workspace>
<dees-editor-workspace
.executionEnvironment=${env}
.initializationPromise=${mountPromise}
></dees-editor-workspace>
</div>
`;
};
@@ -46,6 +148,9 @@ export class DeesEditorWorkspace extends DeesElement {
@property({ type: Object })
accessor executionEnvironment: IExecutionEnvironment | null = null;
@property({ attribute: false })
accessor initializationPromise: Promise<void> | 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<void> {
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);
}
}
}

View File

@@ -1 +1,2 @@
export * from './dees-editor-workspace.js';
export * from './typescript-intellisense.js';

View File

@@ -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<string> = 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<void> {
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<void> {
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<boolean> {
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<boolean> {
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<void> {
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';
}
}
}