diff --git a/hkmc2/js/src/main/scala/hkmc2/Compiler.scala b/hkmc2/js/src/main/scala/hkmc2/Compiler.scala index 35d12931c3..7b3d9083f9 100644 --- a/hkmc2/js/src/main/scala/hkmc2/Compiler.scala +++ b/hkmc2/js/src/main/scala/hkmc2/Compiler.scala @@ -64,7 +64,7 @@ class Compiler(paths: MLsCompiler.Paths)(using cctx: CompilerCtx): perFileDiagnostics @JSExportTopLevel("Paths") -final class Paths(prelude: Str, runtime: Str, term: Str) extends MLsCompiler.Paths: +final class Paths(prelude: Str, runtime: Str, term: Str, std: Str) extends MLsCompiler.Paths: val preludeFile = Path(prelude) val runtimeFile = Path(runtime) val termFile = Path(term) diff --git a/hkmc2/js/src/test/scala/hkmc2/CompilerTest.scala b/hkmc2/js/src/test/scala/hkmc2/CompilerTest.scala index e8fc6a727f..b4c69ed392 100644 --- a/hkmc2/js/src/test/scala/hkmc2/CompilerTest.scala +++ b/hkmc2/js/src/test/scala/hkmc2/CompilerTest.scala @@ -8,34 +8,44 @@ import scala.scalajs.js.annotation._ import scala.scalajs.js.Dynamic.global class CompilerTest extends AnyFunSuite: + val projectRoot = node.process.cwd() + val compilePath = node.path.join(projectRoot, "hkmc2", "shared", "src", "test", "mlscript-compile") + val runtimePath = node.path.join(projectRoot, "hkmc2", "shared", "src", "test", "mlscript-compile", "RuntimeJS.mjs") + val preludePath = node.path.join(projectRoot, "hkmc2", "shared", "src", "test", "mlscript", "decls", "Prelude.mls") + private def loadStandardLibrary(): Map[String, String] = - val projectRoot = node.process.cwd() - val compilePath = node.path.join(projectRoot, "hkmc2", "shared", "src", "test", "mlscript-compile") - val preludePath = node.path.join(projectRoot, "hkmc2", "shared", "src", "test", "mlscript", "decls", "Prelude.mls") - node.fs.readdirSync(compilePath).filter(_.endsWith(".mls")).toSeq.flatMap: fileName => + // Actually, there's no need to load `.mjs` files. But we did it since we + // were importing them to reduce duplicated parsing and elaboration. The + // imports can be reverted back to `.mls` files, but we should check that + // doing so does not cause any significant slowdown first. + node.fs.readdirSync(compilePath).filter: + fileName => fileName.endsWith(".mls") || fileName.endsWith(".mjs") + .toSeq.flatMap: fileName => val filePath = node.path.join(compilePath, fileName) if node.fs.existsSync(filePath) then Some(s"/std/$fileName" -> node.fs.readFileSync(filePath, "utf-8")) else None - .toMap + ("/std/Prelude.mls" -> node.fs.readFileSync(preludePath, "utf-8")) + .toMap + + ("/std/RuntimeJS.mjs" -> node.fs.readFileSync(runtimePath, "utf-8")) + + ("/std/Prelude.mls" -> node.fs.readFileSync(preludePath, "utf-8")) - private val paths = new Paths("/std/Prelude.mls", "/std/Runtime.mjs", "/std/Term.mjs") + private val paths = new Paths("/std/Prelude.mls", "/std/Runtime.mjs", "/std/Term.mjs", "/std") private def createCompiler(): (InMemoryFileSystem, Compiler) = val stdLib = loadStandardLibrary() val fs = new InMemoryFileSystem(stdLib) - given CompilerCtx = CompilerCtx.fresh(fs) + given CompilerCtx = CompilerCtx.fresh(fs, LocalTestModuleResolver(io.Path("/std"))) (fs, new Compiler(paths)) test("compiler can compile a simple program"): val (fs, compiler) = createCompiler() // Write test program to the file system - val code = """|import "./std/Option.mls" - |import "./std/Stack.mls" - |import "./std/Predef.mls" + val code = """|import "std/Option.mls" + |import "std/Stack.mls" + |import "std/Predef.mls" | |open Stack |open Option diff --git a/hkmc2/jvm/src/test/scala/hkmc2/AppTestRunner.scala b/hkmc2/jvm/src/test/scala/hkmc2/AppTestRunner.scala new file mode 100644 index 0000000000..db2acee449 --- /dev/null +++ b/hkmc2/jvm/src/test/scala/hkmc2/AppTestRunner.scala @@ -0,0 +1,73 @@ +package hkmc2 + +import org.scalatest.{funsuite, funspec, ParallelTestExecution} +import org.scalatest.time._ +import org.scalatest.concurrent.{TimeLimitedTests, Signaler} + +import mlscript.utils.*, shorthands.* +import io.PlatformPath.given + +import AppTestRunner.given +import hkmc2.codegen.Local + +/** + * A simple test runner that compiles apps written in MLscript. + */ +class AppTestRunner + extends funspec.AnyFunSpec + // with ParallelTestExecution // Can `MLsCompiler` handle parallel compilation? + // with TimeLimitedTests // TODO +: + import AppTestRunner.* + + private val inParallel = isInstanceOf[ParallelTestExecution] + + // val timeLimit = TimeLimit + + for app <- os.list(appsDir).filter(os.isDir) do + val allFiles = os.walk(app).filter(os.isFile).filter(_.ext == "mls").toSeq + val appName = app.baseName + + given Config = Config.default + val wrap: (=> Unit) => Unit = body => AppTestRunner.synchronized(body) + val report = ReportFormatter(System.out.println, colorize = true, wrap = Some(wrap)) + val compiler = MLsCompiler(paths, mkRaise = report.mkRaise) + + describe(s"$appName (${"file" countBy allFiles.size})"): + + allFiles.foreach: file => + val relativeName = file.relativeTo(app).toString() + + it(relativeName): + + AppTestRunner.synchronized: + println(s"Compiling: [${fansi.Bold.On(appName)}] ${fansi.Color.Green(relativeName)}") + + assert(true, s"Placeholder test for app: $relativeName") + + compiler.compileModule(file) + + if report.badLines.nonEmpty then + fail(s"Unexpected diagnostic at: " + + report.badLines.distinct.sorted + .map("\n\t"+relativeName+"."+file.ext+":"+_).mkString(", ")) +end AppTestRunner + +object AppTestRunner: + + val mainTestDir = os.pwd / "hkmc2" / "shared" / "src" / "test" + val appsDir = mainTestDir / "mlscript-apps" + val stdlibDir = mainTestDir / "mlscript-compile" + + val paths = new MLsCompiler.Paths: + val preludeFile = mainTestDir / "mlscript" / "decls" / "Prelude.mls" + val runtimeFile = stdlibDir / "Runtime.mjs" + val termFile = stdlibDir / "Term.mjs" + + val nodeModulesPath = os.pwd / "node_modules" + + // We may use a different module resolver for URL modules in browsers. For + // example, `import "https://esm.sh/nanoid"` should be accepted. + given cctx: CompilerCtx = CompilerCtx.fresh(io.FileSystem.default, LocalTestModuleResolver(stdlibDir, S(nodeModulesPath))) + +end AppTestRunner diff --git a/hkmc2/jvm/src/test/scala/hkmc2/CompileTestRunner.scala b/hkmc2/jvm/src/test/scala/hkmc2/CompileTestRunner.scala index ec1cae10fe..6d2b74fe41 100644 --- a/hkmc2/jvm/src/test/scala/hkmc2/CompileTestRunner.scala +++ b/hkmc2/jvm/src/test/scala/hkmc2/CompileTestRunner.scala @@ -8,7 +8,7 @@ import os.up import mlscript.utils._, shorthands._ import io.PlatformPath.given -import CompileTestRunner.given +import CompileTestRunner.{*, given} class CompileTestRunner @@ -21,11 +21,6 @@ class CompileTestRunner // val timeLimit = TimeLimit - val pwd = os.pwd - val workingDir = pwd - - val mainTestDir = workingDir/"hkmc2"/"shared"/"src"/"test" - // The compilation tests currently include compiling the benchmark instrumentation code. val dirs = mainTestDir :: workingDir/"hkmc2Benchmarks"/"src"/"test" :: Nil @@ -58,10 +53,7 @@ class CompileTestRunner val wrap: (=> Unit) => Unit = body => CompileTestRunner.synchronized(body) val report = ReportFormatter(System.out.println, colorize = true, wrap = Some(wrap)) val compiler = MLsCompiler( - paths = new MLsCompiler.Paths: - val preludeFile = mainTestDir / "mlscript" / "decls" / "Prelude.mls" - val runtimeFile = mainTestDir / "mlscript-compile" / "Runtime.mjs" - val termFile = mainTestDir / "mlscript-compile" / "Term.mjs", + paths = compilerPaths, mkRaise = report.mkRaise ) compiler.compileModule(file) @@ -77,7 +69,20 @@ end CompileTestRunner object CompileTestRunner: - given cctx: CompilerCtx = CompilerCtx.fresh(io.FileSystem.default) + val pwd = os.pwd + val workingDir = pwd + + val mainTestDir = workingDir/"hkmc2"/"shared"/"src"/"test" + val stdPath = mainTestDir / "mlscript-compile" + + val compilerPaths = new MLsCompiler.Paths: + val preludeFile = mainTestDir / "mlscript" / "decls" / "Prelude.mls" + val runtimeFile = mainTestDir / "mlscript-compile" / "Runtime.mjs" + val termFile = mainTestDir / "mlscript-compile" / "Term.mjs" + + val nodeModulesPath = workingDir / "node_modules" + + given cctx: CompilerCtx = CompilerCtx.fresh(io.FileSystem.default, LocalTestModuleResolver(stdPath, S(nodeModulesPath))) end CompileTestRunner diff --git a/hkmc2/shared/src/main/scala/hkmc2/CompilerCtx.scala b/hkmc2/shared/src/main/scala/hkmc2/CompilerCtx.scala index 94cbd5877f..ef14dee9bf 100644 --- a/hkmc2/shared/src/main/scala/hkmc2/CompilerCtx.scala +++ b/hkmc2/shared/src/main/scala/hkmc2/CompilerCtx.scala @@ -22,6 +22,7 @@ class CompilerCtx( val importing: Opt[(io.Path, CompilerCtx)], val beingCompiled: Set[io.Path], val fs: io.FileSystem, + val moduleResolver: ModuleResolver, cache: CompilerCache, ): @@ -31,7 +32,7 @@ class CompilerCtx( case N => Nil def derive(newFile: io.Path): CompilerCtx = - CompilerCtx(S(newFile, this), beingCompiled + newFile, fs, cache) + CompilerCtx(S(newFile, this), beingCompiled + newFile, fs, moduleResolver, cache) def getElaboratedBlock (file: io.Path, prelude: Ctx) @@ -65,7 +66,8 @@ object CompilerCtx: inline def get(using cctx: CompilerCtx) = cctx - def fresh(fs: io.FileSystem): CompilerCtx = CompilerCtx(N, Set.empty, fs, new PlatformCompilerCache) + def fresh(fs: io.FileSystem, moduleResolver: io.FileSystem ?=> ModuleResolver): CompilerCtx = + CompilerCtx(N, Set.empty, fs, moduleResolver(using fs), new PlatformCompilerCache) end CompilerCtx diff --git a/hkmc2/shared/src/main/scala/hkmc2/MLsCompiler.scala b/hkmc2/shared/src/main/scala/hkmc2/MLsCompiler.scala index 7e9d0913d7..9a32310413 100644 --- a/hkmc2/shared/src/main/scala/hkmc2/MLsCompiler.scala +++ b/hkmc2/shared/src/main/scala/hkmc2/MLsCompiler.scala @@ -9,6 +9,7 @@ import utils.* import hkmc2.semantics.* import hkmc2.syntax.Keyword.`override` import semantics.Elaborator.{Ctx, State} +import hkmc2.io.Path class ParserSetup(file: io.Path, dbgParsing: Bool)(using state: Elaborator.State, raise: Raise, cctx: CompilerCtx): diff --git a/hkmc2/shared/src/main/scala/hkmc2/ModuleResolver.scala b/hkmc2/shared/src/main/scala/hkmc2/ModuleResolver.scala new file mode 100644 index 0000000000..a207397bb5 --- /dev/null +++ b/hkmc2/shared/src/main/scala/hkmc2/ModuleResolver.scala @@ -0,0 +1,96 @@ +package hkmc2 + +import mlscript.utils.*, shorthands.* + +trait ModuleResolver: + /** + * Try to resolve path if it refers to a module or a source file in a module. + * + * @param path the import path + * @return the resolved path and module identifier, if any. + */ + def tryResolveModulePath(path: Str): Opt[(Str \/ io.Path, Opt[Str])] + +/** + * For local tests, including `CompileTestRunner`, `DiffTestRunner`, etc. + * + * @param stdPath the path to the standard library folder + * @param nodeModulesPath the optional path to the `node_modules` folder. + * If provided, we will do a simple check to see if + * the requested Node.js built-in module exists. + */ +class LocalTestModuleResolver(stdPath: io.Path, nodeModulesPath: Opt[io.Path])(using fs: io.FileSystem) extends ModuleResolver: + import LocalTestModuleResolver.* + + private def existsNodeModule(orgNameOpt: Opt[Str], moduleName: Str): Bool = + nodeModulesPath match + case S(basePath) => + val packagePath = orgNameOpt match + case S(orgName) => basePath / s"@$orgName" / moduleName + case N => basePath / moduleName + fs.exists(packagePath) + case N => false + + /** + * Resolve an imported module name to a directory (or file) path. This method + * should be overridden by subclasses to provide module resolution logic. + * + * @param orgName the optional organization name of the module + * @param moduleName the name of the module being imported + * @param noSubPath if the import does not have a sub-path (i.e., only the module name) + * @return the pure module name or the resolved path + */ + private def resolveModule(orgName: Opt[Str], moduleName: Str, noSubPath: Bool): Opt[(Str \/ io.Path, Opt[Str])] = + // Currently, there is only one std module, so the implementation here is + // hard-coded. If one day we implement the mechanism of a module manifest + // (for example, through `.mlson` file or `.witton` file), this part will + // need to be updated accordingly. + if orgName.isEmpty then + if noSubPath then + val realName = if moduleName.startsWith("node:") then moduleName.drop(5) else moduleName + if allowedNodeJsModules contains realName then S(L(moduleName) -> S(realName)) + else if existsNodeModule(orgName, moduleName) then S(L(moduleName) -> N) + else N + else if moduleName == "std" then S(R(stdPath) -> N) + else N + else N + + def tryResolveModulePath(path: Str): Opt[(Str \/ io.Path, Opt[Str])] = path match + case r(orgName, modName, subPath) => + val orgNameOpt = Opt(orgName) + if subPath is null then + resolveModule(orgNameOpt, modName, true) + else + val res = resolveModule(orgNameOpt, modName, false).map: + case (R(p), id) => (R(p / io.RelPath(subPath)), id) + case other => other + res + case _ => N + +object LocalTestModuleResolver: + def apply(stdPath: io.Path, nodeModulesPath: Opt[io.Path] = N)(using fs: io.FileSystem): LocalTestModuleResolver = + new LocalTestModuleResolver(stdPath, nodeModulesPath) + + /** + * The pattern of valid Node.js module specifiers. + * + * 1. **Group 1:** Scope (no `@`) + * + Example: `@my-org/foo/bar` → `"my-org"` + * 2. **Group 2:** Package name + * + Example: `@my-org/foo/bar` → `"foo"` + * + Example: `mypkg/test` → `"mypkg"` + * 3. **Group 3:** The remaining text after the first slash + * + Example: `@my-org/foo/bar/baz` → `"bar/baz"` + * + Example: `mypkg/sub/path` → `"sub/path"` + * + Example: `mypkg` → `null` + */ + private val r = """^(?:@([a-z0-9-~][a-z0-9-._~]*)\/)?((?:node:)?[a-z0-9-~][a-z0-9-._~]*)(?:\/(.*))?$""".r + + /** + * An incomplete list of allowed Node.js built-in modules. Add more as needed. + */ + private val allowedNodeJsModules = Set( + "fs", "path", "http", "https", "url", "util", "events", "stream", "buffer", + "os", "child_process", "vm", "assert", "tty", "process") + +// TODO: WebModuleResolver that can resolve URL imports. diff --git a/hkmc2/shared/src/main/scala/hkmc2/semantics/Elaborator.scala b/hkmc2/shared/src/main/scala/hkmc2/semantics/Elaborator.scala index 6cd7793ae2..2170893a22 100644 --- a/hkmc2/shared/src/main/scala/hkmc2/semantics/Elaborator.scala +++ b/hkmc2/shared/src/main/scala/hkmc2/semantics/Elaborator.scala @@ -1000,7 +1000,7 @@ extends Importer with ucs.SplitElaborator: case (m @ PrefixApp(Keywrd(Keyword.`import`), arg)) :: sts => reportUnusedAnnotations val (newCtx, newAcc) = arg match - case StrLit(path) => + case path: StrLit => val stmt = importPath(path).withLocOf(m) (ctx + (stmt.sym.nme -> stmt.sym), stmt :: acc) diff --git a/hkmc2/shared/src/main/scala/hkmc2/semantics/Importer.scala b/hkmc2/shared/src/main/scala/hkmc2/semantics/Importer.scala index 83915846a0..62bed4f716 100644 --- a/hkmc2/shared/src/main/scala/hkmc2/semantics/Importer.scala +++ b/hkmc2/shared/src/main/scala/hkmc2/semantics/Importer.scala @@ -11,63 +11,70 @@ import hkmc2.io import utils.TraceLogger import Elaborator.* -import hkmc2.syntax.LetBind +import hkmc2.syntax.{LetBind, Tree}, Tree.StrLit class Importer: self: Elaborator => import tl.* - def importPath(path: Str)(using cfg: Config): Import = - // log(s"pwd: ${os.pwd}") - // log(s"wd: ${wd}") - - val file = - if path.startsWith("/") - then io.Path(path) - else wd / io.RelPath(path) - - val nme = file.baseName + def importPath(rawPath: StrLit)(using cfg: Config): Import = + cctx.moduleResolver.tryResolveModulePath(rawPath.value) match + case S(L(specifier), moduleName) => + // The path resolves to a platform dependent specifier, which is NOT a + // path and should be used as-is, e.g., Node.js built-in modules. + val id = new syntax.Tree.Ident(moduleName.getOrElse(specifier)) // TODO loc + val sym = TermSymbol(LetBind, N, id) + Import(sym, specifier, wd / io.RelPath(rawPath.value)) // hmm, the third arg is dummy??? + case S(R(actualFile), moduleName) => + // The specifier is resolved to a file path. + importFile(rawPath, actualFile, moduleName.getOrElse(actualFile.baseName)) + case N => + // The specifier could not be resolved. We treat it as a file path. + val actualFile = + if rawPath.value.startsWith("/") then io.Path(rawPath.value) + else wd / io.RelPath(rawPath.value) + importFile(rawPath, actualFile, actualFile.baseName) + + private def importFile(rawPath: StrLit, actualFile: io.Path, nme: Str)(using cfg: Config): Import = val id = new syntax.Tree.Ident(nme) // TODO loc lazy val sym = TermSymbol(LetBind, N, id) - if path.startsWith(".") || path.startsWith("/") then // leave alone imports like "fs" - log(s"importing $file") - - val nme = file.baseName - val id = new syntax.Tree.Ident(nme) // TODO loc + log(s"importing $actualFile") + + if cctx.fs.exists(actualFile) then - file.ext match + actualFile.ext match case "mjs" | "js" => - Import(sym, file.toString, file) + Import(sym, actualFile.toString, actualFile) case "mls" if { - !cctx.beingCompiled.contains(file) `||`: + !cctx.beingCompiled.contains(actualFile) `||`: raise: ErrorReport: msg"Circular imports of `mls` files are not yet supported" -> N - :: (cctx.allFilesBeingImported :+ file).map(f => msg" importing ${f.toString}" -> N) + :: (cctx.allFilesBeingImported :+ actualFile).map(f => msg" importing ${f.toString}" -> N) false } => - val sym = tl.trace(s">>> Importing $file"): + val sym = tl.trace(s">>> Importing $actualFile"): given TL = tl - val artifact = cctx.getElaboratedBlock(file, prelude) + val artifact = cctx.getElaboratedBlock(actualFile, prelude) artifact.tree.definedSymbols.find(_._1 === nme) match case Some(nme -> imsym) => imsym - case None => lastWords(s"File $file does not define a symbol named $nme") + case None => lastWords(s"File $actualFile does not define a symbol named $nme") - val jsFile = file.up / io.RelPath(file.baseName + ".mjs") + val jsFile = actualFile.up / io.RelPath(actualFile.baseName + ".mjs") Import(sym, jsFile.toString, jsFile) case _ => - if file.ext =/= "mls" then raise: - ErrorReport(msg"Unsupported file extension: ${file.ext}" -> N :: Nil) - Import(sym, path, file) + if actualFile.ext =/= "mls" then raise: + ErrorReport(msg"Unsupported file type" -> rawPath.toLoc :: Nil) + Import(sym, rawPath.value, actualFile) else - Import(sym, path, file) - - + raise: + ErrorReport(msg"Cannot resolve the import path ${actualFile.toString}" -> rawPath.toLoc :: Nil) + Import(sym, rawPath.value, actualFile) \ No newline at end of file diff --git a/hkmc2/shared/src/test/mlscript-apps/.gitignore b/hkmc2/shared/src/test/mlscript-apps/.gitignore new file mode 100644 index 0000000000..9044a0c52e --- /dev/null +++ b/hkmc2/shared/src/test/mlscript-apps/.gitignore @@ -0,0 +1 @@ +*.mjs \ No newline at end of file diff --git a/hkmc2/shared/src/test/mlscript-apps/web-ide/.gitignore b/hkmc2/shared/src/test/mlscript-apps/web-ide/.gitignore new file mode 100644 index 0000000000..c795b054e5 --- /dev/null +++ b/hkmc2/shared/src/test/mlscript-apps/web-ide/.gitignore @@ -0,0 +1 @@ +build \ No newline at end of file diff --git a/hkmc2/shared/src/test/mlscript-apps/web-ide/README.md b/hkmc2/shared/src/test/mlscript-apps/web-ide/README.md new file mode 100644 index 0000000000..674d8e298d --- /dev/null +++ b/hkmc2/shared/src/test/mlscript-apps/web-ide/README.md @@ -0,0 +1,15 @@ +# MLscript Web IDE + +This is an online integrated development environment +(hereinafter referred to as the Web IDE) +developed for MLscript. +It supports syntax highlighting, +multi-file compilation, +module JavaScript code generation, +and sandbox execution in local browser. + +## Getting Started + +- Run `sbt hkmc2JS / fastOptJS` before starting using the web demo. + This command compiles the MLscript compiler to JavaScript. + \ No newline at end of file diff --git a/hkmc2/shared/src/test/mlscript-apps/web-ide/compiler/index.js b/hkmc2/shared/src/test/mlscript-apps/web-ide/compiler/index.js new file mode 100644 index 0000000000..b68d2e55a3 --- /dev/null +++ b/hkmc2/shared/src/test/mlscript-apps/web-ide/compiler/index.js @@ -0,0 +1,114 @@ +import * as fs from "../filesystem/fs.js"; + +// We put the compiler on the worker as well because the MLscript compiler takes +// more time when handling multiple files. If it runs directly here, it would +// block the user interface from updating. +const compilerWorker = new Worker("/compiler/worker.js", { type: "module" }); + +/** @type {Map} */ +const callbackMap = new Map(); + +compilerWorker.addEventListener("message", function (e) { + console.log("[Compiler Worker] Message received:", e.data); + + if (e.data.type === "compile-success") { + const { result, changes } = e.data; + const diagnostics = result?.diagnostics ?? result; + const compiledFiles = result?.compiledFiles ?? []; + console.log("[Compiler] Result:", diagnostics); + + // Apply file changes to main file system + for (const [path, content] of Object.entries(changes)) { + fs.write(path, content); + } + // Mark compiled sources as up-to-date + compiledFiles + .filter((path) => typeof path === "string" && path.endsWith(".mls")) + .forEach((path) => fs.setAttr(path, "compiled", true)); + + // Dispatch compilation completed event + const doneEvent = new CustomEvent("compilation-status-change", { + detail: { status: "done" }, + }); + document.dispatchEvent(doneEvent); + + // Resolve the promise + const callbacks = callbackMap.get(e.data.id); + if (callbacks === undefined) { + console.error("[Compiler] No callback found for id:", e.data.id); + } else { + callbacks.resolve({ result: diagnostics, changes }); + } + } else if (e.data.type === "compile-error") { + const { name, message, stack } = e.data; + console.error(`[Compiler] ${name}: ${message}`); + console.error(stack); + + // Dispatch compilation error event + const errorEvent = new CustomEvent("compilation-status-change", { + detail: { status: "error" }, + }); + document.dispatchEvent(errorEvent); + + // Reject the promise + const callbacks = callbackMap.get(e.data.id); + if (callbacks === undefined) { + console.error("[Compiler] No callback found for id:", e.data.id); + } else { + callbacks.reject(Object.assign(new Error(message), { name, stack })); + } + } else { + console.warn("[Compiler Worker] Unknown message type:", e.data.type); + } +}); + +compilerWorker.addEventListener("error", function (error) { + console.error("[Compiler Worker] Worker error:", error); +}); + +/** + * Compile the given MLscript files using the compiler worker. + * + * Currently, we also need to pass all MLscript files in the file system to the + * worker because there is no simple way to share the file system between the + * main thread and the worker. The current approach works even when the number + * of files is not too large. + * + * Calling this function will also dispatch compilation status change events: + * - `"running"` when compilation starts, + * - `"done"` when compilation succeeds, and + * - `"error"` when compilation fails. + * Therefore, the caller does not need to dispatch these events manually. + * + * The updated files upon successful compilation will also be written back to + * the file system automatically. Therefore, the caller does not need to handle + * file updates manually. + * + * @param {string[]} filePaths the list of file paths to compile + * @param {Record} allFiles + * all MLscript source files in the file system + * @returns {Promise<{ result: string, changes: Record }>} + * compilation result and changed files + */ +export async function compile(filePaths, allFiles) { + return new Promise((resolve, reject) => { + // Dispatch compilation started event. + const startEvent = new CustomEvent("compilation-status-change", { + detail: { status: "running" }, + }); + document.dispatchEvent(startEvent); + const id = makeUniqueID(); + // Send compile request to the worker. + compilerWorker.postMessage({ + type: "compile", + payload: { id, filePaths, allFiles }, + }); + // Push the callbacks to the queue. + callbackMap.set(id, { resolve, reject }); + }); +} + +function makeUniqueID() { + const nonce = (~~(Math.random() * 0xFFFF)).toString(16).padStart(4, '0'); + return `${new Date().toISOString()}_${nonce}`; +} diff --git a/hkmc2/shared/src/test/mlscript-apps/web-ide/compiler/worker.js b/hkmc2/shared/src/test/mlscript-apps/web-ide/compiler/worker.js new file mode 100644 index 0000000000..5b5c1e26cc --- /dev/null +++ b/hkmc2/shared/src/test/mlscript-apps/web-ide/compiler/worker.js @@ -0,0 +1,92 @@ +// Compiler Web Worker +import * as MLscript from "../build/MLscript.mjs"; + +let compiler = null; +let filesStore = {}; + +/** Maintain a set of modified paths. @type {Set} */ +let modifiedFiles = new Set(); + +function createVirtualFileSystem() { + return { + read(path) { + if (path in filesStore) { + return filesStore[path]; + } + throw new Error(`File not found: ${path}`); + }, + + write(path, content) { + filesStore[path] = content; + modifiedFiles.add(path); + return true; + }, + + exists(path) { + return path in filesStore; + }, + }; +} + +function initializeCompiler() { + if (!compiler) { + const virtualFS = createVirtualFileSystem(); + const dummyFileSystem = new MLscript.DummyFileSystem(virtualFS); + + // We assume that three standard library files are always present. + const paths = new MLscript.Paths( + "/std/Prelude.mls", + "/std/Runtime.mjs", + "/std/Term.mjs" + ); + + compiler = new MLscript.Compiler(dummyFileSystem, paths); + } +} + +self.addEventListener("message", function (e) { + const { + type, + payload: { id, allFiles, filePaths }, + } = e.data; + + if (type === "compile") { + try { + initializeCompiler(); + + filesStore = allFiles; + + modifiedFiles.clear(); + + const diagnosticsPerFile = []; + for (const filePath of filePaths) { + diagnosticsPerFile.push(...compiler.compile(filePath)); + } + + const changes = {}; + for (const path of modifiedFiles) { + changes[path] = filesStore[path]; + } + + self.postMessage({ + type: "compile-success", + id, + result: { diagnostics: diagnosticsPerFile, compiledFiles: filePaths }, + changes, + }); + } catch (error) { + self.postMessage({ + type: "compile-error", + id, + name: error.name, + message: error.message, + stack: error.stack, + }); + } + } else { + self.postMessage({ + type: "error", + error: `Unknown message type: ${type}`, + }); + } +}); diff --git a/hkmc2/shared/src/test/mlscript-apps/web-ide/components/ConsolePanel.js b/hkmc2/shared/src/test/mlscript-apps/web-ide/components/ConsolePanel.js new file mode 100644 index 0000000000..d26cae82b6 --- /dev/null +++ b/hkmc2/shared/src/test/mlscript-apps/web-ide/components/ConsolePanel.js @@ -0,0 +1,183 @@ +import { restorePanelHeight, savePanelHeight } from './PanelPersistence.js'; +import './ResizeHandle.js'; + +// Console Panel Custom Element +class ConsolePanel extends HTMLElement { + constructor() { + super(); + this.messages = []; + this.isCollapsed = false; + this.preserveLogs = false; + } + + connectedCallback() { + this.render(); + this.attachEventListeners(); + this.restoreSizeFromStorage(); + } + + restoreSizeFromStorage() { + restorePanelHeight(this, 'console-panel-height', 100, 600); + } + + saveSizeToStorage(height) { + savePanelHeight('console-panel-height', height); + } + + render() { + this.innerHTML = ` + +
+
+

Console

+ + +
+ +
+
+ `; + } + + log(type, ...args) { + const message = { + type, + content: args, + timestamp: new Date() + }; + this.messages.push(message); + this.appendMessage(message); + } + + appendMessage(message) { + const consoleContent = this.querySelector('.console-content'); + if (!consoleContent) return; + + const messageEl = document.createElement('div'); + messageEl.className = `console-message console-${message.type}`; + + // Format the content + const formattedContent = message.content.map(arg => { + if (typeof arg === 'object') { + try { + return JSON.stringify(arg, null, 2); + } catch (e) { + return String(arg); + } + } + return String(arg); + }).join(' '); + + // Create icon based on type + let iconClassName = ''; + switch (message.type) { + case 'error': + iconClassName = 'icon-x'; + break; + case 'warn': + iconClassName = 'icon-triangle-alert'; + break; + case 'info': + iconClassName = 'icon-info'; + break; + default: + iconClassName = 'icon-chevron-right'; + } + + messageEl.innerHTML = ` + + ${this.escapeHtml(formattedContent)} + `; + + consoleContent.appendChild(messageEl); + + // Auto-scroll to bottom + consoleContent.scrollTop = consoleContent.scrollHeight; + } + + escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + clear() { + if (this.preserveLogs) { + return; + } + this.messages = []; + const consoleContent = this.querySelector('.console-content'); + if (consoleContent) { + consoleContent.innerHTML = ''; + } + } + + toggleCollapse() { + this.isCollapsed = !this.isCollapsed; + + if (this.isCollapsed) { + // Store current height before collapsing + this.dataset.lastHeight = this.style.height || ''; + this.dataset.lastMinHeight = this.style.minHeight || ''; + this.dataset.lastMaxHeight = this.style.maxHeight || ''; + this.style.height = '40px'; + this.style.minHeight = '40px'; + this.style.maxHeight = '40px'; + this.style.flexBasis = '40px'; + this.style.flexGrow = '0'; + this.style.flexShrink = '0'; + } else { + // Restore previous height or use default + const lastHeight = this.dataset.lastHeight; + const lastMinHeight = this.dataset.lastMinHeight; + const lastMaxHeight = this.dataset.lastMaxHeight; + + if (lastHeight && lastHeight !== '40px') { + this.style.height = lastHeight; + this.style.minHeight = lastMinHeight || ''; + this.style.maxHeight = lastMaxHeight || ''; + this.style.flexBasis = lastHeight; + } else { + this.style.height = ''; + this.style.minHeight = ''; + this.style.maxHeight = ''; + this.style.flexBasis = ''; + this.style.flexGrow = ''; + this.style.flexShrink = ''; + } + } + + this.classList.toggle('collapsed', this.isCollapsed); + } + + attachEventListeners() { + const clearBtn = this.querySelector('.clear-btn'); + if (clearBtn) { + clearBtn.addEventListener('click', () => this.clear()); + } + + const collapseBtn = this.querySelector('.collapse-btn'); + if (collapseBtn) { + collapseBtn.addEventListener('click', () => this.toggleCollapse()); + } + + const preserveLogsCheckbox = this.querySelector('.preserve-logs-checkbox'); + if (preserveLogsCheckbox) { + preserveLogsCheckbox.addEventListener('change', (e) => { + this.preserveLogs = e.target.checked; + }); + } + } +} + +customElements.define('console-panel', ConsolePanel); + +export { ConsolePanel }; diff --git a/hkmc2/shared/src/test/mlscript-apps/web-ide/components/EditorPanel.js b/hkmc2/shared/src/test/mlscript-apps/web-ide/components/EditorPanel.js new file mode 100644 index 0000000000..9ed0349537 --- /dev/null +++ b/hkmc2/shared/src/test/mlscript-apps/web-ide/components/EditorPanel.js @@ -0,0 +1,621 @@ +import { subscribe, read, stat } from "../filesystem/fs.js"; +import { createEditor } from "../editor/editor.js"; +import "./FileTooltip.js"; + +// Editor Panel Custom Element +class EditorPanel extends HTMLElement { + constructor() { + super(); + this.openTabs = new Map(); + this.activeTabId = null; + this.tabCounter = 0; + this.tabTooltipTimeout = null; + this.tabTooltipPendingTarget = null; + this.tabTooltipHoverTarget = null; + this.emitOpenFilesChange(); + } + + emitOpenFilesChange() { + const paths = Array.from(this.openTabs.values()).map((t) => t.path); + document.dispatchEvent( + new CustomEvent("open-files-changed", { + detail: { paths }, + }) + ); + } + + connectedCallback() { + this.render(); + this.setupEventListeners(); + this.setupTabBarScrolling(); + + // Subscribe to file system changes + this.unsubscribe = subscribe((event) => { + // Update content of open tabs if files are modified + if ( + event.type === "write" || + event.type === "delete" || + event.type === "rename" || + event.type === "readonly" || + event.type === "attr" + ) { + for (const [tabId, tab] of this.openTabs) { + if (tab.path === event.path) { + if (event.type === "delete") { + // Close tab if file is deleted + this.closeTab(tabId); + } else if (event.type === "rename") { + // Update tab name and path + tab.name = event.node.name; + tab.path = event.newPath; + this.updateDisplay(); + } else if (event.type === "readonly") { + tab.readonly = event.readonly; + this.updateDisplay(); + } else if (event.type === "attr") { + tab.attrs = event.node?.attrs || {}; + this.updateDisplay(); + } else if (event.type === "write") { + // Reload content if file was modified externally + const currentContent = tab.editorView.state.doc.toString(); + const fileContent = read(event.path); + + if (fileContent !== null && fileContent !== currentContent) { + // Update content while preserving cursor position + const transaction = tab.editorView.state.update({ + changes: { + from: 0, + to: tab.editorView.state.doc.length, + insert: fileContent, + }, + }); + tab.editorView.dispatch(transaction); + } + } + } + } + } + }); + } + + disconnectedCallback() { + if (this.unsubscribe) { + this.unsubscribe(); + } + if (this.resizeObserver) { + this.resizeObserver.disconnect(); + } + // Destroy all CodeMirror instances + for (const tab of this.openTabs.values()) { + if (tab.editorView) { + tab.editorView.destroy(); + } + } + } + + render() { + this.innerHTML = ` +
+ +
+ + +
+
+
Open a file to start editing
+
+ `; + } + + setupEventListeners() { + window.addEventListener("file-open", (e) => { + this.openFile(e.detail.path, e.detail.fileName); + }); + + // Keyboard shortcuts + document.addEventListener("keydown", (e) => { + // Check if Cmd (Mac) or Ctrl (Windows/Linux) is pressed + const isMac = window.navigator.platform.toUpperCase().indexOf("MAC") >= 0; + const isCmdOrCtrl = isMac ? e.metaKey : e.ctrlKey; + + if (isCmdOrCtrl) { + // Cmd/Ctrl+S - Trigger compile + if (e.key === "s" || e.key === "S") { + e.preventDefault(); + // Get the active tab's file path + const activeTab = this.activeTabId + ? this.openTabs.get(this.activeTabId) + : null; + // Dispatch compile event + const compileEvent = new CustomEvent("compile-requested", { + bubbles: true, + detail: { + filePath: activeTab ? activeTab.path : null, + }, + }); + document.dispatchEvent(compileEvent); + } + + // Cmd/Ctrl+E - Execute the current file + if (e.key === "e" || e.key === "E") { + e.preventDefault(); + const activeTab = this.activeTabId + ? this.openTabs.get(this.activeTabId) + : null; + if (activeTab) { + const executeEvent = new CustomEvent("execute-requested", { + bubbles: true, + detail: { + filePath: activeTab ? activeTab.path : null, + }, + }); + document.dispatchEvent(executeEvent); + } + } + + if (e.key === "b" || e.key === "B") { + e.preventDefault(); + const fileExplorer = document.querySelector("file-explorer"); + if (fileExplorer) { + fileExplorer.toggleCollapse(); + } + } + } + + if (e.ctrlKey) { + // Ctrl+W - Close active tab + if ((e.key === "w" || e.key === "W")) { + e.preventDefault(); + if (this.activeTabId) { + this.closeTab(this.activeTabId); + } + } + } + }); + } + + setupTabBarScrolling() { + const tabBar = this.querySelector(".tab-bar"); + const leftArrow = this.querySelector(".tab-scroll-left"); + const rightArrow = this.querySelector(".tab-scroll-right"); + + let scrollInterval = null; + const scrollSpeed = 3; + + // Mouse wheel scrolling (convert vertical scroll to horizontal) + tabBar.addEventListener("wheel", (e) => { + e.preventDefault(); + tabBar.scrollLeft += e.deltaY; + }); + + // Left arrow hover scrolling + const startScrollLeft = () => { + if (scrollInterval) return; + scrollInterval = setInterval(() => { + tabBar.scrollLeft -= scrollSpeed; + }, 10); + }; + + const startScrollRight = () => { + if (scrollInterval) return; + scrollInterval = setInterval(() => { + tabBar.scrollLeft += scrollSpeed; + }, 10); + }; + + const stopScroll = () => { + if (scrollInterval) { + clearInterval(scrollInterval); + scrollInterval = null; + } + }; + + leftArrow.addEventListener("mouseenter", startScrollLeft); + leftArrow.addEventListener("mouseleave", stopScroll); + rightArrow.addEventListener("mouseenter", startScrollRight); + rightArrow.addEventListener("mouseleave", stopScroll); + + // Update arrow visibility based on scroll position + this.updateArrowVisibility = () => { + const hasOverflow = tabBar.scrollWidth > tabBar.clientWidth; + const isAtStart = tabBar.scrollLeft === 0; + const isAtEnd = + tabBar.scrollLeft >= tabBar.scrollWidth - tabBar.clientWidth - 1; + + const hideArrow = (arrow) => { + if (arrow.classList.contains("visible")) { + arrow.classList.remove("visible"); + // Set display: none after fade-out transition + setTimeout(() => { + if (!arrow.classList.contains("visible")) { + arrow.style.display = "none"; + } + }, 200); // Match the CSS transition duration + } + }; + + const showArrow = (arrow) => { + if (!arrow.classList.contains("visible")) { + arrow.style.display = "flex"; + // Force reflow to ensure display change is applied before transition + arrow.offsetHeight; + arrow.classList.add("visible"); + } + }; + + if (hasOverflow) { + if (isAtStart) { + hideArrow(leftArrow); + } else { + showArrow(leftArrow); + } + if (isAtEnd) { + hideArrow(rightArrow); + } else { + showArrow(rightArrow); + } + } else { + hideArrow(leftArrow); + hideArrow(rightArrow); + } + }; + + tabBar.addEventListener("scroll", this.updateArrowVisibility); + + // Store observer for cleanup + this.resizeObserver = new ResizeObserver(this.updateArrowVisibility); + this.resizeObserver.observe(tabBar); + + // Initial check + setTimeout(this.updateArrowVisibility, 0); + } + + openFile(filePath, fileName) { + // Check if file is already open + let existingTabId = null; + for (const [tabId, tab] of this.openTabs) { + if (tab.path === filePath) { + existingTabId = tabId; + break; + } + } + + if (existingTabId) { + // File already open, just switch to it + this.switchTab(existingTabId); + return existingTabId; + } else { + // Create new tab + const tabId = `tab-${this.tabCounter++}`; + + // Create container for CodeMirror + const editorDiv = document.createElement("div"); + editorDiv.className = "editor-codemirror"; + + // Load file content from fs + const content = read(filePath); + const initialContent = content !== null ? content : ""; + const extension = filePath.match(/\.(\w+)$/)?.[1] ?? ""; + const nodeInfo = stat(filePath); + const isReadonly = !!nodeInfo?.readonly; + const attrs = nodeInfo?.attrs || {}; + const editorView = createEditor( + editorDiv, + initialContent, + filePath, + extension, + isReadonly + ); + this.openTabs.set(tabId, { + name: fileName, + path: filePath, + editorDiv, + editorView, + readonly: isReadonly, + attrs, + }); + const editorContainer = this.querySelector(".editor-container"); + editorContainer.appendChild(editorDiv); + this.switchTab(tabId); + this.updateDisplay(); + this.emitOpenFilesChange(); + return tabId; + } + } + + openFileAtLine(filePath, line) { + // Extract file name from path + const fileName = filePath.split('/').pop(); + + // Open the file (or switch to it if already open) + const tabId = this.openFile(filePath, fileName); + + // Get the editor view for this tab + const tab = this.openTabs.get(tabId); + if (tab && tab.editorView) { + // Navigate to the line + // CodeMirror lines are 1-indexed, so we use the line number as-is + const linePos = tab.editorView.state.doc.line(line); + + // Move cursor to the beginning of the line and scroll into view + tab.editorView.dispatch({ + selection: { anchor: linePos.from, head: linePos.from }, + scrollIntoView: true + }); + + // Focus the editor + tab.editorView.focus(); + } + } + + switchTab(tabId) { + // Hide all editors + for (const tab of this.openTabs.values()) { + tab.editorDiv.classList.remove("active"); + } + + // Show the selected editor + const selectedTab = this.openTabs.get(tabId); + if (selectedTab) { + selectedTab.editorDiv.classList.add("active"); + this.activeTabId = tabId; + selectedTab.editorView.focus(); + } + + this.updateDisplay(); + + // Scroll the active tab into view + this.scrollTabIntoView(tabId); + } + + closeTab(tabId) { + const tab = this.openTabs.get(tabId); + if (tab) { + // Destroy CodeMirror instance + if (tab.editorView) { + tab.editorView.destroy(); + } + + // Remove editor div from DOM + tab.editorDiv.remove(); + + // Remove from map + this.openTabs.delete(tabId); + + // If we closed the active tab, switch to another + if (this.activeTabId === tabId) { + const remainingTabs = Array.from(this.openTabs.keys()); + if (remainingTabs.length > 0) { + this.switchTab(remainingTabs[remainingTabs.length - 1]); + } else { + this.activeTabId = null; + this.notifyActiveTabChange(null); + } + } + } + + this.updateDisplay(); + this.emitOpenFilesChange(); + } + + clearTabTooltip(reason = "unknown") { + const tooltip = this.querySelector("file-tooltip.tab-tooltip"); + console.log("[tab-tooltip] hide", { + reason, + pendingTargetPath: this.tabTooltipPendingTarget?.dataset?.path || null, + hoverTargetPath: this.tabTooltipHoverTarget?.dataset?.path || null, + }); + if (this.tabTooltipTimeout !== null) { + clearTimeout(this.tabTooltipTimeout); + this.tabTooltipTimeout = null; + } + this.tabTooltipPendingTarget = null; + this.tabTooltipHoverTarget = null; + if (tooltip) { + tooltip.hide(); + } + } + + updateDisplay() { + const tabBar = this.querySelector(".tab-bar"); + const emptyState = this.querySelector(".empty-state"); + const tooltip = this.querySelector("file-tooltip.tab-tooltip"); + // Reset any visible tooltip and pending timers when rebuilding the tab bar + this.clearTabTooltip("rebuild-tab-bar"); + + // Update tab bar + const tabs = Array.from(this.openTabs.entries()) + .map(([tabId, tab]) => { + const isActive = tabId === this.activeTabId; + + // Split filename and extension + const lastDot = tab.name.lastIndexOf("."); + let nameHtml; + if (lastDot > 0) { + const baseName = tab.name.substring(0, lastDot); + const extension = tab.name.substring(lastDot); + nameHtml = `${baseName}${extension}`; + } else { + nameHtml = `${tab.name}`; + } + + return ` +
+ + ${tab.readonly ? '' : ""} + ${nameHtml} + + +
+ `; + }) + .join(""); + + tabBar.innerHTML = tabs; + + // Show/hide empty state + if (this.openTabs.size === 0) { + emptyState.classList.remove("hidden"); + } else { + emptyState.classList.add("hidden"); + } + this.notifyActiveTabChange( + this.activeTabId ? this.openTabs.get(this.activeTabId) : null + ); + this.emitOpenFilesChange(); + + // Set up tab tooltips for display metadata of files. + + const showTooltip = (tabEl, reason = "unknown") => { + if (!tooltip) return; + const tabId = tabEl.getAttribute("data-tab-id"); + const tab = this.openTabs.get(tabId); + if (!tab) return; + console.log("[tab-tooltip] show", { + reason, + tabId, + path: tab.path, + visible: tooltip.classList.contains("visible"), + pendingTargetPath: this.tabTooltipPendingTarget?.dataset?.path || null, + hoverTargetPath: this.tabTooltipHoverTarget?.dataset?.path || null, + }); + tooltip.show(tabEl, { + path: tab.path, + name: tab.name, + size: tab.editorView?.state.doc.length, + placement: "bottom", + }); + }; + + // Attach event listeners to tabs + const tabElements = tabBar.querySelectorAll(".tab"); + const setupNameScroll = (container) => { + if (!container || container.dataset.scrollInit) return; + const textEl = container.querySelector(".tab-name-text"); + if (!textEl) return; + container.dataset.scrollInit = "true"; + const start = () => { + const distance = + textEl.scrollWidth - container.clientWidth + 16; // extra padding to reveal end + if (distance <= 0) return; + const duration = Math.min(12, Math.max(4, distance / 40)); + textEl.style.setProperty("--scroll-distance", `${distance}px`); + textEl.style.setProperty("--scroll-duration", `${duration}s`); + textEl.classList.add("scrolling"); + }; + const stop = () => { + textEl.classList.remove("scrolling"); + textEl.style.removeProperty("--scroll-distance"); + textEl.style.removeProperty("--scroll-duration"); + }; + container.addEventListener("mouseenter", start); + container.addEventListener("mouseleave", stop); + container.addEventListener("focus", start); + container.addEventListener("blur", stop); + }; + + tabElements.forEach((tabEl) => { + setupNameScroll(tabEl.querySelector(".tab-name")); + tabEl.addEventListener("click", (e) => { + if (!e.target.closest(".tab-close")) { + const tabId = tabEl.getAttribute("data-tab-id"); + this.switchTab(tabId); + } + }); + + // Tooltip on tab hover with movement guard to avoid false triggers on rerenders + tabEl.addEventListener("mouseenter", () => { + this.tabTooltipHoverTarget = tabEl; + console.log("[tab-tooltip] mouseenter", { + tabId: tabEl.getAttribute("data-tab-id"), + path: tabEl.dataset.path, + }); + }); + tabEl.addEventListener("mousemove", () => { + if (this.tabTooltipHoverTarget !== tabEl) return; + if (tooltip && tooltip.classList.contains("visible")) { + showTooltip(tabEl, "already-visible"); + return; + } + if (this.tabTooltipPendingTarget === tabEl) return; + if (this.tabTooltipTimeout !== null) { + clearTimeout(this.tabTooltipTimeout); + } + this.tabTooltipPendingTarget = tabEl; + console.log("[tab-tooltip] schedule show", { + tabId: tabEl.getAttribute("data-tab-id"), + path: tabEl.dataset.path, + delayMs: 5000, + }); + this.tabTooltipTimeout = setTimeout(() => { + if (this.tabTooltipHoverTarget !== tabEl) return; + showTooltip(tabEl, "delay-elapsed"); + this.tabTooltipPendingTarget = null; + this.tabTooltipTimeout = null; + }, 5000); + }); + tabEl.addEventListener("mouseleave", () => + this.clearTabTooltip("mouseleave") + ); + }); + + const closeButtons = tabBar.querySelectorAll(".tab-close"); + closeButtons.forEach((btn) => { + btn.addEventListener("click", (e) => { + e.stopPropagation(); + const tabId = btn.getAttribute("data-tab-id"); + this.closeTab(tabId); + }); + }); + + // Hide tooltip when tab bar scrolls + tabBar.addEventListener("scroll", () => + this.clearTabTooltip("tab-bar-scroll") + ); + + // Update arrow visibility after tabs change + if (this.updateArrowVisibility) { + setTimeout(this.updateArrowVisibility, 0); + } + } + + scrollTabIntoView(tabId) { + setTimeout(() => { + const tabBar = this.querySelector(".tab-bar"); + const tabElement = this.querySelector(`.tab[data-tab-id="${tabId}"]`); + + if (tabBar && tabElement) { + const tabBarRect = tabBar.getBoundingClientRect(); + const tabRect = tabElement.getBoundingClientRect(); + + // Check if tab is fully visible + const isVisible = + tabRect.left >= tabBarRect.left && tabRect.right <= tabBarRect.right; + + if (!isVisible) { + // Scroll to make the tab visible with some padding + const scrollOffset = tabElement.offsetLeft - tabBar.offsetLeft - 20; + tabBar.scrollTo({ + left: scrollOffset, + behavior: "smooth", + }); + } + } + }, 0); + } + + notifyActiveTabChange(tab) { + const detail = tab + ? { path: tab.path, isStd: !!tab.attrs?.std } + : { path: null, isStd: false }; + document.dispatchEvent( + new CustomEvent("active-tab-changed", { + detail, + }) + ); + } +} + +customElements.define("editor-panel", EditorPanel); diff --git a/hkmc2/shared/src/test/mlscript-apps/web-ide/components/FileExplorer.js b/hkmc2/shared/src/test/mlscript-apps/web-ide/components/FileExplorer.js new file mode 100644 index 0000000000..42dc40ce4f --- /dev/null +++ b/hkmc2/shared/src/test/mlscript-apps/web-ide/components/FileExplorer.js @@ -0,0 +1,367 @@ +import { fileTree, subscribe, createFile } from '../filesystem/fs.js'; +import { restorePanelWidth, savePanelWidth } from './PanelPersistence.js'; +import './FileTooltip.js'; +import './ResizeHandle.js'; + +// File Explorer Custom Element +class FileExplorer extends HTMLElement { + constructor() { + super(); + this.isCollapsed = false; + this.rootNodes = new Map(); + this.isCreatingFile = false; + this.newFileInput = null; + this.tooltipTimeout = null; + this.activeTooltipTarget = null; + this.openFiles = new Set(); + } + + connectedCallback() { + this.render(); + this.attachEventListeners(); + this.attachTooltipHandlers(); + this.restoreSizeFromStorage(); + this.handleOpenFilesChanged = (e) => { + this.openFiles = new Set(e.detail?.paths || []); + this.updateOpenFlags(); + }; + document.addEventListener("open-files-changed", this.handleOpenFilesChanged); + + // Subscribe to file system changes + this.unsubscribe = subscribe((event) => { + // Only update tree on structural changes at root level + if (event.type === 'create' || event.type === 'delete' || event.type === 'rename') { + // Check if this is a root-level change + const pathParts = event.path.replace(/^\//, '').split('/'); + if (pathParts.length === 1) { + // Root level change - update tree + this.updateTree(); + } + } + }); + } + + restoreSizeFromStorage() { + restorePanelWidth(this, 'file-explorer-width', 150, 600); + } + + saveSizeToStorage(width) { + savePanelWidth('file-explorer-width', width); + } + + disconnectedCallback() { + if (this.unsubscribe) { + this.unsubscribe(); + } + if (this.tooltipTimeout !== null) { + clearTimeout(this.tooltipTimeout); + this.tooltipTimeout = null; + } + if (this.handleOpenFilesChanged) { + document.removeEventListener( + "open-files-changed", + this.handleOpenFilesChanged + ); + } + } + + render() { + this.innerHTML = ` +
+

Files

+ + +
+
+ + + `; + + this.updateTree(); + } + + /** + * Filter out .mjs files that have a corresponding .mls file + * @param {Array} children - Array of child nodes + * @returns {Array} Filtered array of children + */ + filterMjsFiles(children) { + // Create a set of .mls file basenames (without extension) + const mlsFiles = new Set(); + children.forEach(child => { + if (child.type === 'file' && child.name.endsWith('.mls')) { + const basename = child.name.slice(0, -4); // Remove .mls extension + mlsFiles.add(basename); + } + }); + + // Filter out .mjs files that have a corresponding .mls file + return children.filter(child => { + if (child.type === 'file' && child.name.endsWith('.mjs')) { + const basename = child.name.slice(0, -4); // Remove .mjs extension + return !mlsFiles.has(basename); + } + return true; // Keep all other files and folders + }); + } + + updateTree() { + const treeView = this.querySelector('.tree-view'); + if (!treeView) return; + + // Filter root-level files to hide .mjs files that have a corresponding .mls file + const filteredRootNodes = this.filterMjsFiles(fileTree); + const newRootPaths = new Set(); + + // Build set of expected root paths + filteredRootNodes.forEach(node => { + const path = `/${node.name}`; + newRootPaths.add(path); + }); + + // Remove root nodes that no longer exist + for (const [path, element] of this.rootNodes.entries()) { + if (!newRootPaths.has(path)) { + element.remove(); + this.rootNodes.delete(path); + } + } + + // Add or update root nodes + filteredRootNodes.forEach((node, index) => { + const path = `/${node.name}`; + let treeNode = this.rootNodes.get(path); + + if (!treeNode) { + // Create new root node, passing fileTree as the parent + treeNode = document.createElement('tree-node'); + treeNode.setData(node, path, fileTree); + this.rootNodes.set(path, treeNode); + + // Insert at correct position + const nextChild = treeView.children[index]; + if (nextChild) { + treeView.insertBefore(treeNode, nextChild); + } else { + treeView.appendChild(treeNode); + } + } else { + // Update existing node's data, passing fileTree as the parent + treeNode.setData(node, path, fileTree); + + // Ensure correct order + const currentPosition = Array.from(treeView.children).indexOf(treeNode); + if (currentPosition !== index) { + const nextChild = treeView.children[index]; + if (nextChild !== treeNode) { + treeView.insertBefore(treeNode, nextChild); + } + } + } + }); + + this.updateOpenFlags(); + } + + toggleCollapse() { + this.isCollapsed = !this.isCollapsed; + this.classList.toggle('collapsed', this.isCollapsed); + + const container = document.querySelector('.app-container'); + + if (!this.isCollapsed) { + // Restore previous width or use default + const width = this.getAttribute('width'); + if (width) { + container.style.setProperty('--file-explorer-width', `${width}px`); + } else { + container.style.removeProperty('--file-explorer-width'); + } + } + // Collapsed state (40px) is handled by CSS :has() selector + } + + startCreatingFile() { + if (this.isCreatingFile) return; + + this.isCreatingFile = true; + const treeView = this.querySelector('.tree-view'); + + // Create a temporary file entry with an input field + const fileEntry = document.createElement('div'); + fileEntry.className = 'file-item new-file-entry'; + + const fileIcon = document.createElement('i'); + fileIcon.className = 'file-icon icon-file-code'; + + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'new-file-input'; + input.placeholder = 'path/to/file.mls'; + + fileEntry.appendChild(fileIcon); + fileEntry.appendChild(input); + + // Insert at the top of the tree + treeView.insertBefore(fileEntry, treeView.firstChild); + this.newFileInput = input; + + // Focus the input + input.focus(); + + // Handle input submission + const submitFile = () => { + const fileName = input.value.trim(); + + if (fileName) { + // Normalize path to support nested folders (convert backslashes and trim extra slashes) + const normalizedInput = fileName.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+$/, ''); + const parts = normalizedInput.split('/').filter(Boolean); + + if (parts.length === 0) { + alert('Please enter a valid file name (e.g. path/to/file.mls)'); + input.focus(); + return; + } + + if (parts.some(part => part === '.' || part === '..')) { + alert('File name cannot contain "." or ".." path segments'); + input.focus(); + return; + } + + const path = `/${parts.join('/')}`; + const success = createFile(path, '', { force: true }); + + if (success) { + // File created successfully, clean up + this.cancelCreatingFile(); + + // Dispatch event to open the newly created file + const event = new CustomEvent('file-open', { + detail: { path, fileName }, + bubbles: true + }); + this.dispatchEvent(event); + } else { + alert('Failed to create file. File may already exist.'); + input.focus(); + } + } else { + // Empty name, cancel creation + this.cancelCreatingFile(); + } + }; + + const cancelFile = () => { + this.cancelCreatingFile(); + }; + + // Submit on Enter, cancel on Escape + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + submitFile(); + } else if (e.key === 'Escape') { + e.preventDefault(); + cancelFile(); + } + }); + + // Cancel on blur (click outside) + input.addEventListener('blur', () => { + // Use setTimeout to allow click events to process first + setTimeout(() => { + if (this.isCreatingFile) { + cancelFile(); + } + }, 200); + }); + } + + cancelCreatingFile() { + if (!this.isCreatingFile) return; + + this.isCreatingFile = false; + const entry = this.querySelector('.new-file-entry'); + if (entry) { + entry.remove(); + } + this.newFileInput = null; + } + + attachEventListeners() { + const collapseBtn = this.querySelector('.collapse-button'); + if (collapseBtn) { + collapseBtn.addEventListener('click', () => this.toggleCollapse()); + } + + const createFileBtn = this.querySelector('.create-file-button'); + if (createFileBtn) { + createFileBtn.addEventListener('click', () => this.startCreatingFile()); + } + } + + attachTooltipHandlers() { + const treeView = this.querySelector('.tree-view'); + const tooltip = this.querySelector('file-tooltip.tree-tooltip'); + if (!treeView || !tooltip) return; + + const hideTooltip = () => { + if (this.tooltipTimeout !== null) { + clearTimeout(this.tooltipTimeout); + this.tooltipTimeout = null; + } + tooltip.hide(); + this.activeTooltipTarget = null; + }; + + treeView.addEventListener('mouseover', (e) => { + const target = e.target.closest('.file-item, summary'); + if (!target || !treeView.contains(target)) return; + + if (this.activeTooltipTarget !== target) { + hideTooltip(); + this.activeTooltipTarget = target; + } + + const currentTarget = target; + this.tooltipTimeout = setTimeout(() => { + if (this.activeTooltipTarget !== currentTarget) return; + const path = currentTarget.dataset.path; + if (!path) return; + tooltip.show(currentTarget, { + path, + name: currentTarget.dataset.name || currentTarget.textContent.trim(), + }); + }, 5000); + }); + + treeView.addEventListener('mouseout', (e) => { + if (!this.activeTooltipTarget) return; + const related = e.relatedTarget; + if (related && this.activeTooltipTarget.contains(related)) return; + if (related && related.closest('.file-item, summary') === this.activeTooltipTarget) return; + hideTooltip(); + }); + + treeView.addEventListener('scroll', hideTooltip); + this.addEventListener('mouseleave', hideTooltip); + } + + updateOpenFlags() { + for (const treeNode of this.rootNodes.values()) { + if (treeNode.updateOpenState) { + treeNode.updateOpenState(this.openFiles); + } + } + } +} + +customElements.define('file-explorer', FileExplorer); + +export { FileExplorer }; diff --git a/hkmc2/shared/src/test/mlscript-apps/web-ide/components/FileTooltip.js b/hkmc2/shared/src/test/mlscript-apps/web-ide/components/FileTooltip.js new file mode 100644 index 0000000000..78666a6efa --- /dev/null +++ b/hkmc2/shared/src/test/mlscript-apps/web-ide/components/FileTooltip.js @@ -0,0 +1,187 @@ +import { stat } from "../filesystem/fs.js"; +import { + computePosition, + shift, + offset, +} from "https://esm.sh/@floating-ui/dom"; + +// Shared tooltip element for displaying file metadata in both the editor tabs +// and the file explorer. +class FileTooltip extends HTMLElement { + constructor() { + super(); + this.relativeTimeFormatter = + typeof Intl !== "undefined" && Intl.RelativeTimeFormat + ? new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }) + : null; + this.showRequestId = 0; + } + + connectedCallback() { + this.classList.add("file-tooltip"); + this.setAttribute("role", "tooltip"); + } + + escapeHtml(str) { + return String(str).replace(/[&<>"']/g, (ch) => { + switch (ch) { + case "&": + return "&"; + case "<": + return "<"; + case ">": + return ">"; + case '"': + return """; + case "'": + return "'"; + default: + return ch; + } + }); + } + + formatRelativeTime(timestamp) { + if (!this.relativeTimeFormatter || !Number.isFinite(timestamp)) return ""; + const divisions = [ + { amount: 60, unit: "seconds" }, + { amount: 60, unit: "minutes" }, + { amount: 24, unit: "hours" }, + { amount: 7, unit: "days" }, + { amount: 4.34524, unit: "weeks" }, + { amount: 12, unit: "months" }, + { amount: Number.POSITIVE_INFINITY, unit: "years" }, + ]; + + let duration = (timestamp - Date.now()) / 1000; + for (const division of divisions) { + if (Math.abs(duration) < division.amount) { + return this.relativeTimeFormatter.format( + Math.round(duration), + division.unit.slice(0, -1) + ); + } + duration /= division.amount; + } + return ""; + } + + formatTimestamp(value) { + if (!Number.isFinite(value)) return "—"; + const absolute = new Date(value).toLocaleString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + const relative = this.formatRelativeTime(value); + return relative ? `${absolute} · ${relative}` : absolute; + } + + formatAttrValue(value) { + if (value === undefined || value === null) return "—"; + if (typeof value === "object") return JSON.stringify(value); + if (value === true) return "true"; + if (value === false) return "false"; + return String(value); + } + + formatSize(size) { + if (!Number.isFinite(size) || size < 0) return "—"; + if (size === 1) return "1 byte"; + if (size < 1024) return `${size} bytes`; + const kb = size / 1024; + if (kb < 1024) return `${kb.toFixed(1)} KB`; + return `${(kb / 1024).toFixed(1)} MB`; + } + + buildContent({ path, name, sizeOverride }) { + if (!path) return null; + const node = stat(path); + if (!node) return null; + + const size = + sizeOverride ?? + (typeof node.content === "string" ? node.content.length : undefined); + const attrs = + node.attrs && Object.keys(node.attrs).length > 0 + ? Object.entries(node.attrs) + .map( + ([key, value]) => + `${key}: ${this.escapeHtml(this.formatAttrValue(value))}` + ) + .join(", ") + : "—"; + + const displayName = name || node.name || path.split("/").pop(); + + return ` +
+
Name
+
${this.escapeHtml(displayName)}
+
Path
+
${this.escapeHtml(path)}
+
Size
+
${this.formatSize(size)}
+
Modified
+
${this.formatTimestamp(node.mtime)}
+
Created
+
${this.formatTimestamp(node.birthtime)}
+
Readonly
+
${node.readonly ? "Yes" : "No"}
+
Attributes
+
${attrs}
+
+ `; + } + + show(targetEl, { path, name, size, placement = "right" } = {}) { + const content = this.buildContent({ + path, + name, + sizeOverride: size, + }); + if (!content || !targetEl) return; + + console.log("[file-tooltip] request show", { + path, + name, + placement, + targetPath: targetEl.dataset?.path, + targetName: targetEl.dataset?.name, + }); + + this.innerHTML = content; + this.dataset.placement = placement; + const requestId = ++this.showRequestId; + + computePosition(targetEl, this, { + placement, + strategy: "fixed", + middleware: [shift({ padding: 5 }), offset(8)], + }).then(({ x, y }) => { + if (requestId !== this.showRequestId) return; + this.style.top = `${y}px`; + this.style.left = `${x}px`; + this.classList.add("visible"); + console.log("[file-tooltip] shown", { + path, + placement, + x, + y, + }); + }); + } + + hide() { + this.showRequestId++; + this.classList.remove("visible"); + delete this.dataset.placement; + console.log("[file-tooltip] hide"); + } +} + +customElements.define("file-tooltip", FileTooltip); + +export { FileTooltip }; diff --git a/hkmc2/shared/src/test/mlscript-apps/web-ide/components/PanelPersistence.js b/hkmc2/shared/src/test/mlscript-apps/web-ide/components/PanelPersistence.js new file mode 100644 index 0000000000..9c29bedff5 --- /dev/null +++ b/hkmc2/shared/src/test/mlscript-apps/web-ide/components/PanelPersistence.js @@ -0,0 +1,62 @@ +// Panel Persistence Utilities +// Provides localStorage persistence for resizable panels + +/** + * Restore panel width from localStorage + * @param {HTMLElement} panel - The panel element + * @param {string} storageKey - localStorage key + * @param {number} minSize - Minimum valid width + * @param {number} maxSize - Maximum valid width + */ +export function restorePanelWidth(panel, storageKey, minSize = 150, maxSize = 600) { + const savedWidth = localStorage.getItem(storageKey); + if (savedWidth) { + const width = parseInt(savedWidth, 10); + // Validate: between minSize and maxSize + if (width >= minSize && width <= maxSize) { + const container = document.querySelector('.app-container'); + const cssVar = `--${panel.tagName.toLowerCase()}-width`; + container.style.setProperty(cssVar, `${width}px`); + panel.setAttribute('width', width); + } + } +} + +/** + * Save panel width to localStorage + * @param {string} storageKey - localStorage key + * @param {number} width - Width to save + */ +export function savePanelWidth(storageKey, width) { + localStorage.setItem(storageKey, width); +} + +/** + * Restore panel height from localStorage + * @param {HTMLElement} panel - The panel element + * @param {string} storageKey - localStorage key + * @param {number} minSize - Minimum valid height + * @param {number} maxSize - Maximum valid height + */ +export function restorePanelHeight(panel, storageKey, minSize = 100, maxSize = 600) { + const savedHeight = localStorage.getItem(storageKey); + if (savedHeight) { + const height = parseInt(savedHeight, 10); + // Validate: between minSize and maxSize + if (height >= minSize && height <= maxSize) { + panel.style.height = `${height}px`; + panel.style.minHeight = `${height}px`; + panel.style.maxHeight = `${height}px`; + panel.setAttribute('height', height); + } + } +} + +/** + * Save panel height to localStorage + * @param {string} storageKey - localStorage key + * @param {number} height - Height to save + */ +export function savePanelHeight(storageKey, height) { + localStorage.setItem(storageKey, height); +} diff --git a/hkmc2/shared/src/test/mlscript-apps/web-ide/components/ReservedPanel.js b/hkmc2/shared/src/test/mlscript-apps/web-ide/components/ReservedPanel.js new file mode 100644 index 0000000000..7875ce8875 --- /dev/null +++ b/hkmc2/shared/src/test/mlscript-apps/web-ide/components/ReservedPanel.js @@ -0,0 +1,428 @@ +import { read } from "../filesystem/fs.js"; +import { restorePanelWidth, savePanelWidth } from './PanelPersistence.js'; +import './ResizeHandle.js'; + +// Reserved Panel Custom Element +class ReservedPanel extends HTMLElement { + constructor() { + super(); + this.isCollapsed = false; + this.collapsedFiles = new Set(); + this.collapsedDiagnostics = new Set(); + } + + connectedCallback() { + this.render(); + this.attachEventListeners(); + this.restoreSizeFromStorage(); + } + + restoreSizeFromStorage() { + restorePanelWidth(this, 'reserved-panel-width', 150, 600); + } + + saveSizeToStorage(width) { + savePanelWidth('reserved-panel-width', width); + } + + render() { + this.innerHTML = ` + +
+

Diagnostics

+ +
+
+
+ + No diagnostics yet +
+
+ `; + } + + getKindIcon(kind) { + const icons = { + 'error': 'icon-circle-x', + 'warning': 'icon-triangle-alert', + 'internal': 'icon-bug' + }; + return icons[kind] || 'icon-circle-alert'; + } + + getSourceOrder(source) { + const order = { + 'lexing': 1, + 'parsing': 2, + 'typing': 3, + 'compilation': 4, + 'runtime': 5 + }; + return order[source] || 999; + } + + getLineAndColumn(text, offset) { + const lines = text.substring(0, offset).split('\n'); + const line = lines.length; + const column = lines[lines.length - 1].length + 1; + return { line, column }; + } + + extractCodeSnippet(text, start, end) { + const startPos = this.getLineAndColumn(text, start); + const endPos = this.getLineAndColumn(text, end); + + const lines = text.split('\n'); + const snippetLines = []; + + for (let i = startPos.line - 1; i < endPos.line; i++) { + if (i < lines.length) { + snippetLines.push({ + lineNumber: i + 1, + content: lines[i] + }); + } + } + + return { + startLine: startPos.line, + startColumn: startPos.column, + endLine: endPos.line, + endColumn: endPos.column, + lines: snippetLines + }; + } + + setDiagnostics(diagnosticsPerFile) { + const content = this.querySelector('.content'); + if (!content) return; + + // Check if there are any diagnostics + const hasErrors = diagnosticsPerFile && diagnosticsPerFile.some(file => + file.diagnostics && file.diagnostics.length > 0 + ); + + if (!hasErrors) { + content.innerHTML = ` +
+ + Everything works fine! +
+ `; + return; + } + + let html = '
'; + + diagnosticsPerFile.forEach((fileData, fileIndex) => { + const { path, diagnostics } = fileData; + + if (!diagnostics || diagnostics.length === 0) return; + + // Read the file content for extracting code snippets + const fileContent = read(path); + + const fileId = `file-${fileIndex}`; + const isFileCollapsed = this.collapsedFiles.has(fileId); + + // Sort diagnostics by source order + const sortedDiagnostics = [...diagnostics].sort((a, b) => + this.getSourceOrder(a.source) - this.getSourceOrder(b.source) + ); + + html += `
`; + html += `
`; + html += ``; + html += `${this.escapeHtml(path)}`; + html += ``; + html += `${diagnostics.length}`; + html += `
`; + + if (!isFileCollapsed) { + html += `
`; + + sortedDiagnostics.forEach((diagnostic, diagIndex) => { + const { kind, source, mainMessage, allMessages } = diagnostic; + const diagId = `${fileId}-diag-${diagIndex}`; + const isDiagCollapsed = this.collapsedDiagnostics.has(diagId); + + html += `
`; + html += `
`; + html += `
`; + html += ``; + html += ``; + html += `${this.escapeHtml(kind.charAt(0).toUpperCase() + kind.slice(1))}`; + html += `(${this.escapeHtml(source.charAt(0).toUpperCase() + source.slice(1))})`; + html += ``; + html += ``; + html += `
`; + + if (isDiagCollapsed) { + html += `
${this.escapeHtml(mainMessage)}
`; + } + + html += `
`; + + if (!isDiagCollapsed && allMessages && allMessages.length > 0) { + html += `
`; + allMessages.forEach(message => { + const { messageBits, location } = message; + html += `
`; + + if (messageBits && messageBits.length > 0) { + html += `
`; + messageBits.forEach(bit => { + if (bit.code) { + html += `${this.escapeHtml(bit.code)}`; + } else if (bit.text) { + html += `${this.escapeHtml(bit.text)}`; + } + }); + html += `
`; + } + + if (location && fileContent) { + const snippet = this.extractCodeSnippet(fileContent, location.start, location.end); + + html += `
`; + html += `
`; + html += `Line ${snippet.startLine}:${snippet.startColumn}`; + html += ``; + html += `
`; + snippet.lines.forEach(({ lineNumber, content }) => { + html += `
`; + html += `${lineNumber}`; + html += `
`;
+
+                  // Check if this line contains the highlight range
+                  if (lineNumber === snippet.startLine && lineNumber === snippet.endLine) {
+                    // Single line highlight
+                    const before = content.substring(0, snippet.startColumn - 1);
+                    const highlighted = content.substring(snippet.startColumn - 1, snippet.endColumn - 1);
+                    const after = content.substring(snippet.endColumn - 1);
+                    html += this.escapeHtml(before);
+                    html += `${this.escapeHtml(highlighted)}`;
+                    html += this.escapeHtml(after);
+                  } else if (lineNumber === snippet.startLine) {
+                    // Start of multi-line highlight
+                    const before = content.substring(0, snippet.startColumn - 1);
+                    const highlighted = content.substring(snippet.startColumn - 1);
+                    html += this.escapeHtml(before);
+                    html += `${this.escapeHtml(highlighted)}`;
+                  } else if (lineNumber === snippet.endLine) {
+                    // End of multi-line highlight
+                    const highlighted = content.substring(0, snippet.endColumn - 1);
+                    const after = content.substring(snippet.endColumn - 1);
+                    html += `${this.escapeHtml(highlighted)}`;
+                    html += this.escapeHtml(after);
+                  } else if (lineNumber > snippet.startLine && lineNumber < snippet.endLine) {
+                    // Middle of multi-line highlight
+                    html += `${this.escapeHtml(content)}`;
+                  } else {
+                    html += this.escapeHtml(content);
+                  }
+
+                  html += `
`; + html += `
`; + }); + html += `
`; + } + + html += `
`; + }); + html += `
`; + } + + html += `
`; + }); + + html += `
`; + } + + html += `
`; + }); + + html += '
'; + content.innerHTML = html; + this.attachDiagnosticListeners(); + } + + attachDiagnosticListeners() { + // File toggle listeners + this.querySelectorAll('.file-header').forEach(header => { + header.addEventListener('click', (e) => { + const fileId = e.currentTarget.dataset.fileId; + if (this.collapsedFiles.has(fileId)) { + this.collapsedFiles.delete(fileId); + } else { + this.collapsedFiles.add(fileId); + } + // Re-render to reflect the change + const content = this.querySelector('.content'); + const diagnosticsContainer = content.querySelector('.diagnostics-container'); + if (diagnosticsContainer) { + // Trigger a re-render by finding the parent caller + // For now, we'll just toggle classes directly + const fileBlock = this.querySelector(`.file-diagnostics[data-file-id="${fileId}"]`); + const list = fileBlock.querySelector('.file-diagnostic-list'); + const icon = header.querySelector('.file-toggle-icon'); + if (list) { + list.style.display = list.style.display === 'none' ? 'block' : 'none'; + } + if (icon) { + icon.className = icon.classList.contains('icon-chevron-right') + ? 'file-toggle-icon icon-chevron-down' + : 'file-toggle-icon icon-chevron-right'; + } + } + }); + }); + + // Diagnostic toggle listeners + this.querySelectorAll('.diagnostic-summary').forEach(summary => { + summary.addEventListener('click', (e) => { + const diagId = e.currentTarget.dataset.diagId; + const diagnostic = this.querySelector(`.diagnostic[data-diag-id="${diagId}"]`); + const details = diagnostic.querySelector('.diagnostic-details'); + let mainMessage = summary.querySelector('.diagnostic-main-message'); + const toggleIcon = summary.querySelector('.diagnostic-toggle-icon'); + + if (this.collapsedDiagnostics.has(diagId)) { + // Expand: show details, hide main message + this.collapsedDiagnostics.delete(diagId); + if (details) details.style.display = 'block'; + if (mainMessage) mainMessage.remove(); + if (toggleIcon) toggleIcon.className = 'diagnostic-toggle-icon icon-chevron-down'; + } else { + // Collapse: hide details, show main message + this.collapsedDiagnostics.add(diagId); + if (details) details.style.display = 'none'; + + // Create and insert main message if it doesn't exist + if (!mainMessage) { + const mainMessageText = diagnostic.dataset.mainMessage; + mainMessage = document.createElement('div'); + mainMessage.className = 'diagnostic-main-message'; + mainMessage.textContent = mainMessageText; + summary.appendChild(mainMessage); + } + + if (toggleIcon) toggleIcon.className = 'diagnostic-toggle-icon icon-chevron-right'; + } + }); + }); + + // Go to location button listeners + this.querySelectorAll('.goto-location-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const filePath = e.currentTarget.dataset.filePath; + const line = parseInt(e.currentTarget.dataset.line, 10); + + // Dispatch a custom event to open the file at the specific line + document.dispatchEvent(new CustomEvent('open-file-at-location', { + detail: { filePath, line } + })); + }); + }); + + // Collapse all diagnostics button listeners + this.querySelectorAll('.collapse-all-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const fileId = e.currentTarget.dataset.fileId; + const fileBlock = this.querySelector(`.file-diagnostics[data-file-id="${fileId}"]`); + const diagnostics = fileBlock.querySelectorAll('.diagnostic'); + const icon = btn.querySelector('i'); + + // Check if all are currently collapsed + let allCollapsed = true; + diagnostics.forEach(diagnostic => { + const diagId = diagnostic.dataset.diagId; + if (!this.collapsedDiagnostics.has(diagId)) { + allCollapsed = false; + } + }); + + if (allCollapsed) { + // Expand all + diagnostics.forEach(diagnostic => { + const diagId = diagnostic.dataset.diagId; + this.collapsedDiagnostics.delete(diagId); + const details = diagnostic.querySelector('.diagnostic-details'); + const mainMessage = diagnostic.querySelector('.diagnostic-main-message'); + const toggleIcon = diagnostic.querySelector('.diagnostic-toggle-icon'); + if (details) details.style.display = 'block'; + if (mainMessage) mainMessage.remove(); + if (toggleIcon) toggleIcon.className = 'diagnostic-toggle-icon icon-chevron-down'; + }); + icon.className = 'icon-list-chevrons-down-up'; + } else { + // Collapse all + diagnostics.forEach(diagnostic => { + const diagId = diagnostic.dataset.diagId; + this.collapsedDiagnostics.add(diagId); + const summary = diagnostic.querySelector('.diagnostic-summary'); + const details = diagnostic.querySelector('.diagnostic-details'); + let mainMessage = summary.querySelector('.diagnostic-main-message'); + const toggleIcon = diagnostic.querySelector('.diagnostic-toggle-icon'); + + if (details) details.style.display = 'none'; + + if (!mainMessage) { + const mainMessageText = diagnostic.dataset.mainMessage; + mainMessage = document.createElement('div'); + mainMessage.className = 'diagnostic-main-message'; + mainMessage.textContent = mainMessageText; + summary.appendChild(mainMessage); + } + + if (toggleIcon) toggleIcon.className = 'diagnostic-toggle-icon icon-chevron-right'; + }); + icon.className = 'icon-list-chevrons-up-down'; + } + }); + }); + } + + escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + toggleCollapse() { + this.isCollapsed = !this.isCollapsed; + this.classList.toggle('collapsed', this.isCollapsed); + + const container = document.querySelector('.app-container'); + + if (!this.isCollapsed) { + // Restore previous width or use default + const width = this.getAttribute('width'); + if (width) { + container.style.setProperty('--reserved-panel-width', `${width}px`); + } else { + container.style.removeProperty('--reserved-panel-width'); + } + } + // Collapsed state (40px) is handled by CSS :has() selector + } + + attachEventListeners() { + const collapseBtn = this.querySelector('.collapse-btn'); + if (collapseBtn) { + collapseBtn.addEventListener('click', () => this.toggleCollapse()); + } + } +} + +customElements.define('reserved-panel', ReservedPanel); + +export { ReservedPanel }; diff --git a/hkmc2/shared/src/test/mlscript-apps/web-ide/components/ResizeHandle.js b/hkmc2/shared/src/test/mlscript-apps/web-ide/components/ResizeHandle.js new file mode 100644 index 0000000000..9b06a55bfd --- /dev/null +++ b/hkmc2/shared/src/test/mlscript-apps/web-ide/components/ResizeHandle.js @@ -0,0 +1,132 @@ +// Resize Handle Custom Element +class ResizeHandle extends HTMLElement { + constructor() { + super(); + this.isResizing = false; + this.startX = 0; + this.startY = 0; + this.startSize = 0; + } + + connectedCallback() { + this.render(); + this.attachEventListeners(); + } + + render() { + const direction = this.getAttribute('direction') || 'horizontal'; + this.className = `resize-handle resize-handle-${direction}`; + this.innerHTML = `
`; + } + + attachEventListeners() { + this.addEventListener('mousedown', this.startResize.bind(this)); + } + + startResize(e) { + e.preventDefault(); + + const direction = this.getAttribute('direction') || 'horizontal'; + const target = this.getAttribute('target'); + // Target is the parent element (the panel itself) + const targetElement = target ? document.querySelector(target) : this.parentElement; + + if (!targetElement) return; + + this.isResizing = true; + this.startX = e.clientX; + this.startY = e.clientY; + + if (direction === 'horizontal') { + this.startSize = targetElement.offsetWidth; + } else { + this.startSize = targetElement.offsetHeight; + } + + document.body.style.cursor = direction === 'horizontal' ? 'ew-resize' : 'ns-resize'; + document.body.style.userSelect = 'none'; + this.classList.add('active'); + + const handleMouseMove = (e) => this.handleResize(e, targetElement, direction); + const handleMouseUp = () => this.stopResize(handleMouseMove, handleMouseUp, targetElement); + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + } + + handleResize(e, targetElement, direction) { + if (!this.isResizing) return; + + const minSize = parseInt(this.getAttribute('min-size')) || 150; + const maxSize = parseInt(this.getAttribute('max-size')) || 600; + + if (direction === 'horizontal') { + const deltaX = e.clientX - this.startX; + const side = this.getAttribute('side') || 'left'; + const newWidth = side === 'left' ? this.startSize + deltaX : this.startSize - deltaX; + + const clampedWidth = Math.max(minSize, Math.min(maxSize, newWidth)); + + // Use CSS custom properties for grid-based layouts + const container = document.querySelector('.app-container'); + if (targetElement.tagName.toLowerCase() === 'file-explorer') { + container.style.setProperty('--file-explorer-width', `${clampedWidth}px`); + targetElement.setAttribute('width', clampedWidth); + } else if (targetElement.tagName.toLowerCase() === 'reserved-panel') { + container.style.setProperty('--reserved-panel-width', `${clampedWidth}px`); + targetElement.setAttribute('width', clampedWidth); + } + } else { + const deltaY = e.clientY - this.startY; + const side = this.getAttribute('side') || 'top'; + // For console panel at bottom with handle at top: dragging up (negative deltaY) should increase height + const newHeight = side === 'top' ? this.startSize - deltaY : this.startSize + deltaY; + + const clampedHeight = Math.max(minSize, Math.min(maxSize, newHeight)); + + // For console panel, set height directly + targetElement.style.height = `${clampedHeight}px`; + targetElement.style.minHeight = `${clampedHeight}px`; + targetElement.style.maxHeight = `${clampedHeight}px`; + targetElement.setAttribute('height', clampedHeight); + } + + // Dispatch resize event for panels to update internal state + targetElement.dispatchEvent(new CustomEvent('panel-resize', { + detail: { + width: targetElement.offsetWidth, + height: targetElement.offsetHeight + } + })); + } + + stopResize(handleMouseMove, handleMouseUp, targetElement) { + this.isResizing = false; + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + this.classList.remove('active'); + + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + + // Save the size to localStorage + if (targetElement && targetElement.saveSizeToStorage) { + const direction = this.getAttribute('direction') || 'horizontal'; + if (direction === 'horizontal') { + const width = parseInt(targetElement.getAttribute('width')); + if (!isNaN(width)) { + targetElement.saveSizeToStorage(width); + } + } else { + const height = parseInt(targetElement.getAttribute('height')); + if (!isNaN(height)) { + targetElement.saveSizeToStorage(height); + } + } + } + } +} + +customElements.define('resize-handle', ResizeHandle); + +export { ResizeHandle }; \ No newline at end of file diff --git a/hkmc2/shared/src/test/mlscript-apps/web-ide/components/ToolbarPanel.js b/hkmc2/shared/src/test/mlscript-apps/web-ide/components/ToolbarPanel.js new file mode 100644 index 0000000000..04120950fd --- /dev/null +++ b/hkmc2/shared/src/test/mlscript-apps/web-ide/components/ToolbarPanel.js @@ -0,0 +1,236 @@ +import { + computePosition, + shift, + offset, +} from "https://esm.sh/@floating-ui/dom"; + +// Toolbar Panel Custom Element +class ToolbarPanel extends HTMLElement { + constructor() { + super(); + this.status = "idle"; // idle, running, done, error, aborted, fatal + this.runningTime = null; + this.startTime = null; + this.isCompiling = false; + this.isStdActive = false; + } + + connectedCallback() { + this.render(); + this.attachEventListeners(); + + // Listen for status change events + window.addEventListener("execution-status-change", (e) => { + this.setStatus(e.detail.status, e.detail.runningTime); + }); + + // Listen for compilation status change events + document.addEventListener("compilation-status-change", (e) => { + this.setCompilationStatus(e.detail.status); + }); + + document.addEventListener("active-tab-changed", (e) => { + this.setActiveFileMeta(e.detail); + }); + } + + setStatus(status, runningTime = null) { + this.status = status; + this.runningTime = runningTime; + this.updateStatusIndicator(); + } + + updateStatusIndicator() { + const statusLight = this.querySelector(".status-light"); + const statusText = this.querySelector(".status-text"); + const terminateBtn = this.querySelector("#terminate"); + const tooltip = this.querySelector(".status-tooltip"); + + if (!statusLight || !statusText) return; + + // Remove all status classes + statusLight.className = "status-light"; + + // Add appropriate class and update text + switch (this.status) { + case "idle": + statusLight.classList.add("status-idle"); + statusText.textContent = "Not running"; + if (terminateBtn) terminateBtn.style.display = "none"; + tooltip.textContent = `No execution is currently running.`; + break; + case "running": + statusLight.classList.add("status-running"); + statusText.textContent = "Running..."; + if (terminateBtn) terminateBtn.style.display = "inline-block"; + tooltip.textContent = `Execution started at ${new Date( + this.startTime + ).toLocaleTimeString()}.`; + break; + case "done": + statusLight.classList.add("status-done"); + statusText.textContent = this.runningTime + ? `Done (${this.runningTime}ms)` + : "Done"; + if (terminateBtn) terminateBtn.style.display = "none"; + tooltip.textContent = `Execution completed successfully in ${this.runningTime}ms.`; + break; + case "error": + statusLight.classList.add("status-error"); + statusText.textContent = "Error"; + if (terminateBtn) terminateBtn.style.display = "none"; + tooltip.textContent = `Execution encountered an error.`; + break; + case "aborted": + statusLight.classList.add("status-aborted"); + statusText.textContent = "Aborted"; + if (terminateBtn) terminateBtn.style.display = "none"; + tooltip.textContent = `Execution was aborted by the user.`; + break; + case "fatal": + statusLight.classList.add("status-fatal"); + statusText.textContent = "Fatal error"; + if (terminateBtn) terminateBtn.style.display = "none"; + tooltip.textContent = `A fatal error occurred during execution.`; + break; + } + } + + render() { + this.innerHTML = ` +
MLscript Web IDE
+
+
+ Not running +
+ +
+ + + +
+ `; + } + + #getActiveFilePath() { + const editorPanel = document.querySelector("editor-panel"); + const activeTab = editorPanel?.activeTabId + ? editorPanel.openTabs.get(editorPanel.activeTabId) + : null; + return activeTab ? activeTab.path : null; + } + + handleCompile() { + this.dispatchEvent( + new CustomEvent("compile-requested", { + bubbles: true, + detail: { filePath: this.#getActiveFilePath() }, + }) + ); + } + + handleExecute() { + this.dispatchEvent( + new CustomEvent("execute-requested", { + bubbles: true, + detail: { filePath: this.#getActiveFilePath() }, + }) + ); + } + + handleTerminate() { + const event = new CustomEvent("terminate-requested", { bubbles: true }); + this.dispatchEvent(event); + } + + setCompilationStatus(status) { + this.isCompiling = status === "running"; + this.updateCompileButton(); + this.updateExecuteButton(); + } + + setActiveFileMeta(meta) { + this.isStdActive = !!meta?.isStd; + this.updateCompileButton(); + this.updateExecuteButton(); + } + + updateCompileButton() { + const compileBtn = this.querySelector("#compile"); + if (!compileBtn) return; + + if (this.isCompiling) { + compileBtn.disabled = true; + compileBtn.classList.add("loading"); + compileBtn.innerHTML = ` + + Compiling... + `; + } else { + compileBtn.disabled = this.isStdActive; + compileBtn.classList.remove("loading"); + compileBtn.classList.toggle("disabled", this.isStdActive); + compileBtn.innerHTML = ` + + Compile + `; + } + } + + updateExecuteButton() { + const executeBtn = this.querySelector("#execute"); + if (!executeBtn) return; + executeBtn.disabled = this.isStdActive; + executeBtn.classList.toggle("disabled", this.isStdActive); + } + + attachEventListeners() { + const compileBtn = this.querySelector("#compile"); + if (compileBtn) { + compileBtn.addEventListener("click", () => this.handleCompile()); + } + const executeBtn = this.querySelector("#execute"); + if (executeBtn) { + executeBtn.addEventListener("click", () => this.handleExecute()); + } + const terminateBtn = this.querySelector("#terminate"); + if (terminateBtn) { + terminateBtn.addEventListener("click", () => this.handleTerminate()); + } + const statusContainer = this.querySelector(".status-container"); + const statusTooltip = this.querySelector(".status-tooltip"); + function showTooltip() { + statusTooltip.style.display = "block"; + computePosition(statusContainer, statusTooltip, { + placement: "bottom", + middleware: [shift({ padding: 5 }), offset(4)], + }).then(({ x, y }) => { + statusTooltip.style.top = `${y}px`; + statusTooltip.style.left = `${x}px`; + }); + } + function hideTooltip() { + statusTooltip.style.display = "none"; + } + [ + ["mouseenter", showTooltip], + ["mouseleave", hideTooltip], + ["focus", showTooltip], + ["blur", hideTooltip], + ].forEach(([event, listener]) => { + statusContainer.addEventListener(event, listener); + }); + } +} + +customElements.define("toolbar-panel", ToolbarPanel); + +export { ToolbarPanel }; diff --git a/hkmc2/shared/src/test/mlscript-apps/web-ide/components/TreeNode.js b/hkmc2/shared/src/test/mlscript-apps/web-ide/components/TreeNode.js new file mode 100644 index 0000000000..efdc31edba --- /dev/null +++ b/hkmc2/shared/src/test/mlscript-apps/web-ide/components/TreeNode.js @@ -0,0 +1,458 @@ +import { subscribe, stat } from '../filesystem/fs.js'; + +// Tree Node Custom Element (Reactive) +class TreeNode extends HTMLElement { + constructor() { + super(); + this.node = null; + this.path = ''; + // Keep references to DOM elements + this.elements = { + fileItem: null, + fileNameText: null, + compiledDot: null, + details: null, + summary: null, + childrenContainer: null + }; + // Map of child path to TreeNode element + this.childTreeNodes = new Map(); + } + + setupNameScroll(container, textEl) { + if (!container || !textEl || container.dataset.scrollInit) return; + container.dataset.scrollInit = "true"; + const start = () => { + const distance = textEl.scrollWidth - container.clientWidth + 16; + if (distance <= 0) return; + const duration = Math.min(12, Math.max(4, distance / 40)); + textEl.style.setProperty("--scroll-distance", `${distance}px`); + textEl.style.setProperty("--scroll-duration", `${duration}s`); + textEl.classList.add("scrolling"); + }; + const stop = () => { + textEl.classList.remove("scrolling"); + textEl.style.removeProperty("--scroll-distance"); + textEl.style.removeProperty("--scroll-duration"); + }; + container.addEventListener("mouseenter", start); + container.addEventListener("mouseleave", stop); + container.addEventListener("focus", start); + container.addEventListener("blur", stop); + } + + connectedCallback() { + // Subscribe to file system changes + this.unsubscribe = subscribe((event) => { + // Handle different event types + if (event.type === 'create' || event.type === 'delete') { + // Structural change - need to update children + if (this.node?.type === 'folder') { + // Check if the event is for a direct child of this folder + const eventPath = event.path; + const parentPath = eventPath.substring(0, eventPath.lastIndexOf('/')); + if (parentPath === this.path) { + this.updateChildren(); + } + } + // If this is a file node, check if we need to update the .mjs button + if (this.node?.type === 'file' && this.node.name.endsWith('.mls')) { + const eventPath = event.path; + const parentPath = eventPath.substring(0, eventPath.lastIndexOf('/')); + const myParentPath = this.path.substring(0, this.path.lastIndexOf('/')); + + // Check if a .mjs file was created/deleted in the same folder + if (parentPath === myParentPath && eventPath.endsWith('.mjs')) { + const eventFileName = eventPath.substring(eventPath.lastIndexOf('/') + 1); + const myBasename = this.node.name.slice(0, -4); + const eventBasename = eventFileName.slice(0, -4); + + // If the .mjs file matches our .mls file, update the button + if (myBasename === eventBasename) { + this.render(); + } + } + } + } else if (event.type === 'rename') { + // Update name if this is the renamed node + if (event.path === this.path) { + this.updateName(); + } + // Update children if a child was renamed + if (this.node?.type === 'folder') { + const eventPath = event.path; + const parentPath = eventPath.substring(0, eventPath.lastIndexOf('/')); + if (parentPath === this.path) { + this.updateChildren(); + } + } + } else if (event.type === 'readonly' || event.type === 'attr') { + // Update readonly icon if this node's readonly status changed + if (event.path === this.path) { + this.render(); + } + } + // No need to handle 'write' events - they don't affect the tree structure + }); + + this.render(); + } + + disconnectedCallback() { + if (this.unsubscribe) { + this.unsubscribe(); + } + // Clean up child nodes + this.childTreeNodes.clear(); + } + + setData(node, path, parentFolderNode = null) { + this.node = node; + this.path = path; + this.parentFolderNode = parentFolderNode; + if (this.isConnected) { + this.render(); + } + } + + updateName() { + if (!this.node) return; + + if (this.node.type === 'file' && this.elements.fileItem) { + this.elements.fileItem.textContent = this.node.name; + } else if (this.node.type === 'folder' && this.elements.summary) { + this.elements.summary.textContent = this.node.name + '/'; + } + } + + updateChildren() { + if (!this.node || this.node.type !== 'folder' || !this.elements.childrenContainer) { + return; + } + + const children = this.node.children || []; + + // Filter out .mjs files that have a corresponding .mls file + const filteredChildren = this.filterMjsFiles(children); + const newChildPaths = new Set(); + + // Build set of expected child paths + filteredChildren.forEach(child => { + const childPath = this.path ? `${this.path}/${child.name}` : child.name; + newChildPaths.add(childPath); + }); + + // Remove child nodes that no longer exist + for (const [childPath, childElement] of this.childTreeNodes.entries()) { + if (!newChildPaths.has(childPath)) { + childElement.remove(); + this.childTreeNodes.delete(childPath); + } + } + + // Add or update children in order + filteredChildren.forEach((child, index) => { + const childPath = this.path ? `${this.path}/${child.name}` : child.name; + + let childElement = this.childTreeNodes.get(childPath); + + if (!childElement) { + // Create new child node + childElement = document.createElement('tree-node'); + childElement.setData(child, childPath, this.node); + this.childTreeNodes.set(childPath, childElement); + + // Insert at correct position + const nextChild = this.elements.childrenContainer.children[index]; + if (nextChild) { + this.elements.childrenContainer.insertBefore(childElement, nextChild); + } else { + this.elements.childrenContainer.appendChild(childElement); + } + } else { + // Update existing child's data + childElement.setData(child, childPath, this.node); + + // Ensure correct order + const currentPosition = Array.from(this.elements.childrenContainer.children).indexOf(childElement); + if (currentPosition !== index) { + const nextChild = this.elements.childrenContainer.children[index]; + if (nextChild !== childElement) { + this.elements.childrenContainer.insertBefore(childElement, nextChild); + } + } + } + }); + } + + /** + * Filter out .mjs files that have a corresponding .mls file + * @param {Array} children - Array of child nodes + * @returns {Array} Filtered array of children + */ + filterMjsFiles(children) { + // Create a set of .mls file basenames (without extension) + const mlsFiles = new Set(); + children.forEach(child => { + if (child.type === 'file' && child.name.endsWith('.mls')) { + const basename = child.name.slice(0, -4); // Remove .mls extension + mlsFiles.add(basename); + } + }); + + // Filter out .mjs files that have a corresponding .mls file + return children.filter(child => { + if (child.type === 'file' && child.name.endsWith('.mjs')) { + const basename = child.name.slice(0, -4); // Remove .mjs extension + return !mlsFiles.has(basename); + } + return true; // Keep all other files and folders + }); + } + + /** + * Check if a .mjs file exists for this .mls file + * @returns {boolean} + */ + hasMjsFile() { + if (!this.node || this.node.type !== 'file' || !this.node.name.endsWith('.mls')) { + return false; + } + + const basename = this.node.name.slice(0, -4); // Remove .mls extension + const mjsFileName = basename + '.mjs'; + + // First try using the cached parent folder node + if (this.parentFolderNode) { + let children; + // Handle root level (which is an array) vs regular folders + if (Array.isArray(this.parentFolderNode)) { + children = this.parentFolderNode; + } else if (this.parentFolderNode.type === 'folder') { + children = this.parentFolderNode.children || []; + } else { + children = []; + } + + return children.some(child => child.type === 'file' && child.name === mjsFileName); + } + + // Fallback: Look up the parent folder dynamically from the file system + const parentPath = this.path.substring(0, this.path.lastIndexOf('/')); + const isRoot = parentPath === ''; + + let children; + if (isRoot) { + // Root level - stat('/') returns the fileTree array directly + const rootArray = stat('/'); + children = Array.isArray(rootArray) ? rootArray : []; + } else { + const parentNode = stat(parentPath); + if (!parentNode || parentNode.type !== 'folder') { + return false; + } + children = parentNode.children || []; + } + + return children.some(child => child.type === 'file' && child.name === mjsFileName); + } + + updateOpenState(openFiles) { + if (!openFiles) return; + if (this.node?.type === 'file' && this.elements.fileItem) { + this.elements.fileItem.classList.toggle('open-in-editor', openFiles.has(this.path)); + } + for (const child of this.childTreeNodes.values()) { + child.updateOpenState(openFiles); + } + } + + /** + * Get the path to the corresponding .mjs file + * @returns {string|null} + */ + getMjsPath() { + if (!this.hasMjsFile()) return null; + + const basename = this.node.name.slice(0, -4); // Remove .mls extension + const mjsFileName = basename + '.mjs'; + const pathParts = this.path.split('/'); + pathParts[pathParts.length - 1] = mjsFileName; + return pathParts.join('/'); + } + + /** + * Get the appropriate icon class for a file based on its extension + * @param {string} fileName - The file name + * @returns {string} Lucide icon name + */ + getFileIcon(fileName) { + const ext = fileName.substring(fileName.lastIndexOf('.')); + switch (ext) { + case '.mls': + case '.mjs': + case '.js': + return 'file-code'; + case '.json': + return 'braces'; + case '.md': + return 'file-text'; + default: + return 'file'; + } + } + + render() { + if (!this.node) return; + + // Only create elements if they don't exist yet + if (this.node.type === 'file') { + if (!this.elements.fileItem) { + this.elements.fileItem = document.createElement('div'); + this.elements.fileItem.className = 'file-item'; + this.elements.fileItem.dataset.path = this.path; + this.elements.fileItem.dataset.name = this.node.name; + + // Create icon element + this.elements.fileIcon = document.createElement('i'); + + // Create a container for the file name + this.elements.fileName = document.createElement('span'); + this.elements.fileName.className = 'file-name'; + this.elements.fileNameText = document.createElement('span'); + this.elements.fileNameText.className = 'file-name-text'; + this.elements.fileName.appendChild(this.elements.fileNameText); + this.setupNameScroll(this.elements.fileName, this.elements.fileNameText); + + this.elements.compiledDot = document.createElement('span'); + this.elements.compiledDot.className = 'compiled-dot'; + + // Attach click listener to the file name + this.elements.fileName.addEventListener('click', () => { + const event = new CustomEvent('file-open', { + detail: { path: this.path, fileName: this.node.name }, + bubbles: true + }); + this.dispatchEvent(event); + }); + + this.elements.fileItem.appendChild(this.elements.fileIcon); + this.elements.fileItem.appendChild(this.elements.fileName); + this.elements.fileItem.appendChild(this.elements.compiledDot); + this.appendChild(this.elements.fileItem); + } + + // Update icon based on readonly status and file type + if (this.node.readonly) { + this.elements.fileIcon.className = 'file-icon icon-file-lock'; + } else { + const icon = this.getFileIcon(this.node.name); + this.elements.fileIcon.className = `file-icon icon-${icon}`; + } + + this.elements.fileNameText.textContent = this.node.name; + this.elements.fileItem.dataset.path = this.path; + this.elements.fileItem.dataset.name = this.node.name; + const isStd = this.node.attrs?.std === true; + const compiledStatus = this.node.attrs?.compiled; + const isCompiled = compiledStatus === true; + this.elements.compiledDot.classList.toggle('hidden', isStd); + this.elements.compiledDot.classList.toggle('needs-compile', !isCompiled && !isStd); + this.elements.compiledDot.title = isStd + ? '' + : isCompiled + ? 'Compiled' + : 'Needs compile'; + + // Add or remove .mjs button based on whether a compiled file exists + if (this.node.name.endsWith('.mls')) { + const hasMjs = this.hasMjsFile(); + + if (hasMjs) { + if (!this.elements.mjsButton) { + this.elements.mjsButton = document.createElement('button'); + this.elements.mjsButton.className = 'mjs-button'; + this.elements.mjsButton.textContent = '.mjs'; + this.elements.mjsButton.title = 'Open compiled .mjs file'; + + // Attach click listener to open the .mjs file + this.elements.mjsButton.addEventListener('click', (e) => { + e.stopPropagation(); // Prevent triggering the file-item click + const mjsPath = this.getMjsPath(); + if (mjsPath) { + const basename = this.node.name.slice(0, -4); + const event = new CustomEvent('file-open', { + detail: { path: mjsPath, fileName: basename + '.mjs' }, + bubbles: true + }); + this.dispatchEvent(event); + } + }); + + this.elements.fileItem.appendChild(this.elements.mjsButton); + } + } else { + // Remove .mjs button if it exists but shouldn't + if (this.elements.mjsButton) { + this.elements.mjsButton.remove(); + this.elements.mjsButton = null; + } + } + } + + } else if (this.node.type === 'folder') { + if (!this.elements.details) { + // Create folder structure + this.elements.details = document.createElement('details'); + + console.log(`The attributes of ${this.path}:`, this.node.attrs); + + // Check collapsed attribute, default to open if not specified + const isCollapsed = this.node.attrs?.collapsed === true; + this.elements.details.open = !isCollapsed; + + this.elements.summary = document.createElement('summary'); + this.elements.summary.dataset.path = this.path; + this.elements.summary.dataset.name = this.node.name + '/'; + + // Create folder icon + this.elements.folderIcon = document.createElement('i'); + // Set initial icon based on collapsed state + this.elements.folderIcon.className = isCollapsed ? 'folder-icon icon-folder' : 'folder-icon icon-folder-open'; + + // Create folder name span + this.elements.folderName = document.createElement('span'); + this.elements.folderName.textContent = this.node.name; + + this.elements.summary.appendChild(this.elements.folderIcon); + this.elements.summary.appendChild(this.elements.folderName); + this.elements.details.appendChild(this.elements.summary); + + this.elements.childrenContainer = document.createElement('div'); + this.elements.childrenContainer.className = 'folder-children'; + this.elements.childrenContainer.style.paddingLeft = '12px'; + this.elements.details.appendChild(this.elements.childrenContainer); + + // Update folder icon on toggle + this.elements.details.addEventListener('toggle', () => { + if (this.elements.details.open) { + this.elements.folderIcon.className = 'folder-icon icon-folder-open'; + } else { + this.elements.folderIcon.className = 'folder-icon icon-folder'; + } + }); + + this.appendChild(this.elements.details); + } + + this.elements.folderName.textContent = this.node.name; + this.elements.summary.dataset.path = this.path; + this.elements.summary.dataset.name = this.node.name; + this.updateChildren(); + } + } +} + +customElements.define('tree-node', TreeNode); + +export { TreeNode }; diff --git a/hkmc2/shared/src/test/mlscript-apps/web-ide/editor/Highlight.mls b/hkmc2/shared/src/test/mlscript-apps/web-ide/editor/Highlight.mls new file mode 100644 index 0000000000..46bd20509c --- /dev/null +++ b/hkmc2/shared/src/test/mlscript-apps/web-ide/editor/Highlight.mls @@ -0,0 +1,127 @@ +module Highlight with... + +let makeWordRegex(...words) = new RegExp of "^(?:" + words.join("|") + ")$" + +let keywords = makeWordRegex of + "val", "class", "trait", "type", "pattern", "object", + "this", "super", "true", "false", "null", "undefined", + "forall", "declare", "where", "with", "fun", "let" + +let moduleKeywords = makeWordRegex of "module", "open", "import" + +let controlKeywords = makeWordRegex of + "if", "then", "else", "case", "while", "do", "throw", "return" + +let operatorKeywords = makeWordRegex of + "and", "or", "not", "set", "new", "new!", + "restricts", "extends", "in", "as", "is", "of" + +let modifiers = makeWordRegex of "mut", "abstract", "data" + +let types = makeWordRegex of + "Int", "Bool", "String", "Unit", "Num", "Nothing", "Anything", + "Object", "Array", "Function", "Error", "List", "Option", "Some", "None" + +let digit = new RegExp of "[\\d.]" + +let operator = new RegExp of "[+\\-*/%<>=!&|^~\\.]" + +let identiferStart = new RegExp of "[$\\w_]" + +let identiferPart = new RegExp of "[$\\w\\d_]" + +let capitalized = new RegExp of "^[A-Z]" + +fun normal(stream, state) = if + let ch = stream.next() + ch is "/" and + stream.eat("/") is Str then + stream.skipToEnd() + "comment" + stream.eat("*") is Str then + set state.parse = blockComment + state.parse(stream, state) + ch is "\"" then + set state.parse = stringContent + state.parse(stream, state) + digit.test(ch) then + stream.eatWhile(digit) + "number" + operator.test(ch) then + stream.eatWhile(operator) + "operator" + ch is "=" and stream.eat(">") is Str then "operator" + identiferStart.test(ch) and + do stream.eatWhile(identiferPart) + let word = stream.current() + keywords.test(word) then + set state.lastKeyword = word + "keyword" + moduleKeywords.test(word) then + set state.lastKeyword = null + "moduleKeyword" + controlKeywords.test(word) then + set state.lastKeyword = null + "controlKeyword" + operatorKeywords.test(word) then + set state.lastKeyword = null + "operatorKeyword" + modifiers.test(word) then + set state.lastKeyword = null + "modifier" + types.test(word) then + set state.lastKeyword = null + "typeName" + capitalized.test(word) then + set state.lastKeyword = null + "typeName" + state.lastKeyword is "fun" then + set state.lastKeyword = null + "propertyName" + else + set state.lastKeyword = null + "variableName" + else null + +fun blockComment(stream, state) = + let shouldStop = false + while shouldStop is false do + if stream.next() is ~null as ch and + ch is "*" and stream.eat("/") is Str then + set {state.parse = normal, shouldStop = true} + else + set shouldStop = false + else + set shouldStop = true + "comment" + +fun stringContent(stream, state) = + let shouldStop = false + let escaped = false + while shouldStop is false do + if stream.next() is ~null as ch and + ch is "\"" and escaped is false then + set {state.parse = normal, shouldStop = true} + else + set escaped = escaped is false and ch is "\\" + else set shouldStop = true + "string" + +fun startState() = mut { parse: normal, lastKeyword: null } + +fun token(stream, state) = + if stream.eatSpace() then + null + else + state.parse(stream, state) + +val mlscript = + name: "mlscript" + startState: startState + token: token + languageData: + commentTokens: + line: "//" + block: + "open": "/*" + close: "*/" diff --git a/hkmc2/shared/src/test/mlscript-apps/web-ide/editor/editor.js b/hkmc2/shared/src/test/mlscript-apps/web-ide/editor/editor.js new file mode 100644 index 0000000000..031c1108dd --- /dev/null +++ b/hkmc2/shared/src/test/mlscript-apps/web-ide/editor/editor.js @@ -0,0 +1,71 @@ +import { EditorView, basicSetup } from 'https://esm.sh/codemirror@6.0.1'; +import { javascript } from 'https://esm.sh/@codemirror/lang-javascript@6.2.4'; +import { StreamLanguage } from 'https://esm.sh/@codemirror/language@6.11.3'; +import { vscodeLight as theme } from "https://esm.sh/@uiw/codemirror-theme-vscode"; +import Highlight from "./Highlight.mjs"; +import { write } from "../filesystem/fs.js"; + +export function createEditor(container, initialContent, filePath, extension, readonly = false) { + // Determine language based on file extension + let languageExtension = [theme]; + if (extension === "mjs" || extension === "js") { + languageExtension.push(javascript()); + } else if (extension === "mls") { + languageExtension.push( + StreamLanguage.define({ + ...Highlight.mlscript, + token: (stream, state) => Highlight.mlscript.token(stream, state), + }) + ); + } + + // Create CodeMirror editor + const editorView = new EditorView({ + doc: initialContent, + extensions: [ + basicSetup, + ...languageExtension, + EditorView.updateListener.of((update) => { + if (readonly) return; + if (update.docChanged) { + // Auto-save on content change + const newContent = update.state.doc.toString(); + write(filePath, newContent); + } + }), + readonly ? EditorView.editable.of(false) : [], + EditorView.theme({ + "&": { + height: "100%", + fontSize: "14px", + }, + ".cm-scroller": { + overflow: "auto", + }, + ".cm-content": { + caretColor: "var(--sand-12)", + fontFamily: + "'Google Sans Code', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace", + }, + ".cm-lineNumbers": { + fontFamily: + "'Google Sans Code', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace", + }, + ".cm-cursor": { + borderLeftColor: "var(--sand-12)", + }, + ".cm-editor .cm-gutters": { + backgroundColor: "var(--sand-2)", + color: "var(--sand-11)", + borderRight: "1px solid var(--sand-6)", + }, + ".cm-activeLineGutter": { + backgroundColor: "var(--sand-2)", + }, + }), + ], + parent: container, + }); + + return editorView; +} diff --git a/hkmc2/shared/src/test/mlscript-apps/web-ide/execution/runner.js b/hkmc2/shared/src/test/mlscript-apps/web-ide/execution/runner.js new file mode 100644 index 0000000000..d3def481d1 --- /dev/null +++ b/hkmc2/shared/src/test/mlscript-apps/web-ide/execution/runner.js @@ -0,0 +1,176 @@ +import { getAllFiles } from "../filesystem/fs.js"; + +/** + * The latest execution instance. It is `null` if there is no execution. + * @type {{ id: string, worker: Worker, isRunning: boolean, startTime: number } | null} + */ +let latestExecution = null; + +function dispatchStatusChange(status, runningTime = null) { + const event = new CustomEvent('execution-status-change', { + detail: { status, runningTime }, + bubbles: true + }); + window.dispatchEvent(event); +} + +/** + * Execute the compiled JavaScript program in a Web Worker. If there is an + * existing execution running, it will not start a new one. + * + * @param {string} mainPath the path to the entry point JavaScript file + * @returns {void} + */ +export function execute(mainPath) { + if (latestExecution !== null) { + if (latestExecution.isRunning) { + // TODO: Show this error message using a toast notification. + console.log("The previous execution is still running. Stop it before starting a new one."); + return; + } else { + console.log("Clean up the previous execution."); + latestExecution.worker.terminate(); + latestExecution = null; + } + } + + // Clear console before each execution (unless preserve logs is enabled) + const consolePanel = document.querySelector('console-panel'); + if (consolePanel) { + consolePanel.clear(); + } + + console.log(`[VM] Starting new execution for ${mainPath}`); + + const id = Date.now().toString(); + + const execution = { + id, + worker: new Worker('execution/worker.js', { type: 'module' }), + isRunning: false, + startTime: null, + }; + + latestExecution = execution; + + function run() { + const files = getAllFiles(); + console.log("[VM] Files:", Object.keys(files)); + execution.worker.postMessage({ type: 'run', id, mainPath, files }); + } + + execution.worker.onmessage = (event) => { + if (latestExecution?.id !== id) { + console.error('Received message from outdated worker, terminating it.'); + latestExecution.worker.terminate(); + return; + } + const { type, payload } = event.data; + const consolePanel = document.querySelector('console-panel'); + + switch (type) { + case 'ready': + console.log('[VM]', 'Ready to run.'); + execution.isRunning = true; + execution.startTime = Date.now(); + dispatchStatusChange('running'); + run(); + break; + case 'log': + console.log('[VM]', payload); + break; + case 'error': + console.error('[VM]', payload); + execution.isRunning = false; + dispatchStatusChange('error'); + break; + case 'done': + console.log('[VM]', payload); + execution.isRunning = false; + const runningTime = execution.startTime ? Date.now() - execution.startTime : null; + dispatchStatusChange('done', runningTime); + break; + // Forward console messages from the VM. + case 'console.log': + console.log('[Execution]', ...payload); + if (consolePanel) consolePanel.log('log', ...payload); + break; + case 'console.error': + console.error('[Execution]', ...payload); + if (consolePanel) consolePanel.log('error', ...payload); + break; + case 'console.warn': + console.warn('[Execution]', ...payload); + if (consolePanel) consolePanel.log('warn', ...payload); + break; + } + }; + + execution.worker.onerror = (event) => { + console.error('[Worker error event]', { + message: event.message, + filename: event.filename, + lineno: event.lineno, + colno: event.colno, + error: event.error, + fullEvent: event + }); + execution.isRunning = false; + dispatchStatusChange('fatal'); + + // Display the error in the console panel + const consolePanel = document.querySelector('console-panel'); + if (consolePanel) { + consolePanel.log('error', `Worker Error: ${event.message || 'Unknown error'}`); + if (event.filename) { + consolePanel.log('error', ` at ${event.filename}:${event.lineno}:${event.colno}`); + } + if (event.error?.stack) { + consolePanel.log('error', event.error.stack); + } + } + + // Also display in the output panel + const reservedPanel = document.querySelector('reserved-panel'); + if (reservedPanel) { + let errorMsg = 'Worker Error:\n'; + if (event.message) errorMsg += `Message: ${event.message}\n`; + if (event.filename) errorMsg += `File: ${event.filename}\n`; + if (event.lineno) errorMsg += `Line: ${event.lineno}:${event.colno}\n`; + if (event.error) errorMsg += `\nStack:\n${event.error.stack || event.error}`; + reservedPanel.setOutput(errorMsg); + } + }; + + execution.worker.addEventListener("messageerror", function (event) { + console.error('[Worker message error]', event); + execution.isRunning = false; + dispatchStatusChange('fatal'); + + const consolePanel = document.querySelector('console-panel'); + if (consolePanel) { + consolePanel.log('error', 'Worker Message Error: Failed to deserialize message from worker'); + } + + const reservedPanel = document.querySelector('reserved-panel'); + if (reservedPanel) { + reservedPanel.setOutput('Worker Message Error: Failed to deserialize message from worker'); + } + }); +} + +export function terminate() { + if (latestExecution !== null && latestExecution.isRunning) { + console.log('[VM] Terminating worker...'); + latestExecution.worker.terminate(); + latestExecution.isRunning = false; + dispatchStatusChange('aborted'); + + const consolePanel = document.querySelector('console-panel'); + if (consolePanel) { + consolePanel.log('warn', 'Execution terminated by user'); + } + + latestExecution = null; + } +} \ No newline at end of file diff --git a/hkmc2/shared/src/test/mlscript-apps/web-ide/execution/worker.js b/hkmc2/shared/src/test/mlscript-apps/web-ide/execution/worker.js new file mode 100644 index 0000000000..a47712a94e --- /dev/null +++ b/hkmc2/shared/src/test/mlscript-apps/web-ide/execution/worker.js @@ -0,0 +1,138 @@ +let ModuleSource, Compartment; + +try { + const sesModule = await import('https://esm.sh/ses@1.14.0'); + + // Compartment is a global constructor added by SES after lockdown + Compartment = globalThis.Compartment || sesModule.Compartment; + + const endoModuleSource = await import('https://esm.sh/@endo/module-source@1.3.3'); + ModuleSource = endoModuleSource.ModuleSource; + + if (!Compartment) { + throw new Error('Compartment constructor not found after loading SES'); + } + if (!ModuleSource) { + throw new Error('ModuleSource not found in @endo/module-source'); + } + + // // lockdown is a global function added by SES + // if (typeof lockdown === 'function') { + // lockdown(); + // } else if (sesModule.lockdown) { + // sesModule.lockdown(); + // } +} catch (err) { + self.postMessage({ + type: 'error', + payload: `Failed to load worker dependencies: ${err.message}\n${err.stack}` + }); + throw err; +} finally { + self.postMessage({ type: "ready" }) +} + +function normalizePath(path) { + const parts = []; + for (const part of path.split('/')) { + if (!part || part === '.') continue; + if (part === '..') { + if (parts.length) parts.pop(); + } else { + parts.push(part); + } + } + return '/' + parts.join('/'); +} + +function resolveRelative(specifier, referrer) { + const idx = referrer.lastIndexOf('/'); + const dir = idx === -1 ? '/' : referrer.slice(0, idx + 1); + return normalizePath(dir + specifier); +} + +// Global error handler for the worker +self.onerror = (message, source, lineno, colno, error) => { + console.error('[Worker global error]', { message, source, lineno, colno, error }); + self.postMessage({ + type: 'error', + payload: `Uncaught error in worker: ${message}\n${error?.stack || error || ''}` + }); + return true; // Prevent default error handling +}; + +self.onunhandledrejection = (event) => { + console.error('[Worker unhandled rejection]', event.reason); + self.postMessage({ + type: 'error', + payload: `Unhandled promise rejection: ${event.reason?.stack || event.reason}` + }); + event.preventDefault(); +}; + +self.onmessage = async (event) => { + try { + const { type } = event.data; + if (type !== 'run') return; + + self.postMessage({ type: "log", payload: "Message received..." }); + + const { mainPath, files } = event.data; + const fileMap = new Map(Object.entries(files)); + + const vmConsole = { + log: (...args) => { + self.postMessage({ type: 'console.log', payload: args }); + }, + error: (...args) => { + self.postMessage({ type: 'console.error', payload: args }); + }, + warn: (...args) => { + self.postMessage({ type: 'console.warn', payload: args }); + }, + }; + + const endowments = { + console: vmConsole, + fetch, + structuredClone, + }; + + const compartment = new Compartment(endowments, {}, { + resolveHook(moduleSpecifier, moduleReferrer) { + self.postMessage({ type: "log", payload: `Resolving module: ${moduleSpecifier} from ${moduleReferrer}` }); + if (moduleSpecifier.startsWith('./') || moduleSpecifier.startsWith('../')) { + return resolveRelative(moduleSpecifier, moduleReferrer); + } + if (moduleSpecifier.startsWith('/')) { + return normalizePath(moduleSpecifier); + } + return moduleSpecifier; + }, + importHook(fullSpecifier) { + const path = normalizePath(fullSpecifier); + const src = fileMap.get(path); + if (src == null) { + throw new Error(`Module not found: ${path}`); + } + self.postMessage({ type: "log", payload: `Importing module: ${path}` }); + return new ModuleSource(src, path); + }, + }); + + try { + self.postMessage({ type: "log", payload: "Importing the main module..." }); + await compartment.import(normalizePath(mainPath)); + self.postMessage({ type: 'done', payload: null }); + } catch (err) { + const msg = err && err.stack ? String(err.stack) : String(err); + self.postMessage({ type: 'error', payload: Object.keys(err) }); + self.postMessage({ type: 'error', payload: msg }); + } + } catch (err) { + // Catch any errors in the message handler setup + const msg = `Error in worker message handler: ${err?.stack || err}`; + console.error(msg); + self.postMessage({ type: 'error', payload: msg }); + } +}; diff --git a/hkmc2/shared/src/test/mlscript-apps/web-ide/filesystem/fs.js b/hkmc2/shared/src/test/mlscript-apps/web-ide/filesystem/fs.js new file mode 100644 index 0000000000..7727125601 --- /dev/null +++ b/hkmc2/shared/src/test/mlscript-apps/web-ide/filesystem/fs.js @@ -0,0 +1,484 @@ +// Virtual File System for MLscript Web Demo + +// File tree structure - moved from main.js +// const now = Date.now(); + +export const fileTree = [ + // Example file data type: + // { + // name: "main.mls", + // type: "file", + // content: "", + // readonly: false, + // atime: now, + // mtime: now, + // ctime: now, + // birthtime: now, + // attrs: {}, + // }, +]; + +// Event listeners for file system changes +const listeners = new Set(); + +// Helper to create timestamp object +function createTimestamps() { + const now = Date.now(); + return { + atime: now, // access time + mtime: now, // modify time + ctime: now, // change time (metadata) + birthtime: now, // creation time + }; +} + +// Helper to update timestamps +function updateAccessTime(node) { + node.atime = Date.now(); +} + +function updateModifyTime(node) { + const now = Date.now(); + node.mtime = now; + node.ctime = now; // metadata changed too +} + +function updateChangeTime(node) { + node.ctime = Date.now(); +} + +// Notify all listeners of changes +function notifyChange(event) { + listeners.forEach((listener) => listener(event)); +} + +/** + * Subscribe to file system changes + * @param {string|Function} pathOrCallback - Path to watch or callback function + * @param {Function} [callback] - Called with event object {type, path, node} + * @returns {Function} Unsubscribe function + */ +export function subscribe(pathOrCallback, callback) { + // Overload 1: subscribe(callback) + if (typeof pathOrCallback === "function") { + const listener = pathOrCallback; + listeners.add(listener); + return () => listeners.delete(listener); + } + + // Overload 2: subscribe(path, callback) + if (typeof pathOrCallback === "string" && typeof callback === "function") { + const path = pathOrCallback; + const filteredListener = (event) => { + if (event.path === path || event.newPath === path) { + callback(event); + } + }; + listeners.add(filteredListener); + return () => listeners.delete(filteredListener); + } + + throw new Error( + "Invalid arguments: expected subscribe(callback) or subscribe(path, callback)" + ); +} + +/** + * Find a node by path + * @param {string} path - Path like '/std/Char.mls' or '/main.mls' + * @returns {Object|null} The node or null if not found + */ +export function findNode(path) { + // Remove leading slash and split + const parts = path + .replace(/^\//, "") + .split("/") + .filter((p) => p); + let current = fileTree; + + for (const part of parts) { + if (Array.isArray(current)) { + current = current.find((node) => node.name === part); + } else if (current?.type === "folder") { + current = current.children?.find((node) => node.name === part); + } else { + return null; + } + + if (!current) return null; + } + + return current; +} + +/** + * Find parent node and child index + * @param {string} path - Path to the node + * @returns {{parent: Object|Array, index: number}|null} + */ +function findParent(path) { + // Remove leading slash and split + const parts = path + .replace(/^\//, "") + .split("/") + .filter((p) => p); + if (parts.length === 0) return null; + + const parentPath = parts.slice(0, -1).join("/"); + const childName = parts[parts.length - 1]; + + let parent; + if (parentPath === "") { + parent = fileTree; + } else { + const parentNode = findNode("/" + parentPath); + if (!parentNode || parentNode.type !== "folder") return null; + parent = parentNode.children; + } + + const index = parent.findIndex((node) => node.name === childName); + return index >= 0 ? { parent, index } : null; +} + +/** + * Check if a path exists + * @param {string} path - Path to check + * @returns {boolean} + */ +export function exists(path) { + return findNode(path) !== null; +} + +/** + * Read file content + * @param {string} path - Path to the file + * @returns {string} File content or null if not found/not a file + */ +export function read(path) { + const node = findNode(path); + if (!node || node.type !== "file") throw new Error(`File not found: ${path}`); + updateAccessTime(node); + return node.content || ""; +} + +/** + * Write content to a file + * @param {string} path - Path to the file + * @param {string} content - Content to write + * @returns {boolean} Success status + */ +export function write(path, content) { + const node = findNode(path); + + // If file doesn't exist, create it with all missing parent directories + if (!node) { + return createFile(path, content, { force: true }); + } + + if (node.type !== "file") return false; + if (node.readonly) throw new Error(`File is readonly: ${path}`); + + node.content = content; + updateModifyTime(node); + + // Mark MLscript files as needing compilation (skip std files) + if (node.name.endsWith(".mls") && !node.attrs?.std) { + if (!node.attrs) node.attrs = {}; + if (node.attrs.compiled !== false) { + node.attrs.compiled = false; + notifyChange({ + type: "attr", + path, + node, + key: "compiled", + value: false, + }); + } + } + + notifyChange({ type: "write", path, node }); + return true; +} + +/** + * Create a new file + * @param {string} path - Path where to create the file (e.g., '/main.mls' or '/std/test.mls') + * @param {string} content - Initial content (default: empty) + * @param {Object} options - Creation options + * @param {boolean} options.force - If true, create missing parent directories + * @param {boolean} options.readonly - If true, file is readonly + * @param {Object} options.attrs - Custom attributes to attach to the file + * @returns {boolean} Success status + */ +export function createFile(path, content = "", options = {}) { + if (exists(path)) return false; + + // Remove leading slash and split + const parts = path + .replace(/^\//, "") + .split("/") + .filter((p) => p); + const fileName = parts[parts.length - 1]; + const parentPath = parts.slice(0, -1).join("/"); + + let parent; + if (parentPath === "") { + parent = fileTree; + } else { + let parentNode = findNode("/" + parentPath); + + // If parent doesn't exist and force is enabled, create all missing directories + if (!parentNode && options.force) { + const pathSegments = parentPath.split("/"); + let currentPath = ""; + + for (const segment of pathSegments) { + currentPath += "/" + segment; + if (!exists(currentPath)) { + createFolder(currentPath); + } + } + + parentNode = findNode("/" + parentPath); + } + + if (!parentNode || parentNode.type !== "folder") return false; + if (!parentNode.children) parentNode.children = []; + parent = parentNode.children; + } + + const timestamps = createTimestamps(); + const attrs = { ...(options.attrs || {}) }; + if (fileName.endsWith(".mls") && attrs.compiled === undefined) { + attrs.compiled = attrs.std ? true : false; + } + const newFile = { + name: fileName, + type: "file", + content: content, + readonly: options.readonly || false, + ...timestamps, + attrs, + }; + + parent.push(newFile); + parent.sort((a, b) => { + // Folders first, then files, alphabetically + if (a.type !== b.type) return a.type === "folder" ? -1 : 1; + return a.name.localeCompare(b.name); + }); + + notifyChange({ type: "create", path, node: newFile }); + return true; +} + +/** + * Create a new folder + * @param {string} path - Path where to create the folder (e.g., '/examples') + * @param {Object} options - Creation options + * @param {boolean} options.readonly - If true, folder is readonly + * @param {Object} options.attrs - Custom attributes to attach to the folder + * @returns {boolean} Success status + */ +export function createFolder(path, options = {}) { + if (exists(path)) return false; + + // Remove leading slash and split + const parts = path + .replace(/^\//, "") + .split("/") + .filter((p) => p); + const folderName = parts[parts.length - 1]; + const parentPath = parts.slice(0, -1).join("/"); + + let parent; + if (parentPath === "") { + parent = fileTree; + } else { + const parentNode = findNode("/" + parentPath); + if (!parentNode || parentNode.type !== "folder") return false; + if (!parentNode.children) parentNode.children = []; + parent = parentNode.children; + } + + const timestamps = createTimestamps(); + const newFolder = { + name: folderName, + type: "folder", + children: [], + readonly: options.readonly || false, + ...timestamps, + attrs: options.attrs || {}, + }; + + parent.push(newFolder); + parent.sort((a, b) => { + // Folders first, then files, alphabetically + if (a.type !== b.type) return a.type === "folder" ? -1 : 1; + return a.name.localeCompare(b.name); + }); + + notifyChange({ type: "create", path, node: newFolder }); + return true; +} + +/** + * Delete a file or folder + * @param {string} path - Path to delete + * @returns {boolean} Success status + */ +export function remove(path) { + const result = findParent(path); + if (!result) return false; + + const { parent, index } = result; + const node = parent[index]; + + parent.splice(index, 1); + + notifyChange({ type: "delete", path, node }); + return true; +} + +/** + * Rename a file or folder + * @param {string} path - Current path + * @param {string} newName - New name (not full path, just the name) + * @returns {boolean} Success status + */ +export function rename(path, newName) { + const node = findNode(path); + if (!node) return false; + + // Check if new name would create a duplicate + const parts = path.split("/").filter((p) => p); + parts[parts.length - 1] = newName; + const newPath = parts.join("/"); + + if (exists(newPath)) return false; + + const oldName = node.name; + node.name = newName; + updateChangeTime(node); + + notifyChange({ type: "rename", path, newPath, node, oldName }); + return true; +} + +/** + * List contents of a folder + * @param {string} path - Path to the folder + * @returns {Array|null} Array of child nodes or null if not found/not a folder + */ +export function list(path) { + const node = findNode(path); + if (!node || node.type !== "folder") return null; + return node.children || []; +} + +/** + * Get node info + * @param {string} path - Path to the node + * @returns {Object|null} Node object or null if not found + */ +export function stat(path) { + return findNode(path); +} + +/** + * Get all files as an object with normalized paths as keys and content as values + * @param {(path: string, node: unknown) => boolean} [predicate] - Optional filter function (path, node) => boolean + * @returns {Record} Object mapping normalized file paths to their content + */ +export function getAllFiles(predicate) { + const result = {}; + + function traverse(nodes, currentPath) { + for (const node of nodes) { + const nodePath = currentPath + "/" + node.name; + + if (node.type === "file") { + if ( + predicate === undefined || + (typeof predicate === "function" && predicate(nodePath, node)) + ) { + result[nodePath] = node.content || ""; + } + } else if (node.type === "folder" && node.children) { + traverse(node.children, nodePath); + } + } + } + + traverse(fileTree, ""); + return result; +} + +/** + * Set a custom attribute on a file or folder + * @param {string} path - Path to the node + * @param {string} key - Attribute key + * @param {any} value - Attribute value + * @returns {boolean} Success status + */ +export function setAttr(path, key, value) { + const node = findNode(path); + if (!node) return false; + + if (!node.attrs) node.attrs = {}; + node.attrs[key] = value; + updateChangeTime(node); + + notifyChange({ type: "attr", path, node, key, value }); + return true; +} + +/** + * Get a custom attribute from a file or folder + * @param {string} path - Path to the node + * @param {string} key - Attribute key + * @returns {any} Attribute value or undefined if not found + */ +export function getAttr(path, key) { + const node = findNode(path); + if (!node || !node.attrs) return undefined; + return node.attrs[key]; +} + +/** + * Remove a custom attribute from a file or folder + * @param {string} path - Path to the node + * @param {string} key - Attribute key + * @returns {boolean} Success status + */ +export function removeAttr(path, key) { + const node = findNode(path); + if (!node || !node.attrs) return false; + + const existed = key in node.attrs; + delete node.attrs[key]; + + if (existed) { + updateChangeTime(node); + notifyChange({ type: "attr", path, node, key, value: undefined }); + } + + return existed; +} + +/** + * Set readonly flag on a file or folder + * @param {string} path - Path to the node + * @param {boolean} readonly - Readonly status + * @returns {boolean} Success status + */ +export function setReadonly(path, readonly) { + const node = findNode(path); + if (!node) return false; + + node.readonly = readonly; + updateChangeTime(node); + + notifyChange({ type: "readonly", path, node, readonly }); + return true; +} diff --git a/hkmc2/shared/src/test/mlscript-apps/web-ide/filesystem/persistent.js b/hkmc2/shared/src/test/mlscript-apps/web-ide/filesystem/persistent.js new file mode 100644 index 0000000000..30d2828b65 --- /dev/null +++ b/hkmc2/shared/src/test/mlscript-apps/web-ide/filesystem/persistent.js @@ -0,0 +1,170 @@ +import { createFile, findNode, subscribe } from "./fs.js"; + +// LocalStorage key prefix for file system +export const FS_PREFIX = "mlscript-fs:"; +export const FS_PATHS_KEY = "mlscript-fs-paths"; + +/** + * Get the list of persisted file paths from localStorage + * @returns {Set} Set of file paths + */ +function getPersistedPaths() { + try { + const data = localStorage.getItem(FS_PATHS_KEY); + return data ? new Set(JSON.parse(data)) : new Set(); + } catch (e) { + console.error("Failed to load persisted paths from localStorage:", e); + return new Set(); + } +} +/** + * Save the list of persisted file paths to localStorage + * @param {Set} paths - Set of file paths + */ +function savePersistedPaths(paths) { + try { + localStorage.setItem(FS_PATHS_KEY, JSON.stringify([...paths])); + } catch (e) { + console.error("Failed to save persisted paths to localStorage:", e); + } +} +/** + * Load a persisted file from localStorage + * @param {string} path - File path + * @returns {Object|null} File data or null if not found + */ +function loadPersistedFile(path) { + try { + const key = FS_PREFIX + path; + const data = localStorage.getItem(key); + return data ? JSON.parse(data) : null; + } catch (e) { + console.error("Failed to load file from localStorage:", path, e); + return null; + } +} +/** + * Load all persisted files from localStorage and restore them to the file tree. + * @returns {number} The number of files restored. + */ +export function loadPersistedFiles() { + let counter = 0; + try { + const paths = getPersistedPaths(); + console.groupCollapsed("Loading persisted files from localStorage"); + for (const path of paths) { + const fileData = loadPersistedFile(path); + if (fileData) { + console.log(`Restoring file: "${path}"`); + createFile(path, fileData.content, { + force: true, + readonly: fileData.readonly, + attrs: fileData.attrs, + }); + + // Restore timestamps + const node = findNode(path); + if (node) { + node.atime = fileData.atime; + node.mtime = fileData.mtime; + node.ctime = fileData.ctime; + node.birthtime = fileData.birthtime; + } + + counter++; + } + } + } finally { + console.groupEnd(); + } + return counter; +} + +// Subscribe to file system changes to persist to localStorage +subscribe((event) => { + const { type, path, node, newPath } = event; + + // Skip standard library files + if (node?.attrs?.std === true) return; + + try { + const paths = getPersistedPaths(); + + switch (type) { + case "create": + case "write": + if (node?.type === "file") { + const key = FS_PREFIX + path; + localStorage.setItem( + key, + JSON.stringify({ + content: node.content, + readonly: node.readonly, + atime: node.atime, + mtime: node.mtime, + ctime: node.ctime, + birthtime: node.birthtime, + attrs: node.attrs, + }) + ); + paths.add(path); + savePersistedPaths(paths); + } + break; + + case "delete": + if (node?.type === "file") { + const key = FS_PREFIX + path; + localStorage.removeItem(key); + paths.delete(path); + savePersistedPaths(paths); + } + break; + + case "rename": + if (node?.type === "file") { + const oldKey = FS_PREFIX + path; + const newKey = FS_PREFIX + newPath; + localStorage.removeItem(oldKey); + localStorage.setItem( + newKey, + JSON.stringify({ + content: node.content, + readonly: node.readonly, + atime: node.atime, + mtime: node.mtime, + ctime: node.ctime, + birthtime: node.birthtime, + attrs: node.attrs, + }) + ); + paths.delete(path); + paths.add(newPath); + savePersistedPaths(paths); + } + break; + + case "attr": + case "readonly": + if (node?.type === "file") { + const key = FS_PREFIX + path; + localStorage.setItem( + key, + JSON.stringify({ + content: node.content, + readonly: node.readonly, + atime: node.atime, + mtime: node.mtime, + ctime: node.ctime, + birthtime: node.birthtime, + attrs: node.attrs, + }) + ); + // No need to update paths list for metadata changes + } + break; + } + } catch (e) { + console.error("Failed to persist file system change to localStorage:", e); + } +}); diff --git a/hkmc2/shared/src/test/mlscript-apps/web-ide/index.html b/hkmc2/shared/src/test/mlscript-apps/web-ide/index.html new file mode 100644 index 0000000000..e0c3472c5e --- /dev/null +++ b/hkmc2/shared/src/test/mlscript-apps/web-ide/index.html @@ -0,0 +1,91 @@ + + + + + + + MLscript Web IDE + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + +
+ +
+ + + + + + + diff --git a/hkmc2/shared/src/test/mlscript-apps/web-ide/main.js b/hkmc2/shared/src/test/mlscript-apps/web-ide/main.js new file mode 100644 index 0000000000..22e90f00ab --- /dev/null +++ b/hkmc2/shared/src/test/mlscript-apps/web-ide/main.js @@ -0,0 +1,130 @@ +import * as MLscript from "./build/MLscript.mjs"; +import * as fs from "./filesystem/fs.js"; +import { loadPersistedFiles } from "./filesystem/persistent.js"; +import { execute, terminate } from "./execution/runner.js"; +import { compile } from "./compiler/index.js"; + +try { + console.groupCollapsed(`Loading standard library files`); + // This `collapsed` attribute make sure that the standard library folder is + // initially collapsed in the file explorer. + fs.createFolder("/std", { force: true, attrs: { collapsed: true } }); + + // Mark standard library files as they will not be persisted. + fs.createFile("/std/Prelude.mls", MLscript.std.prelude, { + force: true, + readonly: true, + attrs: { std: true, compiled: true }, + }); + + MLscript.std.files.forEach(([filePath, content]) => { + console.log(`Loading file: "${filePath}"`); + fs.createFile(filePath, content, { + force: true, + readonly: true, + attrs: { std: true, compiled: true }, + }); + }); +} finally { + console.groupEnd(); +} + +// Load persisted user files from localStorage. +if (loadPersistedFiles() === 0) { + fs.createFile("/main.mls", `import "./std/Predef.mls" + +open Predef + +print of "Welcome to MLscript Web IDE!" +print of "============================" + +print of "Press Ctrl-S to compile." +print of "Press Ctrl-E to execute." +`, { force: true }) +} + +/** + * Collect all MLscript files for compilation and determine target files. + * @param {string} targetPath the file path that triggered the compile request + */ +function collectFilesForCompilation(targetPath) { + const files = fs.getAllFiles((path) => path.endsWith(".mls")); + const targetPaths = new Set( + Object.keys(files).filter((p) => { + const node = fs.stat(p); + if (node?.attrs?.std) return false; + return node?.attrs?.compiled !== true; + }) + ); + if (targetPath) targetPaths.add(targetPath); + return [files, Array.from(targetPaths)]; +} + +/** + * Mark specified files as compiled. + * @param {string[]} filePaths paths to the files + */ +function markAsCompiled(filePaths) { + filePaths.forEach((p) => { + const node = fs.stat(p); + if (!node?.attrs?.std) { + fs.setAttr(p, "compiled", true); + } + }) +} + +// Global compile event listener +document.addEventListener("compile-requested", (e) => { + const targetPath = e.detail.filePath; + const [allFiles, targetPaths] = collectFilesForCompilation(targetPath); + + compile(targetPaths, allFiles).then(({ result: diagnosticsPerFile }) => { + markAsCompiled(targetPaths); + const reservedPanel = document.querySelector('reserved-panel'); + if (reservedPanel) { + reservedPanel.setDiagnostics(diagnosticsPerFile); + } + }); +}); + +document.addEventListener("execute-requested", async function (event) { + let { filePath } = event.detail; + if (typeof filePath !== "string") { + // This should not happen, but just in case. + console.error("Invalid file path for execution:", filePath); + return; + } + const mjsFilePath = filePath.replace(/\.mls$/, ".mjs"); + if (fs.exists(mjsFilePath)) { + execute(mjsFilePath); + } else { + // If the compiled file does not exist, we first compile it. + // TODO: Show this message using a toast notification. + console.warn("Compiled file not found, compiling first:", mjsFilePath); + const [allFiles, targetPaths] = collectFilesForCompilation(filePath); + + compile(targetPaths, allFiles).then(() => { + markAsCompiled(targetPaths); + if (fs.exists(mjsFilePath)) { + execute(mjsFilePath); + } else { + // TODO: Show this error message using a toast notification. + console.error( + "Compiled file not found after compilation:", + mjsFilePath + ); + } + }); + } +}); + +document.addEventListener("terminate-requested", terminate); + +// Handle navigation to file location from diagnostics +document.addEventListener("open-file-at-location", (e) => { + const { filePath, line } = e.detail; + const editorPanel = document.querySelector('editor-panel'); + if (editorPanel) { + editorPanel.openFileAtLine(filePath, line); + } +}); diff --git a/hkmc2/shared/src/test/mlscript-apps/web-ide/style.css b/hkmc2/shared/src/test/mlscript-apps/web-ide/style.css new file mode 100644 index 0000000000..4eaecd968d --- /dev/null +++ b/hkmc2/shared/src/test/mlscript-apps/web-ide/style.css @@ -0,0 +1,1656 @@ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +:root { + --z-tooltip: 800; + --monospace: 'Google Sans Code', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + --sans-serif: 'Rubik', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + font-family: var(--sans-serif); +} + +button { + font-family: inherit; +} + +body { + background: var(--sand-1); + color: var(--sand-12); + height: 100vh; + overflow: hidden; + --panel-header-height: 2.5rem; +} + +.app-layout { + display: flex; + flex-direction: column; + height: 100vh; + overflow: hidden; +} + +.app-container { + display: grid; + grid-template-columns: var(--file-explorer-width, 250px) 1fr var(--reserved-panel-width, 300px); + flex: 1; + min-height: 0; + gap: 0; + background: var(--sand-1); +} + +.app-container:has(file-explorer.collapsed) { + --file-explorer-width: 40px; +} + +.app-container:has(reserved-panel.collapsed) { + --reserved-panel-width: 40px; +} + +/* File Explorer */ +file-explorer { + display: flex; + flex-direction: column; + background: var(--sand-1); + border-right: 1px solid var(--sand-6); + position: relative; +} + +file-explorer .header { + display: flex; + align-items: center; + gap: 0.125rem; + padding: 8px 12px; + background: var(--sand-2); + border-bottom: 1px solid var(--sand-6); + height: var(--panel-header-height); +} + +file-explorer .header h2 { + font-size: 14px; + font-weight: 500; +} + +file-explorer.collapsed .header h2 { + display: none; +} + +file-explorer .button:first-of-type { + margin-left: auto; +} + +file-explorer .button { + flex-shrink: 0; + background: none; + border: none; + color: var(--sand-11); + cursor: pointer; + font-size: 1rem; + padding: 0.25rem; + line-height: 1rem; +} + +file-explorer .button i { + display: block; + height: 1rem; + width: 1rem; +} + + +file-explorer .button:hover { + color: var(--sand-12); + background: var(--sand-4); + border-radius: 3px; +} + +file-explorer .tree-view { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding: 8px; +} + +file-explorer.collapsed .tree-view { + display: none; +} + +file-explorer.collapsed .header { + padding: 0; + justify-content: center; +} + +file-explorer.collapsed .header > *:not(.collapse-button) { + display: none; +} + +file-explorer details { + margin: 2px 0; +} + +file-explorer summary { + cursor: pointer; + padding: 4px 8px; + border-radius: 3px; + user-select: none; + list-style: none; + display: flex; + align-items: center; + gap: 6px; +} + +file-explorer summary::-webkit-details-marker { + display: none; +} + +file-explorer summary:hover { + background: var(--sand-3); +} + +file-explorer .folder-icon { + flex-shrink: 0; + font-size: 1rem; + color: var(--sand-11); +} + +file-explorer .file-icon { + flex-shrink: 0; + font-size: 1rem; + color: var(--sand-11); +} + +file-explorer .file-icon.icon-file-lock { + color: #a61e1e; +} + +file-explorer .file-item { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px 4px 8px; + border-radius: 3px; +} + +file-explorer .file-extname { + font-weight: 600; +} + +file-explorer .file-item:hover { + background: var(--sand-3); +} + +file-explorer .file-item.open-in-editor .file-name { + font-variation-settings: 'wght' 550; +} + +file-explorer .file-name { + flex: 1; + cursor: pointer; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: inline-block; + position: relative; +} + +file-explorer .compiled-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: transparent; + border: 1px solid var(--sand-7); + flex-shrink: 0; + margin-left: 4px; + opacity: 0.7; +} + +file-explorer .compiled-dot.needs-compile { + background: var(--amber-10, #f5a524); + border-color: var(--amber-11, #f59e0b); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--amber-10, #f5a524) 30%, transparent); + opacity: 1; +} + +file-explorer .compiled-dot.hidden { + display: none; +} + +file-explorer .file-name-text { + display: inline-block; + will-change: transform; +} + +file-explorer .file-name-text.scrolling { + animation: file-name-scroll var(--scroll-duration, 8s) ease-in-out infinite alternate; + text-overflow: clip; +} + +@keyframes file-name-scroll { + 0% { + transform: translateX(0); + } + 100% { + transform: translateX(calc(-1 * var(--scroll-distance, 0px))); + } +} + +file-explorer .mjs-button { + flex-shrink: 0; + padding: 1px 6px; + font-size: 0.75rem; + font-weight: 600; + border: 1px solid var(--sand-7); + background: var(--sand-3); + color: var(--sand-11); + border-radius: 3px; + cursor: pointer; + line-height: 1.2; + transition: all 0.15s ease; +} + +file-explorer .mjs-button:hover { + background: var(--sand-4); + border-color: var(--sand-8); + color: var(--sand-12); +} + +file-explorer .mjs-button:active { + background: var(--sand-5); + transform: translateY(1px); +} + +file-explorer .new-file-input { + flex: 1; + min-width: 0; + background: transparent; + border: none; + padding: 0; + font-size: inherit; + line-height: inherit; + color: var(--sand-12); + outline: none; + font-family: inherit; + caret-color: var(--sand-12); +} + +file-explorer .new-file-input:focus { + box-shadow: 0 1px 0 var(--sand-10); +} + +file-explorer .new-file-input::placeholder { + color: var(--sand-9); +} + +/* Editor Panel */ +editor-panel { + display: flex; + flex-direction: column; + background: var(--sand-1); + overflow: hidden; + border-left: none; + border-right: none; +} + +editor-panel .tab-bar-wrapper { + position: relative; + display: flex; + background: var(--sand-2); + border-bottom: 1px solid var(--sand-6); + height: var(--panel-header-height); +} + +editor-panel .tab-bar { + display: flex; + flex: 1; + height: 100%; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; + -ms-overflow-style: none; +} + +editor-panel .tab-bar::-webkit-scrollbar { + display: none; +} + +editor-panel .tab-scroll-arrow { + display: none; + position: absolute; + top: 0; + align-items: center; + justify-content: center; + width: 40px; + height: 100%; + background: linear-gradient(to right, var(--sand-2), transparent); + border: none; + color: var(--sand-11); + cursor: pointer; + font-size: 20px; + font-weight: bold; + padding: 0; + z-index: 1; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s ease; +} + +editor-panel .tab-scroll-arrow.visible { + opacity: 1; + pointer-events: auto; +} + +editor-panel .tab-scroll-arrow:hover { + color: var(--sand-12); +} + +editor-panel .tab-scroll-left { + left: 0; + background: linear-gradient(to right, var(--sand-2) 75%, transparent); +} + +editor-panel .tab-scroll-right { + right: 0; + background: linear-gradient(to left, var(--sand-2) 75%, transparent); +} + +editor-panel .tab { + display: flex; + align-items: center; + gap: 0.4rem; + padding: 0.5rem 0.5rem 0.5rem 0.75rem; + background: var(--sand-3); + border-right: 1px solid var(--sand-6); + cursor: pointer; + font-size: 0.875rem; + white-space: nowrap; + color: var(--sand-11); + user-select: none; + transition: color 0.15s ease, background 0.15s ease; + flex: 0 0 auto; + min-width: 120px; + max-width: 260px; +} + +editor-panel .tab.active { + background: var(--sand-1); + color: var(--sand-12); +} + +editor-panel .tab:not(.active):hover { + background: var(--sand-4); + color: var(--sand-12); +} + +editor-panel .tab-name { + display: flex; + align-items: center; + gap: 0; + min-width: 0; + overflow: hidden; + flex: 1 1 auto; +} + +editor-panel .tab-name > .tab-basename { + font-variation-settings: 'wght' 450; + transition: font-variation-settings 0.60s ease; +} + +editor-panel .tab-name-text { + display: inline-block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; + will-change: transform; + padding-right: 0.25rem; + padding-left: 0.1rem; +} + +editor-panel .tab-name-text.scrolling { + animation: file-name-scroll var(--scroll-duration, 8s) ease-in-out infinite alternate; + text-overflow: clip; +} + +editor-panel .tab-lock { + margin-right: 0.35rem; + color: inherit; + font-size: 0.9rem; +} + +editor-panel .tab.active .tab-name > .tab-basename { + font-variation-settings: 'wght' 650; +} + +editor-panel .tab-extension { + font-weight: 600; + color: var(--sand-11); +} + +editor-panel .tab-close { + background: none; + border: none; + color: var(--sand-11); + cursor: pointer; + padding: 0.25rem; + font-size: 1rem; + line-height: 1; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.2s ease; + flex-shrink: 0; +} + +editor-panel .tab-close:hover { + color: var(--sand-12); + background: var(--sand-5); +} + +file-tooltip { + font-size: 0.875rem; + color: var(--sand-11); + position: fixed; + width: max-content; + max-width: 400px; + top: 0; + left: 0; + background: var(--sand-2); + border: 1px solid var(--sand-6); + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); + z-index: 2000; + word-break: break-all; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s ease 0s; + --tooltip-bg: var(--sand-2); + --tooltip-border: var(--sand-6); + --arrow-size: 8px; + --arrow-border-size: 10px; +} + +file-tooltip.visible { + opacity: 0.9; + pointer-events: auto; +} + +file-tooltip::before, +file-tooltip::after { + content: ""; + position: absolute; + width: 0; + height: 0; +} + +file-tooltip[data-placement="right"]::before { + border-style: solid; + border-width: var(--arrow-border-size) var(--arrow-border-size) var(--arrow-border-size) 0; + border-color: transparent var(--tooltip-border) transparent transparent; + left: calc(-1 * var(--arrow-border-size)); + top: 50%; + transform: translateY(-50%); +} + +file-tooltip[data-placement="right"]::after { + border-style: solid; + border-width: var(--arrow-size) var(--arrow-size) var(--arrow-size) 0; + border-color: transparent var(--tooltip-bg) transparent transparent; + left: calc(-1 * var(--arrow-size)); + top: 50%; + transform: translateY(-50%); +} + +file-tooltip[data-placement="bottom"]::before { + border-style: solid; + border-width: 0 var(--arrow-border-size) var(--arrow-border-size) var(--arrow-border-size); + border-color: transparent transparent var(--tooltip-border) transparent; + top: calc(-1 * var(--arrow-border-size)); + left: 50%; + transform: translateX(-50%); +} + +file-tooltip[data-placement="bottom"]::after { + border-style: solid; + border-width: 0 var(--arrow-size) var(--arrow-size) var(--arrow-size); + border-color: transparent transparent var(--tooltip-bg) transparent; + top: calc(-1 * var(--arrow-size)); + left: 50%; + transform: translateX(-50%); +} + +file-tooltip .tooltip-grid { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.2rem 0.75rem; + align-items: start; +} + +file-tooltip .tooltip-label { + color: var(--sand-9); + font-weight: 600; + white-space: nowrap; +} + +file-tooltip .tooltip-value { + color: var(--sand-12); + word-break: break-word; + min-width: 0; +} + +editor-panel .editor-container { + flex: 1; + position: relative; + overflow: hidden; +} + +editor-panel .editor-codemirror { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: none; + overflow: hidden; +} + +editor-panel .editor-codemirror.active { + display: block; +} + +/* CodeMirror customization */ +editor-panel .cm-editor { + height: 100%; + background: var(--sand-1); + color: var(--sand-12); +} + +editor-panel .cm-gutters { + background: var(--sand-1); + color: var(--sand-11); + border-right: 1px solid var(--sand-6); +} + +editor-panel .cm-panels.cm-panels-bottom { + position: absolute; + left: 50%; + transform: translateX(-50%); + bottom: 12px; + width: min(900px, calc(100% - 24px)); + background: color-mix(in srgb, var(--sand-2) 92%, transparent); + border: 1px solid var(--sand-6); + border-radius: 10px; + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.12); + padding: 0.5rem 0.75rem; + z-index: 1100; +} + +editor-panel .cm-search.cm-panel { + position: relative; + display: grid; + grid-template-columns: minmax(280px, 1fr) repeat(3, auto) repeat(3, auto); + grid-template-rows: auto auto; + column-gap: 0.5rem; + row-gap: 0.4rem; + align-items: center; + font-size: 1rem; + color: var(--sand-12); +} + +editor-panel .cm-search .cm-textfield { + border: 1px solid var(--sand-6); + background: var(--sand-1); + color: var(--sand-12); + border-radius: 6px; + padding: 0.25rem 0.5rem; + min-width: 180px; + font-size: 1.02rem; + line-height: 1.25rem; + font-family: var(--monospace); + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +editor-panel .cm-search .cm-textfield:focus { + outline: none; + border-color: var(--sand-8); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--sand-8) 40%, transparent); +} + +editor-panel .cm-search label { + display: inline-flex; + align-items: center; + gap: 0.35rem; + color: var(--sand-11); + font-size: 0.9rem; + white-space: nowrap; +} + +editor-panel .cm-search label input[type="checkbox"] { + accent-color: var(--sand-11); +} + +editor-panel .cm-search .cm-button, +editor-panel .cm-search button[name="close"] { + background: linear-gradient(180deg, var(--sand-3), var(--sand-2)); + border: 1px solid var(--sand-6); + border-radius: 6px; + color: var(--sand-12); + padding: 0.25rem 0.55rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s ease, border-color 0.15s ease, transform 0.1s ease; +} + +editor-panel .cm-search .cm-button:hover, +editor-panel .cm-search button[name="close"]:hover { + background: linear-gradient(180deg, var(--sand-4), var(--sand-3)); + border-color: var(--sand-7); +} + +editor-panel .cm-search .cm-button:active, +editor-panel .cm-search button[name="close"]:active { + transform: translateY(1px); +} + +editor-panel .cm-search button[name="close"] { + position: absolute; + top: 6px; + right: 6px; + font-size: 1.05rem; + padding: 4px 8px; +} + +editor-panel .cm-search br { + display: none; +} + +/* Grid placement for search panel */ +editor-panel .cm-search .cm-textfield[name="search"] { + grid-row: 1; + grid-column: 1; +} + +editor-panel .cm-search label:nth-of-type(1) { + grid-row: 1; + grid-column: 2; +} + +editor-panel .cm-search label:nth-of-type(2) { + grid-row: 1; + grid-column: 3; +} + +editor-panel .cm-search label:nth-of-type(3) { + grid-row: 1; + grid-column: 4; +} + +editor-panel .cm-search .cm-button[name="next"] { + grid-row: 1; + grid-column: 5; +} + +editor-panel .cm-search .cm-button[name="prev"] { + grid-row: 1; + grid-column: 6; +} + +editor-panel .cm-search .cm-button[name="select"] { + grid-row: 1; + grid-column: 7; +} + +editor-panel .cm-search .cm-textfield[name="replace"] { + grid-row: 2; + grid-column: 1; +} + +editor-panel .cm-search .cm-button[name="replace"] { + grid-row: 2; + grid-column: 5; +} + +editor-panel .cm-search .cm-button[name="replaceAll"] { + grid-row: 2; + grid-column: 6; +} + + +editor-panel .empty-state { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: var(--sand-11); + font-size: 14px; +} + +editor-panel .empty-state.hidden { + display: none; +} + +/* Reserved Panel */ +reserved-panel { + display: flex; + flex-direction: column; + background: var(--sand-1); + border-left: 1px solid var(--sand-6); + position: relative; +} + +reserved-panel .header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: var(--sand-2); + border-bottom: 1px solid var(--sand-6); + height: var(--panel-header-height); +} + +reserved-panel .header h2 { + font-size: 14px; + font-weight: 500; +} + +reserved-panel.collapsed .header h2 { + display: none; +} + +reserved-panel .collapse-btn { + background: none; + border: none; + color: var(--sand-11); + cursor: pointer; + padding: 4px; + font-size: 16px; + line-height: 1; +} + +reserved-panel .collapse-btn:hover { + color: var(--sand-12); + background: var(--sand-4); + border-radius: 3px; +} + +reserved-panel .content { + flex: 1; + position: relative; + color: var(--sand-11); + font-size: 0.875rem; + overflow-y: auto; + overflow-x: hidden; +} + +reserved-panel.collapsed .content { + display: none; +} + +/* Empty and success states */ +reserved-panel .empty-state, +reserved-panel .success-state { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.75rem; + color: var(--sand-11); +} + +reserved-panel .empty-state i, +reserved-panel .success-state i { + font-size: 3rem; + opacity: 0.5; +} + +reserved-panel .success-state { + color: #46a758; +} + +reserved-panel .success-state i { + color: #46a758; + opacity: 0.8; +} + +reserved-panel .empty-state span, +reserved-panel .success-state span { + font-size: 0.875rem; + font-weight: 500; +} + +/* Diagnostics display */ +reserved-panel .diagnostics-container { + width: 100%; + height: 100%; + overflow-y: auto; + padding: 0.375rem; +} + +reserved-panel .file-diagnostics { + margin-bottom: 0.5rem; + background: var(--sand-2); + border: 1px solid var(--sand-6); + border-radius: 0.25rem; + overflow: hidden; +} + +reserved-panel .file-diagnostics:last-child { + margin-bottom: 0; +} + +reserved-panel .file-header { + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.375rem 0.5rem; + cursor: pointer; + user-select: none; + transition: background 0.15s ease; + background: var(--sand-3); + border-bottom: 1px solid var(--sand-6); +} + +reserved-panel .file-header:hover { + background: var(--sand-4); +} + +reserved-panel .file-toggle-icon { + flex-shrink: 0; + font-size: 0.75rem; + color: var(--sand-11); + transition: transform 0.2s ease; +} + +reserved-panel .file-path { + font-family: var(--monospace); + font-size: 0.75rem; + font-weight: 600; + color: var(--sand-12); + flex: 1; +} + +reserved-panel .collapse-all-btn { + flex-shrink: 0; + background: transparent; + border: none; + color: var(--sand-11); + padding: 0.1875rem; + cursor: pointer; + transition: all 0.15s ease; + display: flex; + align-items: center; + justify-content: center; + line-height: 1; + border-radius: 0.1875rem; +} + +reserved-panel .collapse-all-btn:hover { + background: var(--sand-5); + color: var(--sand-12); +} + +reserved-panel .collapse-all-btn i { + font-size: 0.75rem; +} + +reserved-panel .file-diagnostic-count { + font-size: 0.625rem; + font-weight: 700; + color: var(--sand-11); + background: var(--sand-5); + border: 1px solid var(--sand-7); + padding: 0.0625rem 0.375rem; + border-radius: 0.5rem; + min-width: 1.125rem; + text-align: center; +} + +reserved-panel .file-diagnostic-list { + padding: 0.1875rem; +} + +reserved-panel .diagnostic { + margin-bottom: 0.1875rem; + background: var(--sand-1); + border: 1px solid var(--sand-5); + border-radius: 0.1875rem; + overflow: hidden; +} + +reserved-panel .diagnostic:last-child { + margin-bottom: 0; +} + +reserved-panel .diagnostic-summary { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: 0.3125rem 0.5rem; + cursor: pointer; + user-select: none; + transition: background 0.15s ease; + background: var(--sand-2); +} + +reserved-panel .diagnostic-header { + display: flex; + align-items: center; + gap: 0.375rem; +} + +reserved-panel .diagnostic-summary:hover { + background: var(--sand-3); +} + +reserved-panel .diagnostic-icon { + flex-shrink: 0; + font-size: 0.875rem; +} + +reserved-panel .diagnostic-error .diagnostic-icon { + color: #e5484d; +} + +reserved-panel .diagnostic-warning .diagnostic-icon { + color: #f76b15; +} + +reserved-panel .diagnostic-internal .diagnostic-icon { + color: #8e4ec6; +} + +reserved-panel .diagnostic-label { + display: flex; + align-items: center; + gap: 0.25rem; + font-size: 0.875rem; + font-weight: 600; +} + +reserved-panel .diagnostic-kind { + font-weight: 700; +} + +reserved-panel .diagnostic-error .diagnostic-kind { + color: #e5484d; +} + +reserved-panel .diagnostic-warning .diagnostic-kind { + color: #f76b15; +} + +reserved-panel .diagnostic-internal .diagnostic-kind { + color: #8e4ec6; +} + +reserved-panel .diagnostic-source { + color: var(--sand-11); + font-weight: 500; +} + +reserved-panel .diagnostic-main-message { + font-size: 0.875rem; + font-weight: 500; + color: var(--sand-12); +} + +reserved-panel .diagnostic-toggle-icon { + flex-shrink: 0; + font-size: 0.875rem; + color: var(--sand-11); + margin-left: auto; + transition: transform 0.2s ease; +} + +reserved-panel .diagnostic-details { + padding: 0.3125rem 0.5rem 0.5rem 0.5rem; +} + +reserved-panel .diagnostic-message { + margin-bottom: 0.5rem; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +reserved-panel .diagnostic-message:last-child { + margin-bottom: 0; +} + +reserved-panel .message-content { + font-size: 0.875rem; + line-height: 1.5; + color: var(--sand-12); +} + +reserved-panel .message-text { + color: var(--sand-12); +} + +reserved-panel .message-code { + font-family: var(--monospace); + font-size: 0.875rem; + background: var(--sand-3); + padding: 0.0625rem 0.25rem; + border-radius: 0.125rem; + color: var(--sand-12); + font-weight: 600; + border: 1px solid var(--sand-7); +} + +reserved-panel .code-snippet { + margin-top: 0.375rem; + background: var(--sand-2); + border: 1px solid var(--sand-6); + border-radius: 0.1875rem; + overflow: hidden; + font-family: var(--monospace); + font-size: 0.8125rem; +} + +reserved-panel .code-snippet-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.1875rem 0.375rem; + background: var(--sand-3); + border-bottom: 1px solid var(--sand-6); +} + +reserved-panel .snippet-location { + font-size: 0.6875rem; + color: var(--sand-11); + font-family: var(--monospace); +} + +reserved-panel .code-snippet-header .goto-location-btn { + padding: 0.125rem; + width: 1rem; + height: 1rem; + font-size: 0.6875rem; +} + +reserved-panel .code-line { + display: flex; + align-items: flex-start; + line-height: 1.3; +} + +reserved-panel .line-number { + flex-shrink: 0; + width: 2.5rem; + padding: 0.09375rem 0.375rem; + text-align: right; + color: var(--sand-10); + background: var(--sand-3); + border-right: 1px solid var(--sand-6); + user-select: none; + font-family: var(--monospace); +} + +reserved-panel .line-content { + flex: 1; + margin: 0; + padding: 0.09375rem 0.5rem; + white-space: pre-wrap; + word-break: break-all; + color: var(--sand-12); + overflow-x: auto; + font-family: var(--monospace); +} + +reserved-panel .line-content mark.highlight { + background: rgba(255, 220, 40, 0.3); + color: var(--sand-12); + font-weight: 600; + border-radius: 0.125rem; + padding: 0 0.0625rem; +} + +reserved-panel .diagnostic-error .line-content mark.highlight { + background: rgba(229, 72, 77, 0.2); +} + +reserved-panel .diagnostic-warning .line-content mark.highlight { + background: rgba(247, 107, 21, 0.2); +} + +reserved-panel .diagnostic-internal .line-content mark.highlight { + background: rgba(142, 78, 198, 0.2); +} + +reserved-panel .goto-location-btn { + background: var(--sand-3); + border: 1px solid var(--sand-6); + color: var(--sand-11); + padding: 0.25rem; + border-radius: 0.1875rem; + cursor: pointer; + transition: all 0.15s ease; + display: flex; + align-items: center; + justify-content: center; + line-height: 1; +} + +reserved-panel .goto-location-btn:hover { + background: var(--sand-4); + border-color: var(--sand-7); + color: var(--sand-12); +} + +reserved-panel .goto-location-btn i { + font-size: 0.875rem; +} + +/* Scrollbar styling */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: var(--sand-2); +} + +::-webkit-scrollbar-thumb { + background: var(--sand-6); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--sand-7); +} + +/* Toolbar */ +toolbar-panel { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 16px; + background: var(--sand-2); + border-bottom: 1px solid var(--sand-6); + min-height: 44px; + gap: 16px; + position: relative; +} + +toolbar-panel .title { + font-size: 1.125rem; + font-weight: 600; + color: var(--sand-12); + user-select: none; + letter-spacing: -0.02em; +} + +toolbar-panel .status-container { + display: flex; + align-items: center; + gap: 8px; + user-select: none; +} + +toolbar-panel .status-light { + width: 12px; + height: 12px; + border-radius: 50%; + box-shadow: 0 0 4px rgba(0, 0, 0, 0.2); + transition: all 0.3s ease; +} + +toolbar-panel .status-idle { + background: var(--sand-7); + opacity: 0.5; +} + +toolbar-panel .status-running { + background: #0091ff; + animation: pulse 1.5s ease-in-out infinite; +} + +toolbar-panel .status-done { + background: #46a758; +} + +toolbar-panel .status-error { + background: #e5484d; +} + +toolbar-panel .status-aborted { + background: #f76b15; +} + +toolbar-panel .status-fatal { + background: #8e4ec6; +} + +@keyframes pulse { + 0%, 100% { + opacity: 1; + box-shadow: 0 0 4px rgba(0, 145, 255, 0.4); + } + 50% { + opacity: 0.6; + box-shadow: 0 0 8px rgba(0, 145, 255, 0.8); + } +} + +toolbar-panel .status-text { + font-size: 13px; + color: var(--sand-11); + font-weight: 500; +} + +toolbar-panel .status-tooltip { + display: none; + font-size: 0.875rem; + color: var(--sand-11); + position: absolute; + width: max-content; + top: 0; + left: 0; + background: var(--sand-2); + border: 1px solid var(--sand-6); + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); + z-index: var(--z-tooltip); +} + +toolbar-panel .actions { + display: flex; + gap: 8px; +} + +toolbar-panel .compile-btn { + background: var(--sand-12); + color: var(--sand-1); + border: none; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: background 0.2s ease; + display: flex; + flex-direction: row; + align-items: center; +} + +toolbar-panel .compile-btn i { + margin-right: 0.25rem; +} + +toolbar-panel .compile-btn:hover { + background: var(--sand-11); +} + +toolbar-panel .compile-btn:active { + background: var(--sand-12); + transform: translateY(1px); +} + +toolbar-panel .compile-btn:disabled { + background: var(--sand-6); + color: var(--sand-3); + cursor: not-allowed; + pointer-events: none; + box-shadow: inset 0 0 0 1px var(--sand-7); +} + +toolbar-panel .compile-btn.loading { + background: var(--sand-7); + cursor: not-allowed; + pointer-events: none; +} + +toolbar-panel .compile-btn.loading:hover { + background: var(--sand-7); +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +toolbar-panel .compile-btn.loading i { + animation: spin 1s linear infinite; +} + +toolbar-panel .terminate-btn { + background: #e5484d; + color: white; + border: none; + padding: 6px 16px; + border-radius: 4px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: background 0.2s ease; +} + +toolbar-panel .terminate-btn:hover { + background: #dc3e42; +} + +toolbar-panel .terminate-btn:active { + background: #e5484d; + transform: translateY(1px); +} + +/* Console Panel */ +console-panel { + display: flex; + flex-direction: column; + background: var(--sand-1); + border-top: 1px solid var(--sand-6); + position: relative; + min-height: 200px; + max-height: 400px; + transition: height 0.3s cubic-bezier(0.4, 0, 0.2, 1), + flex-basis 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +console-panel.collapsed { + min-height: 40px; + max-height: 40px; +} + +console-panel .header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: var(--sand-2); + border-bottom: 1px solid var(--sand-6); + height: var(--panel-header-height); +} + +console-panel .header-left { + display: flex; + align-items: center; + gap: 12px; +} + +console-panel .header h2 { + font-size: 14px; + font-weight: 500; +} + +console-panel.collapsed .header h2 { + display: none; +} + +console-panel .header .preserve-logs-label { + display: flex; + align-items: center; + gap: 0.25rem; + font-size: 0.875rem; + color: var(--sand-11); + font-weight: 450; + cursor: pointer; + user-select: none; +} + +console-panel .preserve-logs-checkbox { + --size: 0.875rem; + appearance: none; + -webkit-appearance: none; + width: var(--size); + height: var(--size); + border: 1.5px solid var(--sand-8); + border-radius: 0.125rem; + background: var(--sand-1); + cursor: pointer; + position: relative; + transition: all 0.15s ease; + flex-shrink: 0; +} + +console-panel .preserve-logs-checkbox:hover { + border-color: var(--sand-9); + background: var(--sand-2); +} + +console-panel .preserve-logs-checkbox:checked { + background: var(--sand-12); + border-color: var(--sand-12); +} + +console-panel .preserve-logs-checkbox:checked::after { + content: ''; + position: absolute; + left: 50%; + top: 50%; + width: calc(var(--size) * 0.3125); + height: calc(var(--size) * 0.5625); + border: solid var(--sand-1); + border-width: 0 2px 2px 0; + box-sizing: border-box; + transform: translate(-50%, -50%) translateY(-1px) rotate(45deg); +} + +console-panel .preserve-logs-checkbox:focus-visible { + outline: 2px solid var(--sand-8); + outline-offset: 2px; +} + +console-panel .clear-btn { + box-sizing: border-box; + background: var(--red-3); + border: 1px solid var(--red-7); + color: var(--red-12); + cursor: pointer; + padding: 0.125rem 0.25rem; + font-size: 0.875rem; + border-radius: 3px; + font-weight: 450; + display: flex; + flex-direction: row; + align-items: center; + gap: 0.125rem; + transition: background-color 150ms ease, border-color 150ms ease; +} + +console-panel .clear-btn:hover { + background: var(--red-4); + color: var(--red-12); + border-color: var(--red-8); +} + +console-panel .clear-btn:active { + background: var(--red-5); + color: var(--red-12); + border-color: var(--red-8); +} + +console-panel.collapsed .clear-btn { + display: none; +} + +console-panel .collapse-btn { + background: none; + border: none; + color: var(--sand-11); + cursor: pointer; + padding: 4px; + font-size: 16px; + line-height: 1; +} + +console-panel .collapse-btn:hover { + color: var(--sand-12); + background: var(--sand-4); + border-radius: 3px; +} + +console-panel .console-content { + flex: 1; + overflow-y: auto; + padding: 4px 0; + font-family: var(--monospace); + font-size: 0.875rem; + line-height: 1.4; +} + +console-panel.collapsed .console-content { + display: none; +} + +console-panel .console-message { + display: flex; + align-items: center; + gap: 8px; + padding: 2px 12px; + border-bottom: 1px solid transparent; +} + +console-panel .console-message:hover { + background: var(--sand-2); +} + +console-panel .console-icon { + flex-shrink: 0; + width: 16px; + text-align: center; +} + +console-panel .console-text { + flex: 1; + word-break: break-word; + white-space: pre-wrap; +} + +/* Console message type styles */ +console-panel .console-log { + color: var(--sand-12); +} + +console-panel .console-log .console-icon { + color: var(--sand-11); +} + +console-panel .console-error { + color: #e5484d; + background: rgba(229, 72, 77, 0.05); + border-bottom-color: rgba(229, 72, 77, 0.1); +} + +console-panel .console-error .console-icon { + color: #e5484d; +} + +console-panel .console-warn { + color: #f76b15; + background: rgba(247, 107, 21, 0.05); + border-bottom-color: rgba(247, 107, 21, 0.1); +} + +console-panel .console-warn .console-icon { + color: #f76b15; +} + +console-panel .console-info { + color: #0091ff; + background: rgba(0, 145, 255, 0.05); + border-bottom-color: rgba(0, 145, 255, 0.1); +} + +console-panel .console-info .console-icon { + color: #0091ff; +} + +/* Resize Handles */ +resize-handle { + position: absolute; + z-index: 100; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + transition: background-color 0.2s ease; +} + +resize-handle.resize-handle-horizontal { + top: 0; + width: 10px; + height: 100%; + cursor: ew-resize; +} + +resize-handle.resize-handle-horizontal[side="left"] { + right: 0; + transform: translateX(50%); +} + +resize-handle.resize-handle-horizontal[side="right"] { + left: 0; + transform: translateX(-50%); +} + +resize-handle.resize-handle-vertical { + left: 0; + width: 100%; + height: 10px; + cursor: ns-resize; +} + +resize-handle.resize-handle-vertical[side="top"] { + top: 0; + transform: translateY(-50%); +} + +resize-handle.resize-handle-vertical[side="bottom"] { + bottom: 0; + transform: translateY(50%); +} + +resize-handle:hover { + background: var(--sand-8); +} + +resize-handle.active { + background: var(--sand-9); +} + +resize-handle .resize-handle-indicator { + pointer-events: none; + opacity: 0; + transition: opacity 0.2s ease; +} + +resize-handle.resize-handle-horizontal .resize-handle-indicator { + width: 2px; + height: 40px; + background: var(--sand-11); + border-radius: 2px; +} + +resize-handle.resize-handle-vertical .resize-handle-indicator { + width: 40px; + height: 2px; + background: var(--sand-11); + border-radius: 2px; +} + +resize-handle:hover .resize-handle-indicator, +resize-handle.active .resize-handle-indicator { + opacity: 1; +} + +/* Hide resize handles when panels are collapsed */ +file-explorer.collapsed resize-handle, +reserved-panel.collapsed resize-handle, +console-panel.collapsed resize-handle { + display: none; +} diff --git a/hkmc2/shared/src/test/mlscript-compile/RuntimeJS.mjs b/hkmc2/shared/src/test/mlscript-compile/RuntimeJS.mjs index ce12c69ce6..c220ff072d 100644 --- a/hkmc2/shared/src/test/mlscript-compile/RuntimeJS.mjs +++ b/hkmc2/shared/src/test/mlscript-compile/RuntimeJS.mjs @@ -15,6 +15,10 @@ const RuntimeJS = { try { return computation() } catch (error) { return onError(error) } }, + try_finally(computation, onFinally) { + try { return computation() } + finally { return onFinally() } + }, symbols: { definitionMetadata: Symbol.for("mlscript.definitionMetadata"), prettyPrint: Symbol.for("mlscript.prettyPrint") diff --git a/hkmc2/shared/src/test/mlscript-compile/apps/parsing/PrattParsing.mls b/hkmc2/shared/src/test/mlscript-compile/apps/parsing/PrattParsing.mls index aab7edb932..264ba42ede 100644 --- a/hkmc2/shared/src/test/mlscript-compile/apps/parsing/PrattParsing.mls +++ b/hkmc2/shared/src/test/mlscript-compile/apps/parsing/PrattParsing.mls @@ -1,8 +1,8 @@ -import "../../Predef.mls" -import "../../Stack.mls" -import "../../Option.mls" -import "../../Iter.mls" -import "../../StrOps.mls" +import "std/Predef.mls" +import "std/Stack.mls" +import "std/Option.mls" +import "std/Iter.mls" +import "std/StrOps.mls" import "./Lexer.mls" import "./Token.mls" import "./Expr.mls" diff --git a/hkmc2/shared/src/test/mlscript/apps/parsing/PrattParsingTest.mls b/hkmc2/shared/src/test/mlscript/apps/parsing/PrattParsingTest.mls index 4aca5615ea..701e6d691e 100644 --- a/hkmc2/shared/src/test/mlscript/apps/parsing/PrattParsingTest.mls +++ b/hkmc2/shared/src/test/mlscript/apps/parsing/PrattParsingTest.mls @@ -1,9 +1,9 @@ :js -import "../../../mlscript-compile/apps/parsing/PrattParsing.mls" -import "../../../mlscript-compile/apps/parsing/Expr.mls" -import "../../../mlscript-compile/apps/parsing/TokenHelpers.mls" -import "../../../mlscript-compile/apps/parsing/Lexer.mls" +import "std/apps/parsing/PrattParsing.mls" +import "std/apps/parsing/Expr.mls" +import "std/apps/parsing/TokenHelpers.mls" +import "std/apps/parsing/Lexer.mls" open Lexer { lex } open PrattParsing { parse } diff --git a/hkmc2/shared/src/test/mlscript/codegen/FieldSymbols.mls b/hkmc2/shared/src/test/mlscript/codegen/FieldSymbols.mls index 583dc46ba9..b4ae3c66d0 100644 --- a/hkmc2/shared/src/test/mlscript/codegen/FieldSymbols.mls +++ b/hkmc2/shared/src/test/mlscript/codegen/FieldSymbols.mls @@ -102,7 +102,7 @@ case //│ modulefulness = Modulefulness of N //│ restParam = N //│ body = Scoped: -//│ syms = HashSet(a, $argument0$) +//│ syms = HashSet($argument0$, a) //│ body = Match: //│ scrut = Ref: //│ l = caseScrut diff --git a/hkmc2/shared/src/test/mlscript/codegen/ModuleResolution.mls b/hkmc2/shared/src/test/mlscript/codegen/ModuleResolution.mls new file mode 100644 index 0000000000..f36458eed4 --- /dev/null +++ b/hkmc2/shared/src/test/mlscript/codegen/ModuleResolution.mls @@ -0,0 +1,36 @@ +:js + +// Hooray! No need to use relative paths! +import "std/Stack.mls" + +open Stack + +1 :: 2 :: 3 :: Nil +//│ = Cons(1, Cons(2, Cons(3, Nil))) + +:silent +import "fs" + +fs.readdirSync +//│ = fun readdirSync + +:silent +import "node:url" + +// Note that the prefix `node:` is removed by the module resolver. +url.pathToFileURL +//│ = fun pathToFileURL + +// Importing packages in node_modules will be checked. + +:silent +import "typescript" + +typescript.version is Str +//│ = true + +// The following will produce the expected error, but it will also display a +// local path in the error message. So I have to comment it out for now. +// :e +// :re +// import "hooray" diff --git a/hkmc2/shared/src/test/mlscript/decls/Prelude.mls b/hkmc2/shared/src/test/mlscript/decls/Prelude.mls index 10f857e48e..9aa09ffea3 100644 --- a/hkmc2/shared/src/test/mlscript/decls/Prelude.mls +++ b/hkmc2/shared/src/test/mlscript/decls/Prelude.mls @@ -217,3 +217,4 @@ declare module scope with declare val document declare val customElements declare class HTMLElement +declare class CustomEvent diff --git a/hkmc2DiffTests/src/test/scala/hkmc2/DiffTestRunner.scala b/hkmc2DiffTests/src/test/scala/hkmc2/DiffTestRunner.scala index 04c02e450e..f46720c04f 100644 --- a/hkmc2DiffTests/src/test/scala/hkmc2/DiffTestRunner.scala +++ b/hkmc2DiffTests/src/test/scala/hkmc2/DiffTestRunner.scala @@ -22,7 +22,6 @@ object DiffTestRunner: class State: - val cctx: CompilerCtx = CompilerCtx.fresh(io.FileSystem.default) val pwd = os.pwd @@ -34,6 +33,9 @@ object DiffTestRunner: // val dir = workingDir/"hkmc2"/"shared"/"src"/"test"/"mlscript" val dir = workingDir/"hkmc2"/"shared"/"src"/"test" + val nodeModulesPath = workingDir/"node_modules" + + val cctx: CompilerCtx = CompilerCtx.fresh(io.FileSystem.default, LocalTestModuleResolver(dir/"mlscript-compile", S(nodeModulesPath))) val validExt = Set("mls") @@ -111,6 +113,7 @@ class DiffTestRunnerBase(state: DiffTestRunner.State) protected lazy val diffTestFiles = allFiles.filter: file => ( !file.segments.contains("staging") // Exclude staging test files + && !file.segments.contains("mlscript-apps") && !file.segments.contains("mlscript-compile") && filter(file.relativeTo(state.workingDir)) ) diff --git a/hkmc2DiffTests/src/test/scala/hkmc2/Watcher.scala b/hkmc2DiffTests/src/test/scala/hkmc2/Watcher.scala index 38fd6f00d0..1a7ad2d152 100644 --- a/hkmc2DiffTests/src/test/scala/hkmc2/Watcher.scala +++ b/hkmc2DiffTests/src/test/scala/hkmc2/Watcher.scala @@ -29,7 +29,18 @@ class Watcher(dirs: Ls[File]): val completionTime = mutable.Map.empty[File, LocalDateTime] val fileHasher = FileHasher.DEFAULT_FILE_HASHER - given cctx: CompilerCtx = CompilerCtx.fresh(FileSystem.default) + val rootPath = os.pwd/os.up + val testDir = rootPath/"hkmc2"/"shared"/"src"/"test" + val preludePath = testDir/"mlscript"/"decls"/"Prelude.mls" + val predefPath = testDir/"mlscript-compile"/"Predef.mls" + val stdPath = testDir/"mlscript-compile" + val compilerPaths = new MLsCompiler.Paths: + val preludeFile = preludePath + val runtimeFile = testDir/"mlscript-compile"/"Runtime.mjs" + val termFile = testDir/"mlscript-compile"/"Term.mjs" + val nodeModulesPath = rootPath/"node_modules" + + given cctx: CompilerCtx = CompilerCtx.fresh(FileSystem.default, LocalTestModuleResolver(stdPath, S(nodeModulesPath))) val watcher: DirectoryWatcher = DirectoryWatcher.builder() .logger(org.slf4j.helpers.NOPLogger.NOP_LOGGER) @@ -73,7 +84,7 @@ class Watcher(dirs: Ls[File]): completionTime(event.path) = LocalDateTime.now() catch ex => // System.err.println("Unexpected error in watcher: " + ex) - // ex.printStackTrace() + ex.printStackTrace() System.err.println("Unexpected error in watcher (" + ex.getClass() + ")") watcher.close() throw ex @@ -94,18 +105,12 @@ class Watcher(dirs: Ls[File]): val path = os.Path(file.pathAsString) val basePath = path.segments.drop(dirPaths.head.segmentCount).toList.init val relativeName = basePath.map(_ + "/").mkString + path.baseName - val rootPath = os.pwd/os.up - val preludePath = rootPath/"hkmc2"/"shared"/"src"/"test"/"mlscript"/"decls"/"Prelude.mls" - val predefPath = rootPath/"hkmc2"/"shared"/"src"/"test"/"mlscript-compile"/"Predef.mls" val isModuleFile = path.segments.contains("mlscript-compile") if isModuleFile then given Config = Config.default MLsCompiler( - paths = new MLsCompiler.Paths: - val preludeFile = preludePath - val runtimeFile = rootPath/"hkmc2"/"shared"/"src"/"test"/"mlscript-compile"/"Runtime.mjs" - val termFile = rootPath/"hkmc2"/"shared"/"src"/"test"/"mlscript-compile"/"Term.mjs", + paths = compilerPaths, mkRaise = ReportFormatter(System.out.println, colorize = true).mkRaise ).compileModule(path) else