feat(editor): add modal prompts for file/folder creation, improve Monaco editor reactivity and add TypeScript IntelliSense support
This commit is contained in:
@@ -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}`;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from './dees-editor-workspace.js';
|
||||
export * from './typescript-intellisense.js';
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user