import { App } from "@web-atoms/core/dist/App"; import { Atom } from "@web-atoms/core/dist/Atom"; import DISingleton from "@web-atoms/core/dist/di/DISingleton"; import { Inject } from "@web-atoms/core/dist/di/Inject"; import FileService, { IFolderStructure, ISaveModel } from "./FileService"; import { AtomBinder } from "@web-atoms/core/dist/core/AtomBinder"; import { AtomDisposableList } from "@web-atoms/core/dist/core/AtomDisposableList"; import { UMD } from "@web-atoms/core/dist/core/types"; import settingsDefault from "../commands/new-app/.vscode/settings"; import tasksDefault from "../commands/new-app/.vscode/tasks"; import packageDefault from "../commands/new-app/package-default"; import tsconfigDefault from "../commands/new-app/tsconfig-default"; import tslintDefault from "../commands/new-app/tslint-default"; import FileModel from "../model/FileModel"; import IContentModel from "../model/IContentModel"; import EditorIDService from "./EditorIDService"; import FileUploadService from "./FileUploadService"; import Logger from "./Logger"; interface IOutputFileModel { outputFiles: Array<{ name: string, text: string }>; } declare var zip: any; const prefix = "http://a/dist/src".length; let timeout = 0; /** * Downloads and manages source code in the browser * this also saves source code snapshot in the browser */ @DISingleton() export default class SourceService { public root: FileModel = new FileModel(null); public folder: IFolderStructure = null; public isCompiling: boolean = false; public get dirty() { return this.root.dirtyChild; } public errors: monaco.languages.typescript.Diagnostic[] = []; public warnings: monaco.languages.typescript.Diagnostic[] = []; public messages: FileModel[] = []; public position: { lineNumber: number, column: number }; @Inject public fileService: FileService; @Inject public logger: Logger; @Inject private app: App; @Inject private fileUploadService: FileUploadService; @Inject private editorIDService: EditorIDService; private disposableList: AtomDisposableList = new AtomDisposableList(); private watchList: Map = new Map(); private tsWorker: (... uri: monaco.Uri[]) => Promise; /** * Updates the current model by downloading * all files from the given source location * @param url url */ public async updateModel(url: string) { if (this.folder && this.folder.root === url) { return; } this.logger.log(`Reading file list ${url}`); this.disposableList.dispose(); this.watchList.clear(); this.disposableList = new AtomDisposableList(); const result = (await this.fileService.getSourceFileList(url)); this.folder = result; if (this.root.children) { for (const iterator of this.root.children) { this.deleteFile(iterator); } } this.logger.log(`Downloading all files`); await this.downloadFiles(result); const pkg = JSON.parse(this.root.get("package.json").content); this.folder.start = pkg.start; this.logger.log(`Compiling source code`); this.watchFiles(); this.logger.log(`Opening default view`); // open first packed tsx view.. this.openView(this.root.get("src")); } public async save(url: string): Promise { const sv: ISaveModel = { comments: "", files: [] }; const id = this.editorIDService.getID(url); if (id) { sv.id = id; } this.logger.log(`Waiting for compilation to finish`); const all = this.root.get("src").descendants; while (all.filter((x) => x.isBuilding).length > 0) { await Atom.delay(1000); } this.logger.log(`Preparing to upload`); this.root.forEach((element) => { if (/^node\_modules\//.test(element.url)) { return; } if (element.tempUrl) { sv.files.push({ name: element.url, content: element.content, url: element.tempUrl }); return; } if (element.dirty || !element.snapshot) { sv.files.push({ name: element.url, content: element.content }); } else { sv.files.push({ name: element.url, content: null, snapshot: element.snapshot }); } }); const result = await this.fileService.saveFiles(url, sv); this.logger.log(`Changes uploaded successfully.`); this.logger.log(`Updating QR Code.`); this.folder = result; this.folder.start = JSON.parse(this.root.get("package.json").content).start; // mark all as saved.. for (const iterator of result.list) { if (iterator.package) { continue; } const existing = this.root.get(iterator.path); // case when packed files are not sent if (!existing) { continue; } if (existing.content) { existing.originalContent = existing.content; } else { existing.tempUrl = null; } existing.snapshot = iterator.snapshot; } AtomBinder.refreshValue(this, "dirty"); return result.root; } public async generateTemplate(template: IContentModel) { this.logger.log(`Generating new template`); this.disposableList.dispose(); this.watchList.clear(); const packageJson = packageDefault; const deps = packageJson.dependencies; const packageVersions = []; for (const key in deps) { if (deps.hasOwnProperty(key)) { const element = deps[key]; packageVersions.push([key, element]); } } // load package dependencies... this.logger.log(`Resolving dependencies`); const packages = packageVersions.map((pkg) => this.fileService.getPackageFiles(pkg[0], pkg[1])); const modules = await Promise.all(packages); this.logger.log(`Downloading dependencies`); await this.downloadFiles({ root: "", host: "", start: "", contentUrl: "", downloadUrl: "", list: modules }); // lets add template files.. this.logger.log(`Downloading template source`); await this.expandTemplate(template, "commands/new-app/templates"); this.root.create("dist", true); const copy = JSON.parse(JSON.stringify(packageJson)); copy.start = `src/${template.start}`; this.createFile("package.json", { content: JSON.stringify(copy, undefined, 4) }, false); this.createFile("tsconfig.json", { content: JSON.stringify(tsconfigDefault, undefined, 4) }, false); this.createFile("tslint.json", { content: JSON.stringify(tslintDefault, undefined, 4) }, false); this.createFile(".vscode/settings.json", { content: JSON.stringify(settingsDefault, undefined, 4) }, false); this.createFile(".vscode/tasks.json", { content: JSON.stringify(tasksDefault, undefined, 4) }, false); this.logger.log(`Compiling source`); this.watchFiles(); } public createFile( name: string, { content, tempUrl, remoteUrl, snapshot }: { content?: string, tempUrl?: string, remoteUrl?: string, snapshot?: string }, watch: boolean = true) { const file = this.root.get(name, true); file.content = content; if (tempUrl) { file.tempUrl = tempUrl; } if (remoteUrl) { file.remoteUrl = remoteUrl; } if (snapshot) { file.snapshot = snapshot; } file.originalContent = content; if (watch) { this.watchFiles(); } } public async deleteFile(file: FileModel, broadcast: boolean = true) { if (file.isFolder) { for (const child of file.descendants) { this.deleteFile(child); } } else { if (broadcast) { this.app.broadcast("delete-file", { url: file.url }); } this.watchList.delete(file); } if (file.model) { file.model.dispose(); } file.parent.children.remove(file); // we need to update every file.. for (const iterator of this.watchList.keys()) { this.app.runAsync(() => this.updateFileModel(iterator)); } } public async renameFile(file: FileModel, name: string ) { const { content, remoteUrl, tempUrl, snapshot } = file; await this.deleteFile(file); this.createFile(name, { content, remoteUrl, tempUrl, snapshot }); this.app.broadcast("open-url", { url: name }); } public async expandTemplate( template: IContentModel, templateRoot: string, srcFolder: string = "src", name: string = null) { function replace(content: string): string { if (name != null && template.replace) { content = content.split(template.replace).join(name); } return content; } await Promise.all(template.src.map(async (iterator) => { const path = UMD.resolvePath( `@web-atoms/online-editor/src/${templateRoot}/${template.value}/${iterator}`); const fileName = replace(`${srcFolder}/${iterator}`); const model = this.root.create(fileName); if (/\.(ts|tsx|js|jsx|md|json)$/i.test(fileName)) { model.content = replace(await this.fileService.getFileSource(path)); model.originalContent = ""; } else { // we need to download, upload and as a temp url... for successful local testing model.remoteUrl = await this.fileUploadService.saveTemp(path); model.tempUrl = model.remoteUrl; } })); if (template.start) { const fileName = replace(`${srcFolder}/${template.start}`); const start = this.root.get(fileName); if (start) { this.app.broadcast("open-url", { url: start.url }); } } if (name) { this.watchFiles(); // open and close.. const f = this.root.get(srcFolder); f.isOpen = false; f.isOpen = true; } } private async downloadFiles(result: IFolderStructure) { // flatten.. const files = []; for (const iterator of result.list) { if (iterator.package) { for (const f of iterator.files) { f.package = iterator.package; f.version = iterator.version; f.localPath = `node_modules/${f.package}/${f.path}`; f.remoteUrl = `https://cdn.jsdelivr.net/npm/${f.package}@${f.version}/${f.path}`.trim(); files.push(f); } continue; } iterator.localPath = iterator.path; const id = iterator.snapshot; iterator.remoteUrl = `${result.host}/${id}`; files.push(iterator); } // download files in batch of 100s... while (files.length) { const slice = files.splice(0, files.length > 200 ? 200 : files.length); await Promise.all(slice.map(async (f) => { if (/\.pack(\.min)?\.js(\.map)?$/.test(f.localPath)) { return; } const model = this.root.create(f.localPath); model.snapshot = f.snapshot; if (/\.(ts|tsx|json|js|md|map)$/.test(f.localPath)) { model.content = await this.fileService.getFileSource(f.remoteUrl); } else { model.content = null; } model.originalContent = model.content; model.remoteUrl = f.remoteUrl; })); } } private watchFiles() { this.app.runAsync(() => this.watchModel()); } private async watchModel() { while (typeof window.monaco === "undefined") { await Atom.delay(1000); } this.root.forEach((element) => { const isTS = /\.(ts|tsx)$/.test(element.name); const isJson = /\.json$/.test(element.name); if (isJson || isTS) { if (isTS) { // only watch files in src folder if (!/^src\//.test(element.url)) { return; } } else { // ignore installed packages if (/^node\_modules\//.test(element.url)) { return; } } if (this.watchList.has(element)) { return; } this.watchList.set(element, true); const updateFileModel = () => this.app.runAsync(() => this.updateFileModel(element)); this.disposableList.add(element.watchChanges(updateFileModel)); updateFileModel(); } }); } private async getWorker(file: FileModel) { if (/\.(ts|tsx)$/.test(file.name)) { if (!this.tsWorker) { this.tsWorker = await monaco.languages.typescript.getTypeScriptWorker(); } return await this.tsWorker(file.model.uri); } return null; } private async updateFileModel(file: FileModel) { const name = file.model.uri.toString(); const newText = file.model.getValue(); if (newText !== file.content) { file.content = newText; file.snapshot = null; } file.isBuilding = true; this.isCompiling = true; AtomBinder.refreshValue(this, "dirty"); try { const fileWorker = await this.getWorker(file); if (fileWorker) { // // let save ?? // if (file.dirty) { // file.tempUrl = await this.fileUploadService.saveContent(file.content); // } // get errors.. const errors = []; const warnings = []; const messages = []; for (const iterator of await fileWorker.getCompilerOptionsDiagnostics(name)) { (iterator as any).model = file; if (iterator.category === 1) { errors.push(iterator); } else if (iterator.category === 2) { warnings.push(iterator); } else { messages.push(iterator); } } for (const iterator of await fileWorker.getSyntacticDiagnostics(name)) { (iterator as any).model = file; if (iterator.category === 1) { errors.push(iterator); } else if (iterator.category === 2) { warnings.push(iterator); } else { messages.push(iterator); } } for (const iterator of await fileWorker.getSemanticDiagnostics(name)) { (iterator as any).model = file; if (iterator.category === 1) { errors.push(iterator); } else if (iterator.category === 2) { warnings.push(iterator); } else { messages.push(iterator); } } for (const iterator of await fileWorker.getSuggestionDiagnostics(name)) { (iterator as any).model = file; if (iterator.category === 1) { errors.push(iterator); } else if (iterator.category === 2) { warnings.push(iterator); } else { messages.push(iterator); } } for (const iterator of warnings) { messages.insert(0, iterator); } for (const iterator of errors) { messages.insert(0, iterator); } // file.errors = errors; // file.warnings = warnings; file.messages = messages; // file.navigationItems = await fileWorker.getNavigationBarItems(name); const output: IOutputFileModel = await fileWorker.getEmitOutput(name); // tslint:disable-next-line: no-debugger // debugger; for (const outputFile of output.outputFiles) { const fileName = outputFile.name = "dist" + outputFile.name.substr(prefix); if (fileName.endsWith(".map")) { // correct the map... outputFile.text = outputFile.text.replace(/\.\.\/\.\.\/src/g, "../src"); } const newModel = this.root.create(fileName); const generated = outputFile.text; if (generated !== newModel.content) { newModel.snapshot = null; newModel.content = outputFile.text; } if (newModel.originalContent !== undefined ) { newModel.originalContent = newModel.content; } } } } catch (ex) { // tslint:disable-next-line: no-console console.error(ex); } finally { file.isBuilding = false; this.isCompiling = false; AtomBinder.refreshValue(this, "dirty"); } if (timeout ) { clearTimeout(timeout); timeout = 0; } timeout = setTimeout(() => { timeout = 0; this.updateMessages(); }, 500); } private updateMessages() { const errors = []; const warnings = []; const messages = []; const keys = this.watchList.keys(); let i = keys.next(); while (!i.done) { const file = i.value as FileModel; i = keys.next(); for (const iterator of file.messages) { if (iterator.category === 1) { errors.push(iterator); } else if (iterator.category === 2) { warnings.push(iterator); } } if (file.messages.length) { messages.push(file); } } this.errors = errors; this.messages = messages; this.warnings = warnings; } private openView(start: FileModel): boolean { if (!start) { return false; } if (/\.tsx$/.test(start.url) && start.isPacked) { this.app.broadcast("open-url", { url: start.url }); return true; } if (start.children) { for (const iterator of start.children) { if (this.openView(iterator)) { return true; } } } return false; } }