diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 055813062..cc11650af 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -153,6 +153,20 @@ To build a distributable binary java -jar dist/coursier-cli.jar fetch --help ``` +## Build the web demo + +coursier is cross-compiled to scala-js, and can run in the browser. It has a [demo web site](https://coursier.github.io/coursier/#demo), that runs resolutions straight from your web browser. + +Its sources are in the `web` module. + +To build and test this demo site locally, you can do +``` +$ sbt web/fastOptJS +$ open web/target/scala-2.12/classes/index.html +``` +(on Linux, use `xdg-open` instead of `open`) + + # Merging PRs on GitHub Use either "Create merge commit" or "Squash and merge". diff --git a/build.sbt b/build.sbt index c0eba4047..966f9c7db 100644 --- a/build.sbt +++ b/build.sbt @@ -196,7 +196,7 @@ lazy val web = project shared, dontPublish, libs ++= { - if (scalaBinaryVersion.value == "2.11") + if (scalaBinaryVersion.value == "2.12") Seq( CrossDeps.scalaJsJquery.value, CrossDeps.scalaJsReact.value @@ -207,7 +207,7 @@ lazy val web = project sourceDirectory := { val dir = sourceDirectory.value - if (scalaBinaryVersion.value == "2.11") + if (scalaBinaryVersion.value == "2.12") dir else dir / "target" / "dummy" @@ -222,7 +222,20 @@ lazy val web = project WebDeps.react .intransitive() ./("react-with-addons.js") + .minified("react-with-addons.min.js") .commonJSName("React"), + WebDeps.react + .intransitive() + ./("react-dom.js") + .minified("react-dom.min.js") + .dependsOn("react-with-addons.js") + .commonJSName("ReactDOM"), + WebDeps.react + .intransitive() + ./("react-dom-server.js") + .minified("react-dom-server.min.js") + .dependsOn("react-dom.js") + .commonJSName("ReactDOMServer"), WebDeps.bootstrapTreeView .intransitive() ./("bootstrap-treeview.min.js") diff --git a/core/js/src/main/scala/coursier/core/compatibility/package.scala b/core/js/src/main/scala/coursier/core/compatibility/package.scala index f5ae4978d..31dc15972 100644 --- a/core/js/src/main/scala/coursier/core/compatibility/package.scala +++ b/core/js/src/main/scala/coursier/core/compatibility/package.scala @@ -98,18 +98,30 @@ package object compatibility { // FIXME Won't work in the browser lazy val cheerio = g.require("cheerio") - def listWebPageRawElements(page: String): Seq[String] = { + lazy val jqueryAvailable = !js.isUndefined(g.$) - val jquery = cheerio.load(page) + def listWebPageRawElements(page: String): Seq[String] = { val links = new ListBuffer[String] - jquery("a").each({ self: js.Dynamic => - val href = jquery(self).attr("href") - if (!js.isUndefined(href)) - links += href.asInstanceOf[String] - () - }: js.ThisFunction0[js.Dynamic, Unit]) + // getting weird "maybe a wrong Dynamic method signature" errors when trying to factor that more + + if (jqueryAvailable) + g.$("
").html(page).find("a").each({ self: js.Dynamic => + val href = g.$(self).attr("href") + if (!js.isUndefined(href)) + links += href.asInstanceOf[String] + () + }: js.ThisFunction0[js.Dynamic, Unit]) + else { + val jquery = cheerio.load(page) + jquery("a").each({ self: js.Dynamic => + val href = jquery(self).attr("href") + if (!js.isUndefined(href)) + links += href.asInstanceOf[String] + () + }: js.ThisFunction0[js.Dynamic, Unit]) + } links.result() } diff --git a/project/CrossDeps.scala b/project/CrossDeps.scala index 4930081a8..81eb2c8f3 100644 --- a/project/CrossDeps.scala +++ b/project/CrossDeps.scala @@ -15,6 +15,6 @@ object CrossDeps { def scalazCore = setting("org.scalaz" %%% "scalaz-core" % SharedVersions.scalaz) def scalaJsDom = setting("org.scala-js" %%% "scalajs-dom" % "0.9.6") def utest = setting("com.lihaoyi" %%% "utest" % "0.6.4") - def scalaJsJquery = setting("be.doeraene" %%% "scalajs-jquery" % "0.9.3") - def scalaJsReact = setting("com.github.japgolly.scalajs-react" %%% "core" % "0.9.0") + def scalaJsJquery = setting("be.doeraene" %%% "scalajs-jquery" % "0.9.4") + def scalaJsReact = setting("com.github.japgolly.scalajs-react" %%% "core" % "1.3.1") } diff --git a/project/WebDeps.scala b/project/WebDeps.scala index fb2de7df1..5e51eadcd 100644 --- a/project/WebDeps.scala +++ b/project/WebDeps.scala @@ -4,7 +4,7 @@ import sbt.Keys._ object WebDeps { def bootstrap = "org.webjars.bower" % "bootstrap" % "3.3.4" - def react = "org.webjars.bower" % "react" % "0.12.2" + def react = "org.webjars.bower" % "react" % "15.6.1" def bootstrapTreeView = "org.webjars.bower" % "bootstrap-treeview" % "1.2.0" def raphael = "org.webjars.bower" % "raphael" % "2.1.4" } diff --git a/web/src/main/scala/coursier/web/App.scala b/web/src/main/scala/coursier/web/App.scala new file mode 100644 index 000000000..1275ebbd9 --- /dev/null +++ b/web/src/main/scala/coursier/web/App.scala @@ -0,0 +1,501 @@ +package coursier.web + +import coursier.{Dependency, MavenRepository, Module, Resolution} +import coursier.maven.MavenSource +import japgolly.scalajs.react.vdom.{Attr, TagMod} +import japgolly.scalajs.react.vdom.HtmlAttrs.dangerouslySetInnerHtml +import japgolly.scalajs.react._ +import japgolly.scalajs.react.vdom.html_<^._ + +import scala.scalajs.js +import js.Dynamic.{global => g} + +object App { + + lazy val arbor = g.arbor + + val resultDependencies = ScalaComponent.builder[(Resolution, Backend)]("Result") + .render_P { + case (res, backend) => + + def infoLabel(label: String) = + <.span(^.`class` := "label label-info", label) + def errorPopOver(label: String, desc: String) = + popOver("danger", label, desc) + def infoPopOver(label: String, desc: String) = + popOver("info", label, desc) + def popOver(`type`: String, label: String, desc: String) = + <.button(^.`type` := "button", ^.`class` := s"btn btn-xs btn-${`type`}", + Attr("data-trigger") := "focus", + Attr("data-toggle") := "popover", Attr("data-placement") := "bottom", + Attr("data-content") := desc, + ^.onClick ==> backend.enablePopover, + ^.onMouseOver ==> backend.enablePopover, + label + ) + + def depItem(dep: Dependency, finalVersionOpt: Option[String]) = { + <.tr( + ^.`class` := (if (res.errorCache.contains(dep.moduleVersion)) "danger" else ""), + <.td(dep.module.organization), + <.td(dep.module.name), + <.td(finalVersionOpt.fold(dep.version)(finalVersion => s"$finalVersion (for ${dep.version})")), + <.td(TagMod( + if (dep.configuration == "compile") TagMod() else TagMod(infoLabel(dep.configuration)), + if (dep.attributes.`type`.isEmpty || dep.attributes.`type` == "jar") TagMod() else TagMod(infoLabel(dep.attributes.`type`)), + if (dep.attributes.classifier.isEmpty) TagMod() else TagMod(infoLabel(dep.attributes.classifier)), + Some(dep.exclusions).filter(_.nonEmpty).map(excls => infoPopOver("Exclusions", excls.toList.sorted.map{case (org, name) => s"$org:$name"}.mkString("; "))).toSeq.toTagMod, + if (dep.optional) TagMod(infoLabel("optional")) else TagMod(), + res.errorCache.get(dep.moduleVersion).map(errs => errorPopOver("Error", errs.mkString("; "))).toSeq.toTagMod + )), + <.td(TagMod( + res.projectCache.get(dep.moduleVersion) match { + case Some((source: MavenSource, proj)) => + // FIXME Maven specific, generalize with source.artifacts + val version0 = finalVersionOpt getOrElse dep.version + val relPath = + dep.module.organization.split('.').toSeq ++ Seq( + dep.module.name, + version0, + s"${dep.module.name}-$version0" + ) + + val root = source.root + + TagMod( + <.a(^.href := s"$root${relPath.mkString("/")}.pom", + <.span(^.`class` := "label label-info", "POM") + ), + <.a(^.href := s"$root${relPath.mkString("/")}.jar", + <.span(^.`class` := "label label-info", "JAR") + ) + ) + + case _ => TagMod() + } + )) + ) + } + + val sortedDeps = res.minDependencies.toList + .sortBy { dep => + val (org, name, _) = coursier.core.Module.unapply(dep.module).get + (org, name) + } + + <.table(^.`class` := "table", + <.thead( + <.tr( + <.th("Organization"), + <.th("Name"), + <.th("Version"), + <.th("Extra"), + <.th("Links") + ) + ), + <.tbody( + sortedDeps + .map(dep => + depItem( + dep, + res + .projectCache + .get(dep.moduleVersion) + .map(_._2.version) + .filter(_ != dep.version) + ) + ) + .toTagMod + ) + ) + } + .build + + object icon { + def apply(id: String) = <.span(^.`class` := s"glyphicon glyphicon-$id", ^.aria.hidden := "true") + def ok = apply("ok") + def edit = apply("pencil") + def remove = apply("remove") + def up = apply("arrow-up") + def down = apply("arrow-down") + } + + val moduleEditModal = ScalaComponent.builder[((Module, String), Int, Backend)]("EditModule") + .render_P { + case ((module, version), moduleIdx, backend) => + <.div(^.`class` := "modal fade", ^.id := "moduleEdit", ^.role := "dialog", ^.aria.labelledBy := "moduleEditTitle", + <.div(^.`class` := "modal-dialog", <.div(^.`class` := "modal-content", + <.div(^.`class` := "modal-header", + <.button(^.`type` := "button", ^.`class` := "close", Attr("data-dismiss") := "modal", ^.aria.label := "Close", + <.span(^.aria.hidden := "true", dangerouslySetInnerHtml := "×") + ), + <.h4(^.`class` := "modal-title", ^.id := "moduleEditTitle", "Dependency") + ), + <.div(^.`class` := "modal-body", + <.form( + <.div(^.`class` := "form-group", + <.label(^.`for` := "inputOrganization", "Organization"), + <.input(^.`class` := "form-control", ^.id := "inputOrganization", ^.placeholder := "Organization", + ^.onChange ==> backend.updateModule(moduleIdx, (dep, value) => dep.copy(module = dep.module.copy(organization = value))), + ^.value := module.organization + ) + ), + <.div(^.`class` := "form-group", + <.label(^.`for` := "inputName", "Name"), + <.input(^.`class` := "form-control", ^.id := "inputName", ^.placeholder := "Name", + ^.onChange ==> backend.updateModule(moduleIdx, (dep, value) => dep.copy(module = dep.module.copy(name = value))), + ^.value := module.name + ) + ), + <.div(^.`class` := "form-group", + <.label(^.`for` := "inputVersion", "Version"), + <.input(^.`class` := "form-control", ^.id := "inputVersion", ^.placeholder := "Version", + ^.onChange ==> backend.updateModule(moduleIdx, (dep, value) => dep.copy(version = value)), + ^.value := version + ) + ), + <.div(^.`class` := "modal-footer", + <.button(^.`type` := "submit", ^.`class` := "btn btn-primary", Attr("data-dismiss") := "modal", "Done") + ) + ) + ) + )) + ) + } + .build + + val modules = ScalaComponent.builder[(Seq[Dependency], Int, Backend)]("Dependencies") + .render_P { + case (deps, editModuleIdx, backend) => + + def depItem(dep: Dependency, idx: Int) = + <.tr( + <.td(dep.module.organization), + <.td(dep.module.name), + <.td(dep.version), + <.td( + <.a(Attr("data-toggle") := "modal", Attr("data-target") := "#moduleEdit", ^.`class` := "icon-action", + ^.onClick ==> backend.editModule(idx), + icon.edit + ) + ), + <.td( + <.a(Attr("data-toggle") := "modal", Attr("data-target") := "#moduleRemove", ^.`class` := "icon-action", + ^.onClick ==> backend.removeModule(idx), + icon.remove + ) + ) + ) + + <.div( + <.p( + <.button(^.`type` := "button", ^.`class` := "btn btn-default customButton", + ^.onClick ==> backend.addModule, + Attr("data-toggle") := "modal", + Attr("data-target") := "#moduleEdit", + "Add" + ) + ), + <.table(^.`class` := "table", + <.thead( + <.tr( + <.th("Organization"), + <.th("Name"), + <.th("Version"), + <.th(""), + <.th("") + ) + ), + <.tbody( + deps + .zipWithIndex + .map((depItem _).tupled) + .toTagMod + ) + ), + moduleEditModal(( + deps + .lift(editModuleIdx) + .fold(Module("", "") -> "")(_.moduleVersion), + editModuleIdx, + backend + )) + ) + } + .build + + val repoEditModal = ScalaComponent.builder[((String, MavenRepository), Int, Backend)]("EditRepo") + .render_P { + case ((name, repo), repoIdx, backend) => + <.div(^.`class` := "modal fade", ^.id := "repoEdit", ^.role := "dialog", ^.aria.labelledBy := "repoEditTitle", + <.div(^.`class` := "modal-dialog", <.div(^.`class` := "modal-content", + <.div(^.`class` := "modal-header", + <.button(^.`type` := "button", ^.`class` := "close", Attr("data-dismiss") := "modal", ^.aria.label := "Close", + <.span(^.aria.hidden := "true", dangerouslySetInnerHtml := "×") + ), + <.h4(^.`class` := "modal-title", ^.id := "repoEditTitle", "Repository") + ), + <.div(^.`class` := "modal-body", + <.form( + <.div(^.`class` := "form-group", + <.label(^.`for` := "inputName", "Name"), + <.input(^.`class` := "form-control", ^.id := "inputName", ^.placeholder := "Name", + ^.onChange ==> backend.updateRepo(repoIdx, (item, value) => (value, item._2)), + ^.value := name + ) + ), + <.div(^.`class` := "form-group", + <.label(^.`for` := "inputVersion", "Root"), + <.input(^.`class` := "form-control", ^.id := "inputVersion", ^.placeholder := "Root", + ^.onChange ==> backend.updateRepo(repoIdx, (item, value) => (item._1, item._2.copy(root = value))), + ^.value := repo.root + ) + ), + <.div(^.`class` := "modal-footer", + <.button(^.`type` := "submit", ^.`class` := "btn btn-primary", Attr("data-dismiss") := "modal", "Done") + ) + ) + ) + )) + ) + } + .build + + val repositories = ScalaComponent.builder[(Seq[(String, MavenRepository)], Int, Backend)]("Repositories") + .render_P { + case (repos, editRepoIdx, backend) => + + def repoItem(item: (String, MavenRepository), idx: Int, isLast: Boolean) = + <.tr( + <.td(item._1), + <.td(item._2.root), + <.td( + <.a(Attr("data-toggle") := "modal", Attr("data-target") := "#repoEdit", ^.`class` := "icon-action", + ^.onClick ==> backend.editRepo(idx), + icon.edit + ) + ), + <.td( + <.a(Attr("data-toggle") := "modal", Attr("data-target") := "#repoRemove", ^.`class` := "icon-action", + ^.onClick ==> backend.removeRepo(idx), + icon.remove + ) + ), + <.td( + if (idx > 0) + <.a(Attr("data-toggle") := "modal", Attr("data-target") := "#repoUp", ^.`class` := "icon-action", + ^.onClick ==> backend.moveRepo(idx, up = true), + icon.up + ) + else + TagMod() + ), + <.td( + if (isLast) + TagMod() + else + <.a(Attr("data-toggle") := "modal", Attr("data-target") := "#repoDown", ^.`class` := "icon-action", + ^.onClick ==> backend.moveRepo(idx, up = false), + icon.down + ) + ) + ) + + <.div( + <.p( + <.button(^.`type` := "button", ^.`class` := "btn btn-default customButton", + ^.onClick ==> backend.addRepo, + Attr("data-toggle") := "modal", + Attr("data-target") := "#repoEdit", + "Add" + ) + ), + <.table(^.`class` := "table", + <.thead( + <.tr( + <.th("Name"), + <.th("Root"), + <.th(""), + <.th(""), + <.th(""), + <.th("") + ) + ), + <.tbody( + (repos.init.zipWithIndex + .map(t => repoItem(t._1, t._2, isLast = false)) ++ + repos.lastOption.map(repoItem(_, repos.length - 1, isLast = true))).toTagMod + ) + ), + repoEditModal(( + repos + .lift(editRepoIdx) + .getOrElse("" -> MavenRepository("")), + editRepoIdx, + backend + )) + ) + } + .build + + val options = ScalaComponent.builder[(ResolutionOptions, Backend)]("ResolutionOptions") + .render_P { + case (options, backend) => + <.div( + <.div(^.`class` := "checkbox", + <.label( + <.input.checkbox( + ^.onChange ==> backend.options.toggleOptional, + if (options.followOptional) ^.checked := true else TagMod() + ), + "Follow optional dependencies" + ) + ) + ) + } + .build + + val resolution = ScalaComponent.builder[(Option[Resolution], Backend)]("Resolution") + .render_P { + case (resOpt, backend) => + resOpt match { + case Some(res) => + <.div( + <.div(^.`class` := "page-header", + <.h1("Resolution") + ), + resultDependencies((res, backend)) + ) + + case None => + <.div() + } + } + .build + + val initialState = State( + List( + Dependency(Module("org.apache.spark", "spark-sql_2.11"), "2.2.1") // DEBUG + ), + Seq("central" -> MavenRepository("https://repo1.maven.org/maven2/")), + ResolutionOptions(), + None, + -1, + -1, + resolving = false, + reverseTree = false, + log = Nil + ) + + val app = ScalaComponent.builder[Unit]("Coursier") + .initialState(initialState) + .backend(new Backend(_)) + .render { scope => + + val S = scope.state + val backend = scope.backend + + <.div( + <.div(^.role := "tabpanel", + <.ul(^.`class` := "nav nav-tabs", ^.role := "tablist", + <.li(^.role := "presentation", ^.`class` := "active", + <.a(^.href := "#dependencies", ^.aria.controls := "dependencies", ^.role := "tab", Attr("data-toggle") := "tab", + s"Dependencies (${S.modules.length})" + ) + ), + <.li(^.role := "presentation", + <.a(^.href := "#repositories", ^.aria.controls := "repositories", ^.role := "tab", Attr("data-toggle") := "tab", + s"Repositories (${S.repositories.length})" + ) + ), + <.li(^.role := "presentation", + <.a(^.href := "#options", ^.aria.controls := "options", ^.role := "tab", Attr("data-toggle") := "tab", + "Options" + ) + ) + ), + <.div(^.`class` := "tab-content", + <.div(^.role := "tabpanel", ^.`class` := "tab-pane active", ^.id := "dependencies", + modules((S.modules, S.editModuleIdx, backend)) + ), + <.div(^.role := "tabpanel", ^.`class` := "tab-pane", ^.id := "repositories", + repositories((S.repositories, S.editRepoIdx, backend)) + ), + <.div(^.role := "tabpanel", ^.`class` := "tab-pane", ^.id := "options", + options((S.options, backend)) + ) + ) + ), + + <.div(<.form(^.onSubmit ==> backend.handleResolve, + <.button(^.`type` := "submit", ^.id := "resolveButton", ^.`class` := "btn btn-lg btn-primary", + ^.disabled := S.resolving, + if (S.resolving) "Resolving..." else "Resolve" + ) + )), + + + <.div(^.role := "tabpanel", ^.id := "results", + <.ul(^.`class` := "nav nav-tabs", ^.role := "tablist", ^.id := "resTabs", + <.li(^.role := "presentation", ^.id := "resResTab", + <.a(^.href := "#resolution", ^.aria.controls := "resolution", ^.role := "tab", Attr("data-toggle") := "tab", + "Resolution" + ) + ), + <.li(^.role := "presentation", ^.id := "resLogTab", + <.a(^.href := "#log", ^.aria.controls := "log", ^.role := "tab", Attr("data-toggle") := "tab", + "Log" + ) + ), + <.li(^.role := "presentation", + <.a(^.href := "#depgraph", ^.aria.controls := "depgraph", ^.role := "tab", Attr("data-toggle") := "tab", + "Graph" + ) + ), + <.li(^.role := "presentation", + <.a(^.href := "#deptreepanel", ^.aria.controls := "deptreepanel", ^.role := "tab", Attr("data-toggle") := "tab", + "Tree" + ) + ) + ), + <.div(^.`class` := "tab-content", + <.div(^.role := "tabpanel", ^.`class` := "tab-pane", ^.id := "resolution", + resolution((S.resolutionOpt, backend)) + ), + <.div(^.role := "tabpanel", ^.`class` := "tab-pane", ^.id := "log", + <.button(^.`type` := "button", ^.`class` := "btn btn-default", + ^.onClick ==> backend.clearLog, + "Clear" + ), + <.div(^.`class` := "well", + <.ul(^.`class` := "log", + S.log.map(e => <.li(e)).toTagMod + ) + ) + ), + <.div(^.role := "tabpanel", ^.`class` := "tab-pane", ^.id := "depgraph", + <.button(^.`type` := "button", ^.`class` := "btn btn-default", + ^.onClick ==> backend.updateDepGraphBtn(S.resolutionOpt.getOrElse(Resolution.empty)), + "Redraw" + ), + <.div(^.id := "depgraphcanvas") + ), + <.div(^.role := "tabpanel", ^.`class` := "tab-pane", ^.id := "deptreepanel", + <.div(^.`class` := "checkbox", + <.label( + <.input.checkbox( + ^.onChange ==> backend.toggleReverseTree, + if (S.reverseTree) ^.checked := true else TagMod() + ), + "Reverse" + ) + ), + <.div(^.id := "deptree") + ) + ) + ) + ) + } + .build + +} diff --git a/web/src/main/scala/coursier/web/Backend.scala b/web/src/main/scala/coursier/web/Backend.scala index 47ec82df1..198960666 100644 --- a/web/src/main/scala/coursier/web/Backend.scala +++ b/web/src/main/scala/coursier/web/Backend.scala @@ -2,42 +2,30 @@ package coursier.web import coursier.{Dependency, Fetch, MavenRepository, Module, Platform, Repository, Resolution} import coursier.maven.MavenSource -import coursier.util.{Gather, Task} -import japgolly.scalajs.react.vdom.{ TagMod, Attr } -import japgolly.scalajs.react.vdom.Attrs.dangerouslySetInnerHtml -import japgolly.scalajs.react.{ ReactEventI, ReactComponentB, BackendScope } -import japgolly.scalajs.react.vdom.prefix_<^._ -import scala.scalajs.concurrent.JSExecutionContext.Implicits.queue +import coursier.util.{EitherT, Gather, Task} +import japgolly.scalajs.react._ +import org.scalajs.dom import org.scalajs.jquery.jQuery -import scala.concurrent.Future - import scala.scalajs.js -import js.Dynamic.{ global => g } +import scala.util.{Failure, Success} +import js.Dynamic.{global => g} -final case class ResolutionOptions( - followOptional: Boolean = false -) - -final case class State( - modules: Seq[Dependency], - repositories: Seq[(String, MavenRepository)], - options: ResolutionOptions, - resolutionOpt: Option[Resolution], - editModuleIdx: Int, - editRepoIdx: Int, - resolving: Boolean, - reverseTree: Boolean, - log: Seq[String] -) - -class Backend($: BackendScope[Unit, State]) { +final class Backend($: BackendScope[_, State]) { def fetch( repositories: Seq[Repository], fetch: Fetch.Content[Task] ): Fetch.Metadata[Task] = { + val fetch0: Fetch.Content[Task] = { a => + if (a.url.endsWith("/")) + // don't fetch directory listings + EitherT[Task, String, String](Task.point(Left(""))) + else + fetch(a) + } + modVers => Gather[Task].gather( modVers.map { case (module, version) => Fetch.find(repositories, module, version, fetch) @@ -102,7 +90,7 @@ class Backend($: BackendScope[Unit, State]) { println("Rendered canvas") } - def updateDepGraphBtn(resolution: Resolution)(e: ReactEventI) = { + def updateDepGraphBtn(resolution: Resolution)(e: raw.SyntheticEvent[_]) = CallbackTo[Unit] { updateDepGraph(resolution) } @@ -150,81 +138,95 @@ class Backend($: BackendScope[Unit, State]) { .treeview(js.Dictionary("data" -> js.Array(minDependencies.toList.map(tree): _*))) } - def resolve(action: => Unit = ()) = { + def resolve(action: => Unit = ()): CallbackTo[Unit] = { + g.$("#resLogTab a:last").tab("show") - $.modState(_.copy(resolving = true, log = Nil)) + $.modState(_.copy(resolving = true, log = Nil)).runNow() val logger: Platform.Logger = new Platform.Logger { def fetched(url: String) = { println(s"<- $url") - $.modState(s => s.copy(log = s"<- $url" +: s.log)) + $.modState(s => s.copy(log = s"<- $url" +: s.log)).runNow() } def fetching(url: String) = { println(s"-> $url") - $.modState(s => s.copy(log = s"-> $url" +: s.log)) + $.modState(s => s.copy(log = s"-> $url" +: s.log)).runNow() } def other(url: String, msg: String) = { println(s"$url: $msg") - $.modState(s => s.copy(log = s"$url: $msg" +: s.log)) + $.modState(s => s.copy(log = s"$url: $msg" +: s.log)).runNow() } } - val s = $.state - def task = { - val res = coursier.Resolution( - s.modules.toSet, - filter = Some(dep => - s.options.followOptional || !dep.optional + $.state.map { s => + + def task = { + val res = coursier.Resolution( + s.modules.toSet, + filter = Some(dep => + s.options.followOptional || !dep.optional + ) ) - ) - res - .process - .run(fetch(s.repositories.map { case (_, repo) => repo }, Platform.artifactWithLogger(logger)), 100) - } - - // For reasons that are unclear to me, not delaying this when using the runNow execution context - // somehow discards the $.modState above. (Not a major problem as queue is used by default.) - Future(task)(scala.scalajs.concurrent.JSExecutionContext.Implicits.queue).flatMap(_.future()).foreach { res: Resolution => - $.modState{ s => - updateDepGraph(res) - updateTree(res, "#deptree", reverse = s.reverseTree) - - s.copy( - resolutionOpt = Some(res), - resolving = false - ) + res + .process + .run(fetch(s.repositories.map { case (_, repo) => repo }, Platform.artifactWithLogger(logger)), 100) } - g.$("#resResTab a:last") - .tab("show") + implicit val ec = scala.scalajs.concurrent.JSExecutionContext.Implicits.queue + + task.map { res: Resolution => + $.modState { s => + updateDepGraph(res) + updateTree(res, "#deptree", reverse = s.reverseTree) + + s.copy( + resolutionOpt = Some(res), + resolving = false + ) + }.runNow() + + g.$("#resResTab a:last") + .tab("show") + }.future()(ec).onComplete { + case Success(_) => + case Failure(t) => + println(s"Caught exception: $t") + } + + () } } - def handleResolve(e: ReactEventI) = { - println(s"Resolving") - e.preventDefault() - jQuery("#results").css("display", "block") - resolve() + def handleResolve(e: raw.SyntheticEvent[_]) = { + + val c = CallbackTo[Unit] { + println(s"Resolving") + e.preventDefault() + jQuery("#results").css("display", "block") + } + + c.flatMap { _ => + resolve() + } } - def clearLog(e: ReactEventI) = { + def clearLog(e: raw.SyntheticEvent[_]) = { $.modState(_.copy(log = Nil)) } - def toggleReverseTree(e: ReactEventI) = { - $.modState{ s => + def toggleReverseTree(e: raw.SyntheticEvent[_]) = + $.modState { s => for (res <- s.resolutionOpt) updateTree(res, "#deptree", reverse = !s.reverseTree) s.copy(reverseTree = !s.reverseTree) } - } - def editModule(idx: Int)(e: ReactEventI) = { + def editModule(idx: Int)(e: raw.SyntheticEvent[_]) = { e.preventDefault() $.modState(_.copy(editModuleIdx = idx)) } - def removeModule(idx: Int)(e: ReactEventI) = { + def removeModule(idx: Int)(e: raw.SyntheticEvent[_]) = { e.preventDefault() $.modState(s => s.copy( @@ -236,21 +238,22 @@ class Backend($: BackendScope[Unit, State]) { ) } - def updateModule(moduleIdx: Int, update: (Dependency, String) => Dependency)(e: ReactEventI) = { + def updateModule(moduleIdx: Int, update: (Dependency, String) => Dependency)(e: raw.SyntheticEvent[dom.raw.HTMLInputElement]) = if (moduleIdx >= 0) { - $.modState{ state => + e.persist() + $.modState { state => val dep = state.modules(moduleIdx) state.copy( modules = state.modules .updated(moduleIdx, update(dep, e.target.value)) ) } - } - } + } else + CallbackTo.pure(()) - def addModule(e: ReactEventI) = { + def addModule(e: raw.SyntheticEvent[_]) = { e.preventDefault() - $.modState{ state => + $.modState { state => val modules = state.modules :+ Dependency(Module("", ""), "") println(s"Modules:\n${modules.mkString("\n")}") state.copy( @@ -260,12 +263,12 @@ class Backend($: BackendScope[Unit, State]) { } } - def editRepo(idx: Int)(e: ReactEventI) = { + def editRepo(idx: Int)(e: raw.SyntheticEvent[_]) = { e.preventDefault() $.modState(_.copy(editRepoIdx = idx)) } - def removeRepo(idx: Int)(e: ReactEventI) = { + def removeRepo(idx: Int)(e: raw.SyntheticEvent[_]) = { e.preventDefault() $.modState(s => s.copy( @@ -277,7 +280,7 @@ class Backend($: BackendScope[Unit, State]) { ) } - def moveRepo(idx: Int, up: Boolean)(e: ReactEventI) = { + def moveRepo(idx: Int, up: Boolean)(e: raw.SyntheticEvent[_]) = { e.preventDefault() $.modState { s => val idx0 = if (up) idx - 1 else idx + 1 @@ -297,21 +300,21 @@ class Backend($: BackendScope[Unit, State]) { } } - def updateRepo(repoIdx: Int, update: ((String, MavenRepository), String) => (String, MavenRepository))(e: ReactEventI) = { - if (repoIdx >= 0) { - $.modState{ state => + def updateRepo(repoIdx: Int, update: ((String, MavenRepository), String) => (String, MavenRepository))(e: raw.SyntheticEvent[dom.raw.HTMLInputElement]) = + if (repoIdx >= 0) + $.modState { state => val repo = state.repositories(repoIdx) state.copy( repositories = state.repositories .updated(repoIdx, update(repo, e.target.value)) ) } - } - } + else + CallbackTo.pure(()) - def addRepo(e: ReactEventI) = { + def addRepo(e: raw.SyntheticEvent[_]) = { e.preventDefault() - $.modState{ state => + $.modState { state => val repositories = state.repositories :+ ("" -> MavenRepository("")) println(s"Repositories:\n${repositories.mkString("\n")}") state.copy( @@ -321,13 +324,13 @@ class Backend($: BackendScope[Unit, State]) { } } - def enablePopover(e: ReactEventI) = { + def enablePopover(e: raw.SyntheticMouseEvent[_]) = CallbackTo[Unit] { g.$("[data-toggle='popover']") .popover() } object options { - def toggleOptional(e: ReactEventI) = { + def toggleOptional(e: raw.SyntheticEvent[_]) = { $.modState(s => s.copy( options = s.options @@ -337,485 +340,3 @@ class Backend($: BackendScope[Unit, State]) { } } } - -object App { - - lazy val arbor = g.arbor - - val resultDependencies = ReactComponentB[(Resolution, Backend)]("Result") - .render{ T => - val (res, backend) = T - - def infoLabel(label: String) = - <.span(^.`class` := "label label-info", label) - def errorPopOver(label: String, desc: String) = - popOver("danger", label, desc) - def infoPopOver(label: String, desc: String) = - popOver("info", label, desc) - def popOver(`type`: String, label: String, desc: String) = - <.button(^.`type` := "button", ^.`class` := s"btn btn-xs btn-${`type`}", - Attr("data-trigger") := "focus", - Attr("data-toggle") := "popover", Attr("data-placement") := "bottom", - Attr("data-content") := desc, - ^.onClick ==> backend.enablePopover, - ^.onMouseOver ==> backend.enablePopover, - label - ) - - def depItem(dep: Dependency, finalVersionOpt: Option[String]) = { - <.tr( - ^.`class` := (if (res.errorCache.contains(dep.moduleVersion)) "danger" else ""), - <.td(dep.module.organization), - <.td(dep.module.name), - <.td(finalVersionOpt.fold(dep.version)(finalVersion => s"$finalVersion (for ${dep.version})")), - <.td(Seq[Seq[TagMod]]( - if (dep.configuration == "compile") Seq() else Seq(infoLabel(dep.configuration)), - if (dep.attributes.`type`.isEmpty || dep.attributes.`type` == "jar") Seq() else Seq(infoLabel(dep.attributes.`type`)), - if (dep.attributes.classifier.isEmpty) Seq() else Seq(infoLabel(dep.attributes.classifier)), - Some(dep.exclusions).filter(_.nonEmpty).map(excls => infoPopOver("Exclusions", excls.toList.sorted.map{case (org, name) => s"$org:$name"}.mkString("; "))).toSeq, - if (dep.optional) Seq(infoLabel("optional")) else Seq(), - res.errorCache.get(dep.moduleVersion).map(errs => errorPopOver("Error", errs.mkString("; "))).toSeq - )), - <.td(Seq[Seq[TagMod]]( - res.projectCache.get(dep.moduleVersion) match { - case Some((source: MavenSource, proj)) => - // FIXME Maven specific, generalize with source.artifacts - val version0 = finalVersionOpt getOrElse dep.version - val relPath = - dep.module.organization.split('.').toSeq ++ Seq( - dep.module.name, - version0, - s"${dep.module.name}-$version0" - ) - - val root = source.root - - Seq( - <.a(^.href := s"$root${relPath.mkString("/")}.pom", - <.span(^.`class` := "label label-info", "POM") - ), - <.a(^.href := s"$root${relPath.mkString("/")}.jar", - <.span(^.`class` := "label label-info", "JAR") - ) - ) - - case _ => Seq() - } - )) - ) - } - - val sortedDeps = res.minDependencies.toList - .sortBy { dep => - val (org, name, _) = coursier.core.Module.unapply(dep.module).get - (org, name) - } - - <.table(^.`class` := "table", - <.thead( - <.tr( - <.th("Organization"), - <.th("Name"), - <.th("Version"), - <.th("Extra"), - <.th("Links") - ) - ), - <.tbody( - sortedDeps.map(dep => - depItem( - dep, - res - .projectCache - .get(dep.moduleVersion) - .map(_._2.version) - .filter(_ != dep.version) - ) - ) - ) - ) - } - .build - - object icon { - def apply(id: String) = <.span(^.`class` := s"glyphicon glyphicon-$id", ^.aria.hidden := "true") - def ok = apply("ok") - def edit = apply("pencil") - def remove = apply("remove") - def up = apply("arrow-up") - def down = apply("arrow-down") - } - - val moduleEditModal = ReactComponentB[((Module, String), Int, Backend)]("EditModule") - .render{ P => - val ((module, version), moduleIdx, backend) = P - <.div(^.`class` := "modal fade", ^.id := "moduleEdit", ^.role := "dialog", ^.aria.labelledby := "moduleEditTitle", - <.div(^.`class` := "modal-dialog", <.div(^.`class` := "modal-content", - <.div(^.`class` := "modal-header", - <.button(^.`type` := "button", ^.`class` := "close", Attr("data-dismiss") := "modal", ^.aria.label := "Close", - <.span(^.aria.hidden := "true", dangerouslySetInnerHtml("×")) - ), - <.h4(^.`class` := "modal-title", ^.id := "moduleEditTitle", "Dependency") - ), - <.div(^.`class` := "modal-body", - <.form( - <.div(^.`class` := "form-group", - <.label(^.`for` := "inputOrganization", "Organization"), - <.input(^.`class` := "form-control", ^.id := "inputOrganization", ^.placeholder := "Organization", - ^.onChange ==> backend.updateModule(moduleIdx, (dep, value) => dep.copy(module = dep.module.copy(organization = value))), - ^.value := module.organization - ) - ), - <.div(^.`class` := "form-group", - <.label(^.`for` := "inputName", "Name"), - <.input(^.`class` := "form-control", ^.id := "inputName", ^.placeholder := "Name", - ^.onChange ==> backend.updateModule(moduleIdx, (dep, value) => dep.copy(module = dep.module.copy(name = value))), - ^.value := module.name - ) - ), - <.div(^.`class` := "form-group", - <.label(^.`for` := "inputVersion", "Version"), - <.input(^.`class` := "form-control", ^.id := "inputVersion", ^.placeholder := "Version", - ^.onChange ==> backend.updateModule(moduleIdx, (dep, value) => dep.copy(version = value)), - ^.value := version - ) - ), - <.div(^.`class` := "modal-footer", - <.button(^.`type` := "submit", ^.`class` := "btn btn-primary", Attr("data-dismiss") := "modal", "Done") - ) - ) - ) - )) - ) - } - .build - - val modules = ReactComponentB[(Seq[Dependency], Int, Backend)]("Dependencies") - .render{ P => - val (deps, editModuleIdx, backend) = P - - def depItem(dep: Dependency, idx: Int) = - <.tr( - <.td(dep.module.organization), - <.td(dep.module.name), - <.td(dep.version), - <.td( - <.a(Attr("data-toggle") := "modal", Attr("data-target") := "#moduleEdit", ^.`class` := "icon-action", - ^.onClick ==> backend.editModule(idx), - icon.edit - ) - ), - <.td( - <.a(Attr("data-toggle") := "modal", Attr("data-target") := "#moduleRemove", ^.`class` := "icon-action", - ^.onClick ==> backend.removeModule(idx), - icon.remove - ) - ) - ) - - <.div( - <.p( - <.button(^.`type` := "button", ^.`class` := "btn btn-default customButton", - ^.onClick ==> backend.addModule, - Attr("data-toggle") := "modal", - Attr("data-target") := "#moduleEdit", - "Add" - ) - ), - <.table(^.`class` := "table", - <.thead( - <.tr( - <.th("Organization"), - <.th("Name"), - <.th("Version"), - <.th(""), - <.th("") - ) - ), - <.tbody( - deps.zipWithIndex - .map((depItem _).tupled) - ) - ), - moduleEditModal(( - deps - .lift(editModuleIdx) - .fold(Module("", "") -> "")(_.moduleVersion), - editModuleIdx, - backend - )) - ) - } - .build - - val repoEditModal = ReactComponentB[((String, MavenRepository), Int, Backend)]("EditRepo") - .render{ P => - val ((name, repo), repoIdx, backend) = P - <.div(^.`class` := "modal fade", ^.id := "repoEdit", ^.role := "dialog", ^.aria.labelledby := "repoEditTitle", - <.div(^.`class` := "modal-dialog", <.div(^.`class` := "modal-content", - <.div(^.`class` := "modal-header", - <.button(^.`type` := "button", ^.`class` := "close", Attr("data-dismiss") := "modal", ^.aria.label := "Close", - <.span(^.aria.hidden := "true", dangerouslySetInnerHtml("×")) - ), - <.h4(^.`class` := "modal-title", ^.id := "repoEditTitle", "Repository") - ), - <.div(^.`class` := "modal-body", - <.form( - <.div(^.`class` := "form-group", - <.label(^.`for` := "inputName", "Name"), - <.input(^.`class` := "form-control", ^.id := "inputName", ^.placeholder := "Name", - ^.onChange ==> backend.updateRepo(repoIdx, (item, value) => (value, item._2)), - ^.value := name - ) - ), - <.div(^.`class` := "form-group", - <.label(^.`for` := "inputVersion", "Root"), - <.input(^.`class` := "form-control", ^.id := "inputVersion", ^.placeholder := "Root", - ^.onChange ==> backend.updateRepo(repoIdx, (item, value) => (item._1, item._2.copy(root = value))), - ^.value := repo.root - ) - ), - <.div(^.`class` := "modal-footer", - <.button(^.`type` := "submit", ^.`class` := "btn btn-primary", Attr("data-dismiss") := "modal", "Done") - ) - ) - ) - )) - ) - } - .build - - val repositories = ReactComponentB[(Seq[(String, MavenRepository)], Int, Backend)]("Repositories") - .render{ P => - val (repos, editRepoIdx, backend) = P - - def repoItem(item: (String, MavenRepository), idx: Int, isLast: Boolean) = - <.tr( - <.td(item._1), - <.td(item._2.root), - <.td( - <.a(Attr("data-toggle") := "modal", Attr("data-target") := "#repoEdit", ^.`class` := "icon-action", - ^.onClick ==> backend.editRepo(idx), - icon.edit - ) - ), - <.td( - <.a(Attr("data-toggle") := "modal", Attr("data-target") := "#repoRemove", ^.`class` := "icon-action", - ^.onClick ==> backend.removeRepo(idx), - icon.remove - ) - ), - <.td( - if (idx > 0) - Seq(<.a(Attr("data-toggle") := "modal", Attr("data-target") := "#repoUp", ^.`class` := "icon-action", - ^.onClick ==> backend.moveRepo(idx, up = true), - icon.up - )) - else - Seq() - ), - <.td( - if (isLast) - Seq() - else - Seq(<.a(Attr("data-toggle") := "modal", Attr("data-target") := "#repoDown", ^.`class` := "icon-action", - ^.onClick ==> backend.moveRepo(idx, up = false), - icon.down - )) - ) - ) - - <.div( - <.p( - <.button(^.`type` := "button", ^.`class` := "btn btn-default customButton", - ^.onClick ==> backend.addRepo, - Attr("data-toggle") := "modal", - Attr("data-target") := "#repoEdit", - "Add" - ) - ), - <.table(^.`class` := "table", - <.thead( - <.tr( - <.th("Name"), - <.th("Root"), - <.th(""), - <.th(""), - <.th(""), - <.th("") - ) - ), - <.tbody( - repos.init.zipWithIndex - .map(t => repoItem(t._1, t._2, isLast = false)) ++ - repos.lastOption.map(repoItem(_, repos.length - 1, isLast = true)) - ) - ), - repoEditModal(( - repos - .lift(editRepoIdx) - .getOrElse("" -> MavenRepository("")), - editRepoIdx, - backend - )) - ) - } - .build - - val options = ReactComponentB[(ResolutionOptions, Backend)]("ResolutionOptions") - .render{ P => - val (options, backend) = P - - <.div( - <.div(^.`class` := "checkbox", - <.label( - <.input(^.`type` := "checkbox", - ^.onChange ==> backend.options.toggleOptional, - if (options.followOptional) Seq(^.checked := "checked") else Seq(), - "Follow optional dependencies" - ) - ) - ) - ) - } - .build - - val resolution = ReactComponentB[(Option[Resolution], Backend)]("Resolution") - .render{ T => - val (resOpt, backend) = T - - resOpt match { - case Some(res) => - <.div( - <.div(^.`class` := "page-header", - <.h1("Resolution") - ), - resultDependencies((res, backend)) - ) - - case None => - <.div() - } - } - .build - - val initialState = State( - Nil, - Seq("central" -> MavenRepository("https://repo1.maven.org/maven2/")), - ResolutionOptions(), - None, - -1, - -1, - resolving = false, - reverseTree = false, - log = Nil - ) - - val app = ReactComponentB[Unit]("Coursier") - .initialState(initialState) - .backend(new Backend(_)) - .render((_,S,B) => - <.div( - <.div(^.role := "tabpanel", - <.ul(^.`class` := "nav nav-tabs", ^.role := "tablist", - <.li(^.role := "presentation", ^.`class` := "active", - <.a(^.href := "#dependencies", ^.aria.controls := "dependencies", ^.role := "tab", Attr("data-toggle") := "tab", - s"Dependencies (${S.modules.length})" - ) - ), - <.li(^.role := "presentation", - <.a(^.href := "#repositories", ^.aria.controls := "repositories", ^.role := "tab", Attr("data-toggle") := "tab", - s"Repositories (${S.repositories.length})" - ) - ), - <.li(^.role := "presentation", - <.a(^.href := "#options", ^.aria.controls := "options", ^.role := "tab", Attr("data-toggle") := "tab", - "Options" - ) - ) - ), - <.div(^.`class` := "tab-content", - <.div(^.role := "tabpanel", ^.`class` := "tab-pane active", ^.id := "dependencies", - modules((S.modules, S.editModuleIdx, B)) - ), - <.div(^.role := "tabpanel", ^.`class` := "tab-pane", ^.id := "repositories", - repositories((S.repositories, S.editRepoIdx, B)) - ), - <.div(^.role := "tabpanel", ^.`class` := "tab-pane", ^.id := "options", - options((S.options, B)) - ) - ) - ), - - <.div(<.form(^.onSubmit ==> B.handleResolve, - <.button(^.`type` := "submit", ^.id := "resolveButton", ^.`class` := "btn btn-lg btn-primary", - if (S.resolving) ^.disabled := "true" else Attr("active") := "true", - if (S.resolving) "Resolving..." else "Resolve" - ) - )), - - - <.div(^.role := "tabpanel", ^.id := "results", - <.ul(^.`class` := "nav nav-tabs", ^.role := "tablist", ^.id := "resTabs", - <.li(^.role := "presentation", ^.id := "resResTab", - <.a(^.href := "#resolution", ^.aria.controls := "resolution", ^.role := "tab", Attr("data-toggle") := "tab", - "Resolution" - ) - ), - <.li(^.role := "presentation", ^.id := "resLogTab", - <.a(^.href := "#log", ^.aria.controls := "log", ^.role := "tab", Attr("data-toggle") := "tab", - "Log" - ) - ), - <.li(^.role := "presentation", - <.a(^.href := "#depgraph", ^.aria.controls := "depgraph", ^.role := "tab", Attr("data-toggle") := "tab", - "Graph" - ) - ), - <.li(^.role := "presentation", - <.a(^.href := "#deptreepanel", ^.aria.controls := "deptreepanel", ^.role := "tab", Attr("data-toggle") := "tab", - "Tree" - ) - ) - ), - <.div(^.`class` := "tab-content", - <.div(^.role := "tabpanel", ^.`class` := "tab-pane", ^.id := "resolution", - resolution((S.resolutionOpt, B)) - ), - <.div(^.role := "tabpanel", ^.`class` := "tab-pane", ^.id := "log", - <.button(^.`type` := "button", ^.`class` := "btn btn-default", - ^.onClick ==> B.clearLog, - "Clear" - ), - <.div(^.`class` := "well", - <.ul(^.`class` := "log", - S.log.map(e => <.li(e)) - ) - ) - ), - <.div(^.role := "tabpanel", ^.`class` := "tab-pane", ^.id := "depgraph", - <.button(^.`type` := "button", ^.`class` := "btn btn-default", - ^.onClick ==> B.updateDepGraphBtn(S.resolutionOpt.getOrElse(Resolution.empty)), - "Redraw" - ), - <.div(^.id := "depgraphcanvas") - ), - <.div(^.role := "tabpanel", ^.`class` := "tab-pane", ^.id := "deptreepanel", - <.div(^.`class` := "checkbox", - <.label( - <.input(^.`type` := "checkbox", - ^.onChange ==> B.toggleReverseTree, - if (S.reverseTree) Seq(^.checked := "checked") else Seq(), - "Reverse" - ) - ) - ), - <.div(^.id := "deptree") - ) - ) - ) - ) - ) - .buildU - -} diff --git a/web/src/main/scala/coursier/web/Main.scala b/web/src/main/scala/coursier/web/Main.scala index 2fa379284..5c643efa2 100644 --- a/web/src/main/scala/coursier/web/Main.scala +++ b/web/src/main/scala/coursier/web/Main.scala @@ -1,14 +1,12 @@ package coursier.web -import japgolly.scalajs.react.React - import scala.scalajs.js.annotation.{JSExport, JSExportTopLevel} import org.scalajs.dom.document @JSExportTopLevel("CoursierWeb") object Main { @JSExport - def main(): Unit = { - React.render(App.app("Coursier"), document.getElementById("demoContent")) - } + def main(): Unit = + App.app() + .renderIntoDOM(document.getElementById("demoContent")) } diff --git a/web/src/main/scala/coursier/web/ResolutionOptions.scala b/web/src/main/scala/coursier/web/ResolutionOptions.scala new file mode 100644 index 000000000..47a1684d1 --- /dev/null +++ b/web/src/main/scala/coursier/web/ResolutionOptions.scala @@ -0,0 +1,5 @@ +package coursier.web + +final case class ResolutionOptions( + followOptional: Boolean = false +) diff --git a/web/src/main/scala/coursier/web/State.scala b/web/src/main/scala/coursier/web/State.scala new file mode 100644 index 000000000..e93a7a4c5 --- /dev/null +++ b/web/src/main/scala/coursier/web/State.scala @@ -0,0 +1,15 @@ +package coursier.web + +import coursier.{Dependency, MavenRepository, Resolution} + +final case class State( + modules: Seq[Dependency], + repositories: Seq[(String, MavenRepository)], + options: ResolutionOptions, + resolutionOpt: Option[Resolution], + editModuleIdx: Int, + editRepoIdx: Int, + resolving: Boolean, + reverseTree: Boolean, + log: Seq[String] +)