├── .ghci ├── .gitignore ├── .gitmodules ├── CHANGELOG.md ├── LICENSE ├── README.md ├── demo ├── index.html └── no-docs-available.html ├── dev-scripts ├── build-client.sh ├── client-file-watch.sh ├── css-file-watch.sh ├── generate-css.sh ├── internal │ └── CompileLucius.hs ├── old │ ├── container-docker │ │ └── Dockerfile │ └── make-container-image.sh ├── run-demo.sh ├── setup-environment.sh └── soh-runner.sh ├── doc └── wishlist.md ├── soh-client ├── LICENSE ├── soh-client.cabal ├── soh.lucius ├── src │ ├── Import.hs │ ├── Import │ │ └── Util.hs │ ├── JavaScript │ │ ├── Ace.hs │ │ ├── IFrame.hs │ │ ├── TermJs.hs │ │ └── Unmanaged.hs │ ├── Main.hs │ ├── Model.hs │ ├── Model │ │ ├── Protocol.hs │ │ └── Server.hs │ ├── Types.hs │ ├── View.hs │ └── View │ │ ├── Annotation.hs │ │ ├── Build.hs │ │ ├── Console.hs │ │ ├── PosMap.hs │ │ └── TypeInfo.hs ├── stack.yaml └── util.js ├── soh-runner-api ├── LICENSE ├── soh-runner-api.cabal └── src │ └── SchoolOfHaskell │ └── Runner │ └── API.hs ├── soh-runner ├── LICENSE ├── main │ └── Main.hs ├── soh-runner.cabal └── src │ └── SchoolOfHaskell │ └── Runner.hs ├── soh-scheduler-api ├── LICENSE ├── soh-scheduler-api.cabal └── src │ └── SchoolOfHaskell │ └── Scheduler │ └── API.hs ├── soh-scheduler ├── .dockerignore ├── Dockerfile ├── LICENSE ├── Makefile ├── Setup.hs ├── main │ └── Main.hs ├── soh-scheduler.cabal ├── src │ └── SchoolOfHaskell │ │ ├── Scheduler.hs │ │ └── Scheduler │ │ ├── AWS.hs │ │ ├── Types.hs │ │ └── Web.hs └── stack.yaml └── stack.yaml /.ghci: -------------------------------------------------------------------------------- 1 | :set -i.:config:dist/build/autogen 2 | :set -DDEVELOPMENT 3 | :set -XCPP -XTemplateHaskell -XQuasiQuotes -XTypeFamilies -XFlexibleContexts -XGADTs -XOverloadedStrings -XMultiParamTypeClasses -XGeneralizedNewtypeDeriving -XEmptyDataDecls -XDeriveDataTypeable 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist* 2 | static/tmp/ 3 | static/combined/ 4 | config/client_session_key.aes 5 | *.hi 6 | *.o 7 | *.sqlite3 8 | .hsenv* 9 | cabal-dev/ 10 | yesod-devel/ 11 | .cabal-sandbox 12 | cabal.sandbox.config 13 | .DS_Store 14 | *.swp 15 | session.*/ 16 | .docker-sandbox/ 17 | .shake.database 18 | .ssh/ 19 | cabal-ghcjs-config 20 | *.js_hi 21 | *.js_o 22 | .stack-work/ 23 | dev-scripts/container-docker/ide-backend-server 24 | dev-scripts/container-docker/soh-runner 25 | soh-scheduler/static/soh.js 26 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "deps/ghcjs-react"] 2 | path = deps/ghcjs-react 3 | url = git@github.com:fpco/ghcjs-react.git 4 | [submodule "deps/ghcjs-from-typescript"] 5 | path = deps/ghcjs-from-typescript 6 | url = git://github.com/fpco/ghcjs-from-typescript.git 7 | [submodule "deps/ghcjs-jquery"] 8 | path = deps/ghcjs-jquery 9 | url = git@github.com:ghcjs/ghcjs-jquery.git 10 | [submodule "deps/stack-ide"] 11 | path = deps/stack-ide 12 | url = https://github.com/commercialhaskell/stack-ide 13 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | schoolofhaskell changelog 2 | ========================= 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 FP Complete 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # School of Haskell 2 | 3 | The School of Haskell is a service providing interactive Haskell 4 | tutorials and documentation. 5 | 6 | ## How to setup 7 | 8 | Run `./dev-scripts/setup-environment.sh` to install the correct GHC and GHCJS. 9 | Note that GHCJS is currently installed from source, and will take quite a while 10 | to compile and boot. 11 | 12 | ## How to build 13 | 14 | Installation requires stack [stack](https://github.com/commercialhaskell/stack) 15 | 0.1.6 or newer. 16 | 17 | To build the serverside code: `stack build` 18 | 19 | To build the client code and CSS: `./dev-scripts/build-client.sh` 20 | 21 | ## How to run the local demo 22 | 23 | `/dev-scripts/run-demo.sh` will run `soh-runner` and open up a browser window 24 | with the demo. The web snippets in the demo requires `yesod` and 25 | `yesod-media-simple`. Install these with `stack build yesod-media-simple` in 26 | order for the web snippets to compile. 27 | 28 | ## Development tips 29 | 30 | I like to rebuild the client whenever any files change. This is what 31 | `dev-scripts/client-file-watch.sh` does. It requires the 32 | [entr](http://entrproject.org/) file watcher program, which can be installed 33 | with `sudo apt-get install entr`. 34 | 35 | If you just change the lucius file, you can run `./dev-scripts/generate-css.sh` 36 | to regenerate the CSS. `dev-scripts/css-file-watch.sh` is similar to 37 | client-file-watch, but it only rebuilds the CSS. 38 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 42 | 43 | 44 |
45 |

Sierpinski Triangle

46 |
47 | import Diagrams.Backend.Cairo (Cairo) 48 | import Diagrams.Prelude 49 | import Yesod.Media.Simple 50 | 51 | main :: IO () 52 | main = serve (sierpinski 4) 53 | 54 | sierpinski :: Int -> Diagram Cairo 55 | sierpinski 1 = triangle 1 56 | sierpinski n = s 57 | === 58 | (s ||| s) # centerX 59 | where s = sierpinski (n-1) 60 |
61 |

Console Calculator

62 |

How would we write a calculator program in Haskell? A simple 63 | calculator should be able to perform the basic arithmetic operations 64 | on two numbers.

65 |
data Operator = Add | Subtract | Multiply | Divide 66 | deriving Read 67 | 68 | eval l o r = case o of 69 | Add -> l + r 70 | Subtract -> l - r 71 | Multiply -> l * r 72 | Divide -> l / r 73 | 74 | prompt txt = do 75 | putStrLn txt 76 | readLn 77 | 78 | main = do 79 | l <- prompt "Left operand?" 80 | o <- prompt "Which operator?" 81 | r <- prompt "Right operand?" 82 | putStrLn $ "The result is " ++ show (eval l o r) 83 |
84 |

Web Calculator

85 |

Let's make our calculator program into a website!

86 |
{-# LANGUAGE TypeFamilies, QuasiQuotes, MultiParamTypeClasses, 87 | TemplateHaskell, OverloadedStrings #-} 88 | import Yesod hiding (Add, Subtract, Multiply, Divide) 89 | import Yesod.Form 90 | import Control.Applicative 91 | 92 | data Calculator = Calculator 93 | 94 | instance Yesod Calculator 95 | 96 | instance RenderMessage Calculator FormMessage where 97 | renderMessage _ _ = defaultFormMessage 98 | 99 | mkYesod "Calculator" [parseRoutes| 100 | / HomeR GET 101 | |] 102 | 103 | main = warpEnv Calculator 104 | 105 | data Calculation = Calculation Double Operator Double 106 | 107 | form = renderDivs $ Calculation 108 | <$> areq doubleField "Left operand" Nothing 109 | <*> areq (selectField optionsEnum) "Operator" Nothing 110 | <*> areq doubleField "Right operand" Nothing 111 | 112 | getHomeR = do 113 | ((result, widget), enctype) <- runFormGet form 114 | 115 | let resultText = "Result: " ++ case result of 116 | FormSuccess (Calculation l o r) -> show (eval l o r) 117 | _ -> "" 118 | 119 | defaultLayout $ do 120 | [whamlet| 121 | <h1> Calculator </h1> 122 | <form enctype=#{enctype}> 123 | ^{widget} 124 | <input #button type="submit" value="Calculate!"> 125 | <h2> #{toHtml resultText} 126 | |] 127 | toWidget [cassius| 128 | body { width: 15em; margin: 0em auto; } 129 | #button { width: 10em; } 130 | input, select { width: 4em; margin: 0.5em; } 131 | .required { text-align: right; } 132 | |] 133 | 134 | data Operator = Add | Subtract | Multiply | Divide 135 | deriving (Eq, Enum, Bounded, Show) 136 | 137 | eval l o r = case o of 138 | Add -> l + r 139 | Subtract -> l - r 140 | Multiply -> l * r 141 | Divide -> l / r 142 |
143 |
144 | 145 | 146 | -------------------------------------------------------------------------------- /demo/no-docs-available.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | No docs available for selection 4 | 5 | 6 | -------------------------------------------------------------------------------- /dev-scripts/build-client.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Run this script from the schoolofhaskell directory, like so: 4 | # 5 | # ./dev-scripts/build-client.sh 6 | # 7 | # You can pass stack arguments in as arguments to this script. 8 | 9 | set -xe 10 | 11 | (cd soh-client; stack build --flag soh-client:local-soh-runner $@) 12 | rm -f demo/soh.js 13 | cp $(cd soh-client; stack path --local-install-root)/bin/soh-client.jsexe/all.js demo/soh.js 14 | ./dev-scripts/generate-css.sh 15 | -------------------------------------------------------------------------------- /dev-scripts/client-file-watch.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | find soh-client -path soh-client/.stack-work -prune -o \( -name *.hs -o -name *.js -o -name *.lucius -o -name *.cabal \) | 4 | entr ./dev-scripts/build-client.sh 5 | -------------------------------------------------------------------------------- /dev-scripts/css-file-watch.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | find soh-client -path soh-client/.stack-work -prune -o -name *.lucius | 4 | entr ./dev-scripts/generate-css.sh 5 | -------------------------------------------------------------------------------- /dev-scripts/generate-css.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Run this script from the schoolofhaskell directory, like so: 4 | # 5 | # ./dev-scripts/generate-css.sh 6 | 7 | set -xe 8 | 9 | stack runghc --package shakespeare-2.0.6 dev-scripts/internal/CompileLucius.hs 10 | -------------------------------------------------------------------------------- /dev-scripts/internal/CompileLucius.hs: -------------------------------------------------------------------------------- 1 | import Text.Lucius 2 | import qualified Data.Text.Lazy as LT 3 | import qualified Data.Text.Lazy.IO as LT 4 | 5 | main = do 6 | input <- LT.readFile "soh-client/soh.lucius" 7 | case luciusRT input [] of 8 | Left err -> fail err 9 | Right res -> LT.writeFile "demo/soh.css" res 10 | -------------------------------------------------------------------------------- /dev-scripts/old/container-docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM fpco/stack-ghcjs-build:lts-2.14 2 | MAINTAINER Michael Sloan 3 | 4 | RUN cabal update; cabal install ide-backend-rts 5 | ADD soh-runner ide-backend-server /opt/soh/ 6 | VOLUME /logs 7 | 8 | ENV PATH /opt/soh:$PATH 9 | ENTRYPOINT ["/opt/soh/soh-runner", "run", "--receipt"] 10 | EXPOSE 3000 4000 11 | -------------------------------------------------------------------------------- /dev-scripts/old/make-container-image.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -xe 4 | 5 | cd dev-scripts/container-docker 6 | 7 | rm -f soh-runner 8 | rm -f ide-backend-server 9 | 10 | ln -P ../../.stack-work/install/x86_64-linux/lts-2.14/7.8.4/bin/soh-runner soh-runner 11 | ln -P ../../.stack-work/install/x86_64-linux/lts-2.14/7.8.4/bin/ide-backend-server ide-backend-server 12 | 13 | docker build -t mgsloan/soh-runner:local . 14 | 15 | # After this, we can run it via: 16 | # 17 | # docker run -p 127.0.0.1:4000:4000 -t -i mgsloan/soh-ghcjs-dev:local /bin/bash -c 'PATH=/opt/soh/:$PATH /opt/soh/soh-runner' 18 | -------------------------------------------------------------------------------- /dev-scripts/run-demo.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -x 4 | 5 | sensible-browser demo/index.html & 6 | ./dev-scripts/soh-runner.sh 7 | -------------------------------------------------------------------------------- /dev-scripts/setup-environment.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -xe 4 | 5 | stack setup 6 | # FIXME: this is needed due to https://github.com/commercialhaskell/stack/issues/1258 7 | stack install happy 8 | (cd soh-client; stack setup) 9 | -------------------------------------------------------------------------------- /dev-scripts/soh-runner.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -x 4 | 5 | stack exec -- soh-runner run -v -r '00000000-0000-0000-0000-000000000000' 6 | -------------------------------------------------------------------------------- /doc/wishlist.md: -------------------------------------------------------------------------------- 1 | This is just a braindump wishlist: 2 | 3 | # Type Info Wishlist 4 | 5 | * Dequalify qualified names, and somehow visually make it apparent that this 6 | happened? Hovering over the portion would tell you the full name. 7 | 8 | * Explanations for builtin type syntax. For example, explanations of 9 | forall, (=>), tuples, lists, etc. These could also be made into links 10 | to relevant documentation, though that might bug advanced users (could 11 | make this a setting). 12 | 13 | - Would also apply to highlighted expressions etc, could possibly 14 | also apply to code in the editor. 15 | 16 | * Highlighting of AST nodes of the type on hover. This lets the user 17 | know the structure of the type's AST, and can aid in learning Haskell 18 | syntax. Unfortunately, this is not possible to do perfectly with our 19 | current information, because we need fixities. Even for advanced 20 | users, this will be helpful for understanding types that involve infix 21 | operators. 22 | 23 | - For expressions within the editor, the above is already given by type 24 | info. 25 | 26 | * Allow type synonyms to be replaced by definitions and vice versa. 27 | 28 | - The equivalent in expressions would be inlining and abstraction 29 | refactorings. 30 | 31 | I think the best implementation strategy for the items above is to 32 | expose annotated ASTs from ide-backend, possibly involving 33 | modifications of GHC itself. Until then, we'll stick to the 34 | haskell-src-exts / autocomplete map annotation of type info. 35 | 36 | # Identifier info 37 | 38 | * Fall back on hoogle when identifier info fails. Might be best to implement 39 | this at the stack-ide level so that all clients can benefit. 40 | -------------------------------------------------------------------------------- /soh-client/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 FP Complete 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /soh-client/soh-client.cabal: -------------------------------------------------------------------------------- 1 | name: soh-client 2 | version: 0.0.1 3 | license: MIT 4 | license-file: LICENSE 5 | author: FP Complete Developers 6 | maintainer: dev@fpcomplete.com 7 | copyright: 2015 FP Complete Corporation 8 | category: Development 9 | build-type: Simple 10 | cabal-version: >=1.10 11 | 12 | Flag local-soh-runner 13 | Description: Use local soh-runner 14 | Default: False 15 | 16 | Flag local-soh-scheduler 17 | Description: Use local soh-scheduler (which will use AWS soh-runners) 18 | Default: False 19 | 20 | executable soh-client 21 | default-language: Haskell2010 22 | hs-source-dirs: src 23 | main-is: Main.hs 24 | js-sources: util.js 25 | ghc-options: -Wall 26 | if flag(local-soh-scheduler) 27 | cpp-options: -DLOCAL_SOH_SCHEDULER 28 | if flag(local-soh-runner) 29 | cpp-options: -DLOCAL_SOH_RUNNER 30 | other-modules: Import 31 | Import.Util 32 | JavaScript.Ace 33 | JavaScript.IFrame 34 | JavaScript.Unmanaged 35 | Model 36 | Model.Protocol 37 | Model.Server 38 | Types 39 | View 40 | View.Build 41 | View.Console 42 | View.PosMap 43 | extensions: CPP 44 | DeriveDataTypeable 45 | DeriveGeneric 46 | FlexibleInstances 47 | ForeignFunctionInterface 48 | GeneralizedNewtypeDeriving 49 | JavaScriptFFI 50 | LambdaCase 51 | MultiParamTypeClasses 52 | NoImplicitPrelude 53 | OverloadedStrings 54 | ParallelListComp 55 | RankNTypes 56 | RecordWildCards 57 | ScopedTypeVariables 58 | StandaloneDeriving 59 | TemplateHaskell 60 | TypeFamilies 61 | ViewPatterns 62 | build-depends: base >=4.7 && <5 63 | , aeson >= 0.8 64 | , async >= 2.0 65 | , base >= 4.7 66 | , bytestring >= 0.10 67 | , ghcjs-ace 68 | , ghcjs-base >= 0.1 69 | , ghcjs-dom >= 0.1 70 | , ghcjs-jquery >= 0.1 71 | , ghcjs-prim >= 0.1 72 | , ghcjs-react 73 | , ghcjs-websockets >= 0.3.0.4 74 | , ide-backend-common 75 | , stack-ide-api 76 | , lens >= 4.7 77 | , mtl >= 2.1 78 | , soh-runner-api 79 | , soh-scheduler-api 80 | , stm >= 2.4 81 | , text >= 1.2 82 | , transformers 83 | , uuid-types >= 1.0 84 | , vector >= 0.10 85 | , void >= 0.7 86 | -------------------------------------------------------------------------------- /soh-client/soh.lucius: -------------------------------------------------------------------------------- 1 | /* Theme CSS */ 2 | 3 | .soh-snippet, #soh-controls > div { 4 | background: #fff; 5 | } 6 | 7 | .soh-snippet, #soh-controls > div, .controls-bar { 8 | border-color: #d1d1d1; 9 | } 10 | 11 | .soh-snippet:hover { 12 | .run { 13 | opacity: 0.75; 14 | color: #c33; 15 | } 16 | } 17 | 18 | .soh-snippet { 19 | .run { 20 | -webkit-transition: opacity 0.2s ease-in-out, color 0.2s ease-in-out; 21 | -moz-transition: opacity 0.2s ease-in-out, color 0.2s ease-in-out; 22 | -o-transition: opacity 0.2s ease-in-out, color 0.2s ease-in-out; 23 | transition: opacity 0.2s ease-in-out, color 0.2s ease-in-out; 24 | opacity: 0.25; 25 | } 26 | .run:hover { 27 | opacity: 1; 28 | color: #d44; 29 | } 30 | .type-popup { 31 | background: white; 32 | z-index: 1; 33 | border-color: #d1d1d1; 34 | .type-info { 35 | background: initial; 36 | border-color: #d1d1d1; 37 | } 38 | } 39 | .type-info-expr { 40 | background: #e5f0ff; 41 | } 42 | } 43 | 44 | #soh-controls { 45 | .tab { 46 | color: #655; 47 | } 48 | .tab:hover { 49 | background-color: #f1f1f1; 50 | } 51 | .tab.tab-focused { 52 | background-color: #e1e1e1; 53 | /* rounded tab? 54 | background-color: white; 55 | border-bottom: 1px solid white; 56 | border-left: 1px solid #d1d1d1; 57 | border-right: 1px solid #d1d1d1; 58 | position: relative; 59 | top: 1px; 60 | border-radius: 0.5em 0.5em 0 0; 61 | */ 62 | } 63 | } 64 | .soh-inline-controls { 65 | margin-top: -1px; 66 | } 67 | 68 | .build-tab-content { 69 | .kind-error { 70 | color: #b94a48; 71 | } 72 | .kind-warning { 73 | color: #9a7a43; 74 | background: #fcf8e3; 75 | } 76 | } 77 | 78 | /* Main CSS */ 79 | 80 | #soh-controls > div { 81 | display: none; 82 | } 83 | 84 | #soh-controls > div.soh-visible { 85 | display: block; 86 | } 87 | 88 | .soh-snippet, #soh-controls > div { 89 | font-family: 'Ubuntu', sans-serif; 90 | border-width: 1px; 91 | border-style: solid; 92 | border-radius: 0.2em; 93 | } 94 | 95 | /* Show which snippet is active when the controls aren't attached */ 96 | .soh-snippet.soh-current.soh-remote-controls { 97 | border-color: #e1c1c1; 98 | } 99 | 100 | .soh-container { 101 | margin: 0.25em 0 0.75em 0; 102 | } 103 | 104 | .soh-snippet { 105 | width: 800px; 106 | /* same background color as the editor theme */ 107 | position: relative; 108 | padding: 0.2em; 109 | .ace_editor { 110 | margin: 0.25em; 111 | height: 300px; 112 | font-size: 15px !important; 113 | } 114 | .run { 115 | position: absolute; 116 | right: 0; 117 | padding: 0.1em; 118 | font-size: 22px; 119 | cursor: pointer; 120 | } 121 | /* glyphicon-play */ 122 | .run:before { 123 | content: "\e072"; 124 | } 125 | /* glyphicon-cog */ 126 | .run.building:before { 127 | content: "\e019"; 128 | } 129 | .type-popup { 130 | position: absolute; 131 | border-width: 1px; 132 | border-style: solid; 133 | border-radius: 0.4em; 134 | padding: 0.4em; 135 | max-width: 50%; 136 | .type-info { 137 | padding-bottom: 0.2em; 138 | border-bottom: 1px dashed; 139 | margin-bottom: 0.2em; 140 | background: initial; 141 | } 142 | .type-info:last-child { 143 | padding-bottom: 0; 144 | border-bottom: none; 145 | margin-bottom: 0; 146 | } 147 | } 148 | .type-info-expr { 149 | position: absolute; 150 | } 151 | } 152 | 153 | #soh-controls { 154 | .controls-bar { 155 | height: 2.3em; 156 | border-bottom-width: 1px; 157 | border-bottom-style: solid 158 | } 159 | .tab { 160 | cursor: pointer; 161 | display: block; 162 | float: left; 163 | height: 100%; 164 | font-size: 120%; 165 | padding: 0.2em 0.7em; 166 | } 167 | .soh-close-btn { 168 | cursor: pointer; 169 | display: inline-block; 170 | float: right; 171 | font-size: 150%; 172 | margin-right: 0.25em; 173 | } 174 | .soh-close-btn:before { 175 | content: "X" 176 | } 177 | /* TODO: make it resizable */ 178 | .tab-content { 179 | height: 30em; 180 | display: none; 181 | } 182 | .tab-content-focused { 183 | display: block; 184 | } 185 | .hide-mark { 186 | display: none; 187 | } 188 | } 189 | 190 | .docs-link { 191 | cursor: pointer; 192 | } 193 | .docs-link:hover { 194 | text-decoration: underline; 195 | } 196 | .highlighted-haskell { 197 | font-size: 15px !important; 198 | white-space: pre-wrap; 199 | } 200 | 201 | .build-tab-content { 202 | overflow: auto; 203 | padding: 0.25em; 204 | .message { 205 | padding: 0.25em; 206 | min-height: 1.5em; 207 | } 208 | .error-proper-span { 209 | cursor: pointer; 210 | text-decoration: underline; 211 | } 212 | .error-msg { 213 | padding-left: 0.5em; 214 | white-space: pre-wrap; 215 | font-family: 'Ubuntu Mono', monospace; 216 | } 217 | } 218 | 219 | .console-tab-content { 220 | overflow: auto; 221 | background: black; 222 | width: 100%; 223 | height: 100%; 224 | text-align: center; 225 | .terminal { 226 | float: none; 227 | display: inline-block; 228 | border: 2px solid white; 229 | margin-top: 1em; 230 | padding: 1em; 231 | } 232 | } 233 | 234 | .docs-tab-content { 235 | /* This is a hack to make the parent window not scroll when the iframe 236 | * anchor is updated. 237 | */ 238 | display: block !important; 239 | position: fixed; 240 | visibility: hidden; 241 | } 242 | 243 | .docs-tab-content.tab-content-focused { 244 | position: initial; 245 | visibility: visible; 246 | } 247 | 248 | .docs-tab-content, .web-tab-content { 249 | iframe { 250 | border: none; 251 | width: 100%; 252 | height: 100%; 253 | } 254 | } 255 | 256 | .tab, .run { 257 | -webkit-user-select: none; 258 | -khtml-user-select: none; 259 | -moz-user-select: none; 260 | -o-user-select: none; 261 | user-select: none; 262 | } 263 | 264 | @-moz-keyframes spin { 100% { -moz-transform: rotate(360deg); } } 265 | @-webkit-keyframes spin { 100% { -webkit-transform: rotate(360deg); } } 266 | @keyframes spin { 100% { -webkit-transform: rotate(360deg); transform:rotate(360deg); } } 267 | -------------------------------------------------------------------------------- /soh-client/src/Import.hs: -------------------------------------------------------------------------------- 1 | module Import 2 | ( module Control.Applicative 3 | , module Control.Concurrent.STM 4 | , module Control.Lens 5 | , module Control.Monad 6 | , module Data.Foldable 7 | , module Data.Maybe 8 | , module Data.Monoid 9 | , module Data.Traversable 10 | , module GHCJS.Foreign 11 | , module GHCJS.Marshal 12 | , module GHCJS.Types 13 | , module IdeSession.Types.Progress 14 | , module IdeSession.Types.Public 15 | , module Import.Util 16 | , module JavaScript.Unmanaged 17 | , module Prelude 18 | , module React 19 | , module React.Lucid 20 | , module Stack.Ide.JsonAPI 21 | , module Types 22 | , ByteString 23 | , Text 24 | -- * Simplified types 25 | , React 26 | , App 27 | , Component 28 | , UComponent 29 | -- * Misc utils 30 | , ixSnippet 31 | , getEditor 32 | , readEditor 33 | , currentSnippet 34 | , positionControlsOnResize 35 | , schedulerHost 36 | , noDocsUrl 37 | ) where 38 | 39 | import Control.Applicative ((<$>), (<*>)) 40 | import Control.Concurrent.STM 41 | import Control.Lens hiding (Sequenced) 42 | import Control.Monad (void, join, when, unless, forever, (>=>), (<=<)) 43 | import Data.ByteString (ByteString) 44 | import Data.Foldable (forM_, mapM_) 45 | import Data.Maybe 46 | import Data.Monoid 47 | import Data.Text (Text) 48 | import Data.Traversable (forM, mapM) 49 | import GHCJS.Foreign 50 | import GHCJS.Marshal 51 | import GHCJS.Types 52 | import IdeSession.Types.Progress 53 | import IdeSession.Types.Public 54 | import Import.Util 55 | import JavaScript.Ace (Editor) 56 | import JavaScript.Unmanaged 57 | import Prelude hiding (mapM, mapM_) 58 | import React hiding (App, getElementById) 59 | import qualified React.Internal 60 | import React.Lucid 61 | import Stack.Ide.JsonAPI 62 | import Types 63 | 64 | type React a = ReactT State IO a 65 | 66 | type App = React.Internal.App State IO 67 | 68 | type Component a = React.Internal.Component State a IO 69 | 70 | type UComponent a = React.Internal.Component State (Unmanaged a) IO 71 | 72 | ixSnippet :: SnippetId -> Traversal' State Snippet 73 | ixSnippet (SnippetId sid) = stateSnippets . ix sid 74 | 75 | getEditor :: State -> SnippetId -> IO Editor 76 | getEditor state sid = 77 | getUnmanagedOrFail (state ^? ixSnippet sid . snippetEditor) 78 | 79 | readEditor :: TVar State -> SnippetId -> IO Editor 80 | readEditor stateVar sid = 81 | readUnmanagedOrFail stateVar (^? ixSnippet sid . snippetEditor) 82 | 83 | currentSnippet :: State -> Maybe SnippetId 84 | currentSnippet state = 85 | case state ^. stateStatus of 86 | InitialStatus -> Nothing 87 | BuildRequested (BuildRequest sid _) -> Just sid 88 | Building sid _ -> Just sid 89 | Built sid _ -> Just sid 90 | QueryRequested sid _ _ -> Just sid 91 | KillRequested sid _ -> Just sid 92 | 93 | foreign import javascript unsafe 94 | "positionControlsOnResize" 95 | positionControlsOnResize :: Element -> Element -> IO () 96 | 97 | schedulerHost :: Text 98 | #if LOCAL_SOH_SCHEDULER 99 | schedulerHost = "http://localhost:3000" 100 | #else 101 | schedulerHost = "http://soh-scheduler-1627848338.us-east-1.elb.amazonaws.com" 102 | #endif 103 | 104 | noDocsUrl :: Text 105 | --FIXME 106 | -- noDocsUrl = schedulerHost <> "/static/no-docs-available.html" 107 | noDocsUrl = "no-docs-available.html" 108 | -------------------------------------------------------------------------------- /soh-client/src/Import/Util.hs: -------------------------------------------------------------------------------- 1 | {-# OPTIONS_GHC -fno-warn-orphans #-} 2 | 3 | module Import.Util where 4 | 5 | import Control.Concurrent.STM 6 | import Control.Exception (SomeException, catch, throwIO) 7 | import Control.Lens hiding (Sequenced, coerce) 8 | import Control.Monad (unless, (<=<)) 9 | import Control.Monad.Trans.Maybe (MaybeT(..)) 10 | import Data.Char (isHexDigit) 11 | import Data.Coerce (coerce) 12 | import Data.Foldable (forM_) 13 | import Data.IORef 14 | import Data.Monoid 15 | import Data.Text (Text, pack) 16 | import qualified Data.Text as T 17 | import Data.Typeable (Typeable, typeRep) 18 | import GHCJS.DOM.HTMLElement (HTMLElement) 19 | import GHCJS.DOM.Types (IsElement) 20 | import GHCJS.Foreign 21 | import GHCJS.Marshal 22 | import GHCJS.Types 23 | import IdeSession.Types.Public 24 | import JavaScript.JQuery (JQuery, selectElement) 25 | import JavaScript.JQuery.Internal (jq_getText) 26 | import Prelude 27 | import React 28 | import React.Lucid 29 | import SchoolOfHaskell.Runner.API 30 | import Stack.Ide.JsonAPI 31 | import System.IO.Unsafe (unsafePerformIO) 32 | 33 | addWhen :: Bool -> Text -> Text -> Text 34 | addWhen True x y = y <> " " <> x 35 | addWhen False _ y = y 36 | 37 | tshow :: Show a => a -> Text 38 | tshow = pack . show 39 | 40 | once :: IO () -> IO (IO ()) 41 | once runAction = do 42 | alreadyCalledRef <- newIORef False 43 | return $ do 44 | alreadyCalled <- readIORef alreadyCalledRef 45 | unless alreadyCalled runAction 46 | 47 | debounce :: Int -> IO () -> IO (IO ()) 48 | debounce ms f = do 49 | f' <- asyncCallback AlwaysRetain f 50 | mtimeoutRef <- newIORef Nothing 51 | return $ do 52 | mtimeout <- readIORef mtimeoutRef 53 | forM_ mtimeout clearTimeout 54 | writeIORef mtimeoutRef . Just =<< setTimeout f' ms 55 | 56 | newtype TimeoutId = TimeoutId (JSRef TimeoutId) 57 | deriving (Typeable, ToJSRef, FromJSRef) 58 | 59 | foreign import javascript unsafe "setTimeout($1, $2)" 60 | setTimeout :: JSFun (IO ()) -> Int -> IO TimeoutId 61 | 62 | foreign import javascript unsafe "clearTimeout($1)" 63 | clearTimeout :: TimeoutId -> IO () 64 | 65 | -------------------------------------------------------------------------------- 66 | -- Misc utils 67 | 68 | prop :: FromJSRef a => JSRef obj -> JSString -> IO (Maybe a) 69 | prop obj n = do 70 | ref <- getProp n obj 71 | if isUndefined ref || isNull ref 72 | then return Nothing 73 | else fromJSRef ref 74 | 75 | mtprop :: FromJSRef a => JSRef obj -> JSString -> MaybeT IO a 76 | mtprop obj n = MaybeT $ prop obj n 77 | 78 | expectProp :: FromJSRef a => JSRef obj -> JSString -> IO a 79 | expectProp obj n = do 80 | mx <- prop obj n 81 | case mx of 82 | Nothing -> fail $ "Couldn't find expected property " ++ fromJSString n 83 | Just x -> return x 84 | 85 | setPropShow :: (Monad m, Show a) => T.Text -> a -> ReactT state m () 86 | setPropShow n = attr n . T.pack . show 87 | 88 | divClass :: Monad m => Text -> ReactT state m a -> ReactT state m a 89 | divClass className f = div_ $ do 90 | class_ className 91 | f 92 | 93 | spanClass :: Monad m => Text -> ReactT state m a -> ReactT state m a 94 | spanClass className f = span_ $ do 95 | class_ className 96 | f 97 | 98 | foreign import javascript unsafe "$2.get($1)" 99 | getElement :: Int -> JQuery -> IO HTMLElement 100 | 101 | -- TODO: Find a better way to do this, or add similar utilities to ghcjs-base 102 | intToJSNumber :: Int -> JSNumber 103 | intToJSNumber n = coerce $ unsafePerformIO $ toJSRef n 104 | 105 | fromJSRefOrFail :: (FromJSRef a, Typeable a) => JSRef a -> IO a 106 | fromJSRefOrFail ref = fromJSRefOrFail' (show (typeRep ref)) ref 107 | 108 | fromJSRefOrFail' :: FromJSRef a => String -> JSRef a -> IO a 109 | fromJSRefOrFail' what ref = do 110 | mx <- fromJSRef ref 111 | case mx of 112 | Nothing -> fail $ "Failed to marshal " ++ what 113 | Just x -> return x 114 | 115 | getElementsByClassName :: JSString -> IO [Element] 116 | getElementsByClassName name = 117 | mapM fromJSRefOrFail =<< fromArray =<< getElementsByClassName' name 118 | 119 | foreign import javascript unsafe "document.getElementsByClassName($1)" 120 | getElementsByClassName' :: JSString -> IO (JSArray Element) 121 | 122 | getElementById :: JSString -> IO (Maybe Element) 123 | getElementById name = 124 | fromJSRef =<< getElementById' name 125 | 126 | foreign import javascript unsafe "document.getElementById($1)" 127 | getElementById' :: JSString -> IO (JSRef Element) 128 | 129 | getElementText :: IsElement e => e -> IO JSString 130 | getElementText = jq_getText <=< selectElement 131 | 132 | -------------------------------------------------------------------------------- 133 | -- Tvar/lens helpers 134 | 135 | setTVarIO :: TVar s -> ASetter' s a -> a -> IO () 136 | setTVarIO v l a = atomically $ modifyTVar' v $ set l a 137 | 138 | modifyTVarIO :: TVar s -> ASetter' s a -> (a -> a) -> IO () 139 | modifyTVarIO v l f = atomically $ modifyTVar' v $ over l f 140 | 141 | waitForTVarIO :: TVar s -> (s -> Maybe a) -> IO a 142 | waitForTVarIO v f = atomically $ do 143 | x <- readTVar v 144 | case f x of 145 | Just y -> return y 146 | Nothing -> retry 147 | 148 | viewTVarIO :: TVar s -> Getter s a -> IO a 149 | viewTVarIO v g = 150 | atomically 151 | (fmap (view g) 152 | (readTVar v)) 153 | 154 | foreign import javascript "console.log($1)" consoleLog :: JSRef a -> IO () 155 | 156 | foreign import javascript "console.warn($1)" consoleWarn :: JSRef a -> IO () 157 | 158 | foreign import javascript "console.error($1)" consoleError :: JSRef a -> IO () 159 | 160 | consoleLogText :: Text -> IO () 161 | consoleLogText = consoleLog . toJSString 162 | 163 | consoleWarnText :: Text -> IO () 164 | consoleWarnText = consoleWarn . toJSString 165 | 166 | consoleErrorText :: Text -> IO () 167 | consoleErrorText = consoleError . toJSString 168 | 169 | showExceptions :: Text -> IO a -> IO a 170 | showExceptions msg f = f `catch` \ex -> do 171 | consoleErrorText $ "Exception in " <> msg <> ": " <> tshow (ex :: SomeException) 172 | throwIO ex 173 | 174 | showAndIgnoreExceptions :: Text -> IO () -> IO () 175 | showAndIgnoreExceptions msg f = f `catch` \ex -> 176 | consoleErrorText $ 177 | "Exception ignored in " <> msg <> ": " <> tshow (ex :: SomeException) 178 | 179 | -------------------------------------------------------------------------------- 180 | -- JsonAPI helpers 181 | 182 | displayIdInfo :: IdInfo -> Text 183 | displayIdInfo (IdInfo IdProp {..} scope) = 184 | "'" <> idName <> "' " <> displayNameSpace idSpace <> " " <> 185 | case scope of 186 | Binder -> "binding" 187 | Local -> "defined locally" <> 188 | case idDefSpan of 189 | ProperSpan ss -> ", at " <> tshow ss 190 | _ -> "" 191 | Imported {..} -> 192 | "imported from " <> displayModuleId idImportedFrom <> 193 | (if idDefinedIn /= idImportedFrom 194 | then ", and defined in " <> displayModuleId idDefinedIn 195 | else "") 196 | WiredIn -> "builtin defined in " <> displayModuleId idDefinedIn 197 | 198 | displayNameSpace :: IdNameSpace -> Text 199 | displayNameSpace VarName = "value" 200 | displayNameSpace DataName = "data constructor" 201 | displayNameSpace TvName = "type variable" 202 | displayNameSpace TcClsName = "type" 203 | 204 | displayModuleId :: ModuleId -> Text 205 | displayModuleId (ModuleId mo pkg) = mo <> " (" <> displayPackageId pkg <> ")" 206 | 207 | displayPackageId :: PackageId -> Text 208 | displayPackageId (PackageId name (Just version) _pkey) = 209 | name <> "-" <> cleanPackageVersion version 210 | displayPackageId (PackageId name Nothing _pkey) = 211 | name 212 | 213 | -- | In our environment, ghc-prim / base / etc has a name like this 214 | -- 215 | -- ghc-prim-0.3.1.0-3f9f683cd77bf581e40e5d3162876874 216 | -- 217 | -- It seems like a good hack for now to just strip the "-hash" off any 218 | -- such package versions. Consider moving it to ide-backend-client? 219 | cleanPackageVersion :: Text -> Text 220 | cleanPackageVersion x@(T.stripPrefix "-" . T.takeEnd 33 -> Just hash) 221 | | T.all isHexDigit hash = T.dropEnd 33 x 222 | cleanPackageVersion x = x 223 | 224 | -- Orphans 225 | 226 | $(concat <$> mapM makePrisms [''Response, ''RunnerResponse, ''Sequenced]) 227 | 228 | deriving instance Typeable Element 229 | -------------------------------------------------------------------------------- /soh-client/src/JavaScript/Ace.hs: -------------------------------------------------------------------------------- 1 | -- | Bindings to the Ace editor. 2 | module JavaScript.Ace 3 | ( Editor 4 | -- * Construction 5 | , makeEditor 6 | -- * Queries 7 | , getValue 8 | , getSelection 9 | , getCharPosition 10 | -- * Mutations 11 | , setValue 12 | , setSelection 13 | , Editor.focus 14 | , Editor.blur 15 | , setMaxLinesInfty 16 | , MarkerId(..) 17 | , addMarker 18 | , removeMarker 19 | -- * Events 20 | , onChange 21 | , ChangeEvent(..) 22 | , onSelectionChange 23 | , addCommand 24 | -- * Auxiliary Types 25 | , Pos(..) 26 | , Range(..) 27 | , Selection(..) 28 | , selectionToRange 29 | , rangeToSelection 30 | , RangeOrdering(..) 31 | , compareRange 32 | , comparePosWithRange 33 | , DeltaPos(..) 34 | , subtractPos 35 | , shiftPos 36 | , shiftRange 37 | ) where 38 | 39 | import Control.Monad (void, join, (<=<)) 40 | import Control.Monad.Trans.Maybe (MaybeT(..)) 41 | import Data.Coerce (coerce) 42 | import Data.Text (Text) 43 | import Data.Typeable (Typeable) 44 | import GHC.Generics (Generic) 45 | import GHCJS.DOM.Types (HTMLElement(..)) 46 | import GHCJS.Foreign 47 | import GHCJS.Marshal 48 | import GHCJS.Types 49 | import Import.Util (getElement, intToJSNumber, mtprop, fromJSRefOrFail, fromJSRefOrFail') 50 | import qualified JavaScript.AceAjax.Raw.Ace as Ace 51 | import qualified JavaScript.AceAjax.Raw.Editor as Editor 52 | import qualified JavaScript.AceAjax.Raw.IEditSession as Session 53 | import qualified JavaScript.AceAjax.Raw.Selection as Selection 54 | import JavaScript.AceAjax.Raw.Types hiding (Selection, Range) 55 | import qualified JavaScript.AceAjax.Raw.VirtualRenderer as Renderer 56 | import JavaScript.JQuery (JQuery) 57 | import Prelude 58 | 59 | -------------------------------------------------------------------------------- 60 | -- Construction 61 | 62 | makeEditor :: JQuery -> IO Editor 63 | makeEditor q = do 64 | e <- join $ Ace.edit1 <$> ace <*> getElement 0 q 65 | Editor.setHighlightActiveLine e (toJSBool False) 66 | s <- Editor.session e 67 | Session.setUseWorker s (toJSBool False) 68 | Session.setMode s "ace/mode/haskell" 69 | r <- Editor.renderer e 70 | Renderer.setTheme r "ace/theme/tomorrow" 71 | Renderer.setShowGutter r (toJSBool False) 72 | Renderer.setShowPrintMargin r (toJSBool False) 73 | setGlobalEditor e 74 | return e 75 | 76 | -- Purely for development purposes 77 | foreign import javascript unsafe "window.ace_editor = $1;" 78 | setGlobalEditor :: Editor -> IO () 79 | 80 | -------------------------------------------------------------------------------- 81 | -- Queries 82 | 83 | getValue :: Editor -> IO Text 84 | getValue = fmap fromJSString . Editor.getValue 85 | 86 | getSelection :: Editor -> IO Selection 87 | getSelection editor = do 88 | s <- Editor.getSelection editor 89 | let fromRef = fromJSRefOrFail' "ace editor selection" . coerce 90 | lead <- Selection.getSelectionLead s 91 | anchor <- Selection.getSelectionAnchor s 92 | Selection <$> fromRef lead <*> fromRef anchor 93 | 94 | getCharPosition :: Editor -> Pos -> IO (Int, Int) 95 | getCharPosition editor pos = do 96 | renderer <- Editor.renderer editor 97 | obj <- Renderer.textToScreenCoordinates 98 | renderer 99 | (intToJSNumber (row pos)) 100 | (intToJSNumber (column pos)) 101 | (cx, cy) <- convertPagePosition obj 102 | (ex, ey) <- 103 | convertPagePosition =<< 104 | getPagePosition . coerce =<< 105 | Renderer.scroller renderer 106 | print (cx, cy, ex, ey) 107 | return (cx - ex, cy - ey) 108 | 109 | convertPagePosition :: JSRef obj -> IO (Int, Int) 110 | convertPagePosition obj = 111 | maybe (fail "Failed to convert page position") return =<< 112 | runMaybeT ((,) <$> mtprop obj "pageX" <*> mtprop obj "pageY") 113 | 114 | --FIXME: switch to using GHCJS.DOM.ClientRect once we update to a sufficient version 115 | -- 116 | -- foreign import unsafe "$1.getBoundingClientRect()" 117 | -- getBoundingClientRect :: Element -> IO ClientRect 118 | 119 | foreign import javascript unsafe 120 | "function(el) {\ 121 | var rect = el.getBoundingClientRect();\ 122 | return { pageX: rect.left, pageY: rect.top };\ 123 | }($1)" 124 | getPagePosition :: HTMLElement -> IO (JSRef obj) 125 | 126 | -------------------------------------------------------------------------------- 127 | -- Mutations 128 | 129 | -- Note: -1 specifies that the cursor should be moved to the end 130 | -- 131 | setValue :: Editor -> JSString -> IO () 132 | setValue e val = void $ Editor.setValue e val (intToJSNumber (-1)) 133 | 134 | setSelection :: Editor -> Selection -> IO () 135 | setSelection editor Selection{..} = do 136 | s <- Editor.getSelection editor 137 | Selection.setSelectionAnchor s 138 | (intToJSNumber (row anchor)) 139 | (intToJSNumber (column anchor)) 140 | Selection.selectTo s 141 | (intToJSNumber (row lead)) 142 | (intToJSNumber (column lead)) 143 | 144 | newtype MarkerId = MarkerId Int 145 | deriving (Eq, Show) 146 | 147 | addMarker :: Editor -> Range -> JSString -> JSString -> Bool -> IO MarkerId 148 | addMarker editor range clazz typ inFront = do 149 | s <- Editor.session editor 150 | rangeRef <- toJSRef range 151 | inFrontRef <- toJSRef inFront 152 | MarkerId <$> js_addMarker s rangeRef clazz typ inFrontRef 153 | 154 | removeMarker :: Editor -> MarkerId -> IO () 155 | removeMarker editor (MarkerId mid) = do 156 | s <- Editor.session editor 157 | js_removeMarker s mid 158 | 159 | -------------------------------------------------------------------------------- 160 | -- Event Handlers 161 | 162 | onChange :: Editor -> (ChangeEvent -> IO ()) -> IO () 163 | onChange e f = do 164 | -- TODO: open pull request on ace typescript bindings making things subtypes of EventEmitter? 165 | parent <- toJSRef =<< Editor.container e 166 | f' <- syncCallback1 (DomRetain (coerce parent)) True (f <=< fromJSRefOrFail) 167 | editorOn e "change" f' 168 | 169 | --TODO: would be cheaper to not marshal the lines and instead get 170 | --their length (at least for our purposes in this project). 171 | 172 | data ChangeEvent 173 | = InsertLines Range [JSString] 174 | | InsertText Range JSString 175 | | RemoveLines Range [JSString] Char 176 | | RemoveText Range JSString 177 | deriving (Typeable) 178 | 179 | instance FromJSRef ChangeEvent where 180 | fromJSRef obj = runMaybeT $ do 181 | inner <- mtprop obj "data" 182 | action <- mtprop inner "action" 183 | range <- mtprop inner "range" 184 | case action :: Text of 185 | "insertLines" -> InsertLines range <$> mtprop inner "lines" 186 | "insertText" -> InsertText range <$> mtprop inner "text" 187 | "removeLines" -> RemoveLines range <$> mtprop inner "lines" 188 | <*> mtprop inner "nl" 189 | "removeText" -> RemoveText range <$> mtprop inner "text" 190 | _ -> MaybeT (return Nothing) 191 | 192 | onSelectionChange :: Editor -> IO () -> IO () 193 | onSelectionChange e f = do 194 | -- TODO: open pull request on ace typescript bindings making things subtypes of EventEmitter? 195 | parent <- toJSRef =<< Editor.container e 196 | f' <- syncCallback1 (DomRetain (coerce parent)) True (\_ -> f >> return jsNull) 197 | s <- Editor.getSelection e 198 | Selection.on s "changeCursor" f' 199 | 200 | addCommand :: Editor -> JSString -> JSString -> JSString -> IO () -> IO () 201 | addCommand editor name win mac f = do 202 | cb <- asyncCallback AlwaysRetain f 203 | js_addCommand editor name win mac cb 204 | 205 | -------------------------------------------------------------------------------- 206 | -- Positions and Ranges 207 | -- 208 | -- Note: inclusion tests consider the range to be inclusive on the 209 | -- left (closed), and exclusive on the right (open). 210 | 211 | data Pos = Pos 212 | { row :: !Int 213 | , column :: !Int 214 | } 215 | deriving (Show, Eq, Ord, Generic, Typeable) 216 | 217 | data Range = Range 218 | { start :: !Pos 219 | , end :: !Pos 220 | } 221 | deriving (Show, Eq, Generic, Typeable) 222 | 223 | data Selection = Selection 224 | { anchor :: !Pos 225 | , lead :: !Pos 226 | } 227 | deriving (Show, Eq, Generic, Typeable) 228 | 229 | instance FromJSRef Pos where fromJSRef = fromJSRef_generic id 230 | instance FromJSRef Range where fromJSRef = fromJSRef_generic id 231 | instance FromJSRef Selection where fromJSRef = fromJSRef_generic id 232 | 233 | instance ToJSRef Range where 234 | toJSRef r = js_createRange 235 | (row (start r)) 236 | (column (start r)) 237 | (row (end r)) 238 | (column (end r)) 239 | 240 | selectionToRange :: Selection -> Range 241 | selectionToRange sel = 242 | if anchor sel < lead sel 243 | then Range (anchor sel) (lead sel) 244 | else Range (lead sel) (anchor sel) 245 | 246 | rangeToSelection :: Range -> Selection 247 | rangeToSelection Range {..} = Selection { anchor = start, lead = end } 248 | 249 | data RangeOrdering 250 | = Before 251 | | Intersecting 252 | | After 253 | deriving (Show, Eq, Ord, Generic, Typeable) 254 | 255 | compareRange :: Range -> Range -> RangeOrdering 256 | compareRange x y 257 | | end x < start y = Before 258 | | end y <= start x = After 259 | | otherwise = Intersecting 260 | 261 | comparePosWithRange :: Pos -> Range -> RangeOrdering 262 | comparePosWithRange pos range 263 | | pos < start range = Before 264 | | end range <= pos = Intersecting 265 | | otherwise = After 266 | 267 | data DeltaPos = DeltaPos 268 | { deltaRow :: !Int 269 | , deltaColumn :: !Int 270 | } 271 | deriving (Show, Eq, Ord, Generic, Typeable) 272 | 273 | subtractPos :: Pos -> Pos -> DeltaPos 274 | subtractPos x y = DeltaPos 275 | { deltaRow = row x - row y 276 | , deltaColumn = column x - column y 277 | } 278 | 279 | shiftPos :: DeltaPos -> Pos -> Pos 280 | shiftPos d p = Pos 281 | { row = deltaRow d + row p 282 | , column = deltaColumn d + column p 283 | } 284 | 285 | shiftRange :: DeltaPos -> Range -> Range 286 | shiftRange d r = Range 287 | { start = shiftPos d (start r) 288 | , end = shiftPos d (end r) 289 | } 290 | 291 | -------------------------------------------------------------------------------- 292 | -- FFI 293 | 294 | -- Needed due to incompleteness ghcjs-from-typescript / the typescript parser 295 | foreign import javascript unsafe "function() { return ace; }()" 296 | ace :: IO Ace 297 | 298 | -- Needed due to incompleteness of typescript ace definitions 299 | 300 | foreign import javascript unsafe "$1.on($2, $3)" 301 | editorOn :: Editor -> JSString -> JSFun (JSRef obj -> IO ()) -> IO () 302 | 303 | foreign import javascript unsafe "$1.setOption('maxLines', Infinity)" 304 | setMaxLinesInfty :: Editor -> IO () 305 | 306 | foreign import javascript unsafe "$1.addMarker($2, $3, $4, $5)" 307 | js_addMarker :: IEditSession -> JSRef Range -> JSString -> JSString -> JSRef Bool -> IO Int 308 | 309 | foreign import javascript unsafe "$1.removeMarker($2)" 310 | js_removeMarker :: IEditSession -> Int -> IO () 311 | 312 | foreign import javascript unsafe "function() { var Range = ace.require('ace/range').Range; return new Range($1,$2,$3,$4); }()" 313 | js_createRange :: Int -> Int -> Int -> Int -> IO (JSRef Range) 314 | 315 | foreign import javascript unsafe "$1.commands.addCommand({name:$2, bindKey:{win:$3, mac:$4, sender:'editor'}, exec:$5})" 316 | js_addCommand :: Editor -> JSString -> JSString -> JSString -> JSFun (IO ()) -> IO () 317 | -------------------------------------------------------------------------------- /soh-client/src/JavaScript/IFrame.hs: -------------------------------------------------------------------------------- 1 | module JavaScript.IFrame 2 | ( IFrame(..) 3 | , buildIFrame 4 | , setIFrameUrl 5 | ) where 6 | 7 | import Control.Lens (Traversal') 8 | import Control.Monad (void) 9 | import Control.Monad.IO.Class (MonadIO) 10 | import Data.Foldable (forM_) 11 | import Data.Text (Text) 12 | import Data.Typeable (Typeable) 13 | import GHCJS.DOM.Element (Element, elementSetAttribute) 14 | import GHCJS.DOM.Node (nodeAppendChild) 15 | import GHCJS.Types (JSString) 16 | import Import.Util (getElement) 17 | import React.Internal (Component, ReactT) 18 | import JavaScript.Unmanaged (Unmanaged, buildUnmanaged) 19 | import GHCJS.Marshal (ToJSRef, FromJSRef) 20 | import Prelude 21 | 22 | newtype IFrame = IFrame Element 23 | deriving (Typeable, ToJSRef, FromJSRef) 24 | 25 | buildIFrame 26 | :: MonadIO m 27 | => Component state (Unmanaged IFrame) m 28 | -> Traversal' state (Unmanaged IFrame) 29 | -> Maybe Text 30 | -> ReactT state m () 31 | buildIFrame c l murl = buildUnmanaged c l $ \_ q -> do 32 | parent <- getElement 0 q 33 | el <- createIFrame 34 | forM_ murl $ elementSetAttribute el ("src" :: JSString) 35 | void $ nodeAppendChild parent (Just el) 36 | return (IFrame el) 37 | 38 | setIFrameUrl :: IFrame -> Text -> IO () 39 | setIFrameUrl (IFrame el) = elementSetAttribute el ("src" :: JSString) 40 | 41 | foreign import javascript unsafe "document.createElement('iframe')" 42 | createIFrame :: IO Element 43 | -------------------------------------------------------------------------------- /soh-client/src/JavaScript/TermJs.hs: -------------------------------------------------------------------------------- 1 | -- | Bindings to the TermJs terminal emulator. 2 | module JavaScript.TermJs 3 | ( TermJs(..) 4 | , initTerminal 5 | , writeTerminal 6 | , onTerminalData 7 | , onTerminalDisconnect 8 | ) where 9 | 10 | import Data.Coerce (coerce) 11 | import Data.Text (Text) 12 | import Data.Typeable (Typeable) 13 | import GHCJS.DOM.HTMLElement (HTMLElement) 14 | import GHCJS.Foreign 15 | import GHCJS.Marshal 16 | import GHCJS.Types 17 | import Import.Util (getElement) 18 | import JavaScript.JQuery (JQuery) 19 | import Prelude 20 | 21 | newtype TermJs = TermJs (JSRef TermJs) 22 | deriving (ToJSRef, FromJSRef, Typeable) 23 | 24 | initTerminal :: JQuery -> IO TermJs 25 | initTerminal q = do 26 | terminal <- newTerminal 27 | openTerminal terminal =<< getElement 0 q 28 | return terminal 29 | 30 | writeTerminal :: TermJs -> Text -> IO () 31 | writeTerminal terminal = writeTerminal' terminal . toJSString 32 | 33 | onTerminalData :: TermJs -> (Text -> IO ()) -> IO () 34 | onTerminalData terminal f = do 35 | parent <- toJSRef =<< terminalParent terminal 36 | f' <- syncCallback1 (DomRetain (coerce parent)) True (f . fromJSString) 37 | onTerminalData' terminal f' 38 | 39 | onTerminalDisconnect :: TermJs -> IO () -> IO () 40 | onTerminalDisconnect terminal f = do 41 | parent <- toJSRef =<< terminalParent terminal 42 | f' <- syncCallback (DomRetain (coerce parent)) True f 43 | onTerminalDisconnect' terminal f' 44 | 45 | -------------------------------------------------------------------------------- 46 | -- FFI 47 | 48 | foreign import javascript 49 | "new Terminal({ useStyle: true, screenKeys: true })" 50 | newTerminal :: IO TermJs 51 | 52 | foreign import javascript 53 | "$1.open($2)" 54 | openTerminal :: TermJs -> HTMLElement -> IO () 55 | 56 | -- foreign import javascript 57 | -- "$1.destroy()" 58 | -- destroyTerminal :: TermJs -> IO () 59 | 60 | -- foreign import javascript 61 | -- "$1.focus()" 62 | -- focusTerminal :: TermJs -> IO () 63 | 64 | foreign import javascript 65 | "$1.write($2)" 66 | writeTerminal' :: TermJs -> JSString -> IO () 67 | 68 | foreign import javascript 69 | "$1.on('data', $2)" 70 | onTerminalData' :: TermJs -> JSFun (JSString -> IO ()) -> IO () 71 | 72 | foreign import javascript 73 | "$1.on('disconnect', $2)" 74 | onTerminalDisconnect' :: TermJs -> JSFun (IO ()) -> IO () 75 | 76 | foreign import javascript 77 | "$1.parent" 78 | terminalParent :: TermJs -> IO HTMLElement 79 | -------------------------------------------------------------------------------- /soh-client/src/JavaScript/Unmanaged.hs: -------------------------------------------------------------------------------- 1 | -- | A React component which allows direct DOM construction and has a 2 | -- single "init" property which specifies a callback for when the 3 | -- component is mounted. Particularly useful for using widgets 4 | -- written in javascript which do not have react bindings, or when 5 | -- react's model does not match well with the domain. 6 | module JavaScript.Unmanaged 7 | ( Unmanaged(..) 8 | , getDefUnmanaged 9 | , newUnmanaged 10 | , buildUnmanaged 11 | , onInitUnmanaged 12 | , getUnmanaged 13 | , getUnmanagedOrFail 14 | , readUnmanaged 15 | , readUnmanagedOrFail 16 | ) where 17 | 18 | import Control.Concurrent.STM 19 | import Control.Lens 20 | import Control.Monad.IO.Class (MonadIO) 21 | import Control.Monad.Reader (ask) 22 | import Data.Proxy (Proxy(Proxy)) 23 | import Data.Typeable (Typeable, typeRep) 24 | import GHCJS.Foreign 25 | import GHCJS.Marshal 26 | import GHCJS.Types 27 | import Import.Util (expectProp) 28 | import JavaScript.JQuery (JQuery) 29 | import Prelude 30 | import React hiding (onClick) 31 | import React.Builder (refAttr) 32 | import React.Internal 33 | 34 | newtype Unmanaged a = Unmanaged (JSRef a) 35 | 36 | instance Show (Unmanaged a) where 37 | show _ = "<>" 38 | 39 | instance Eq (Unmanaged a) where 40 | _ == _ = True 41 | 42 | getDefUnmanaged :: IO (Unmanaged a) 43 | getDefUnmanaged = return (Unmanaged jsNull) 44 | 45 | newUnmanaged 46 | :: Monad m 47 | => App state m -- ^ The app. 48 | -> IO (Component state (Unmanaged a) m) -- ^ Component. 49 | newUnmanaged app = 50 | createComponent 51 | (newClass app 52 | (return ()) 53 | (didMount app) 54 | (\_ _ -> return ()) 55 | (\_ _ -> return False) 56 | (\_ _ -> return ())) 57 | 58 | didMount 59 | :: App state m 60 | -> Traversal' state (Unmanaged a) 61 | -> JQuery 62 | -> JSRef this 63 | -> IO () 64 | didMount app r el this = do 65 | props <- expectProp this "props" 66 | f <- getProp ("init" :: JSString) props 67 | x <- invokeCallback f el 68 | release f 69 | atomically $ modifyTVar (appState app) $ set r (Unmanaged x) 70 | 71 | buildUnmanaged 72 | :: (MonadIO m, ToJSRef a) 73 | => Component state (Unmanaged a) m 74 | -> Traversal' state (Unmanaged a) 75 | -> (TVar state -> JQuery -> IO a) 76 | -> ReactT state m () 77 | buildUnmanaged c l f = buildComponent c l $ onInitUnmanaged f 78 | 79 | onInitUnmanaged :: (MonadIO m, ToJSRef a) => (TVar state -> JQuery -> IO a) -> ReactT state m () 80 | onInitUnmanaged f = do 81 | app <- ask 82 | --FIXME: use retention based on the dom root. 83 | f' <- internalLiftIOReact $ syncCallback2 AlwaysRetain True $ \obj q -> do 84 | r <- toJSRef =<< f (appState app) q 85 | -- This is necessary because the callbacks can't directly return. 86 | setProp ("result" :: JSString) r obj 87 | refAttr "init" f' 88 | 89 | getUnmanaged :: (Typeable a, FromJSRef a) => Unmanaged a -> IO (Maybe a) 90 | getUnmanaged (Unmanaged ref) = fromJSRef ref 91 | 92 | getUnmanagedOrFail :: forall a. (Typeable a, FromJSRef a) => Maybe (Unmanaged a) -> IO a 93 | getUnmanagedOrFail munmanaged = do 94 | let shownType = "(Unmanaged (" ++ show (typeRep (Proxy :: Proxy a)) ++ "))" 95 | case munmanaged of 96 | Nothing -> fail $ "getUnmanagedOrFail received 'Nothing' for " ++ shownType 97 | Just unmanaged -> do 98 | mx <- getUnmanaged unmanaged 99 | case mx of 100 | Nothing -> fail $ "Failed to view " ++ shownType 101 | Just x -> return x 102 | 103 | readUnmanaged 104 | :: (Typeable a, FromJSRef a) => TVar state -> (state -> Unmanaged a) -> IO (Maybe a) 105 | readUnmanaged stateVar f = getUnmanaged . f =<< readTVarIO stateVar 106 | 107 | readUnmanagedOrFail 108 | :: (Typeable a, FromJSRef a) => TVar state -> (state -> Maybe (Unmanaged a)) -> IO a 109 | readUnmanagedOrFail stateVar f = getUnmanagedOrFail . f =<< readTVarIO stateVar 110 | 111 | -- Better way to do this?? 112 | 113 | foreign import javascript unsafe "function() { var obj = {}; $1(obj, $2); return obj.result; }()" 114 | invokeCallback :: JSFun (JQuery -> IO ()) -> JQuery -> IO (JSRef a) 115 | -------------------------------------------------------------------------------- /soh-client/src/Main.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE OverloadedStrings #-} 2 | 3 | import Control.Concurrent 4 | import Import 5 | import Model 6 | import Model.Server 7 | import SchoolOfHaskell.Scheduler.API 8 | import View (renderControls, renderEditor) 9 | 10 | -- | Main function of the School of Haskell client. 11 | main :: IO () 12 | main = do 13 | -- Get the code elements. 14 | els <- getElementsByClassName "soh-code" 15 | -- Initialize app state 16 | app <- getApp (length els) 17 | ace <- newUnmanaged app 18 | termjs <- newUnmanaged app 19 | iframe <- newUnmanaged app 20 | -- Render the controls, if they're in a predefined div. 21 | mcontrols <- getElementById "soh-controls" 22 | mcolumn <- getElementById "soh-column" 23 | inlineControls <- case (mcontrols, mcolumn) of 24 | (Just controls, Just column) -> do 25 | void $ forkIO $ react app (renderControls termjs iframe) controls 26 | positionControlsOnResize controls column 27 | return False 28 | _ -> return True 29 | -- Substitute the code elements with editors 30 | forM_ (zip els [SnippetId 0..]) $ \(el, sid) -> do 31 | code <- getElementText el 32 | let renderer = renderEditor ace termjs iframe sid code inlineControls 33 | void $ forkIO $ react app renderer el 34 | -- Run the application 35 | #if LOCAL_SOH_RUNNER 36 | let devMappings = PortMappings [(4000, 4000), (3000, 3000)] 37 | runApp "localhost" devMappings devReceipt app 38 | #else 39 | let spec = ContainerSpec "soh-runner" 40 | -- clearContainers 41 | receipt <- createContainer spec 42 | (host, ports) <- pollForContainerAddress 60 $ 43 | getContainerDetailByReceipt receipt 44 | runApp host ports receipt app 45 | #endif 46 | 47 | -- clearContainers :: IO () 48 | -- clearContainers = do 49 | -- containers <- listContainers 50 | -- forM_ containers stopContainerById 51 | -------------------------------------------------------------------------------- /soh-client/src/Model.hs: -------------------------------------------------------------------------------- 1 | -- | This module defines the School of Haskell client's behavior. It 2 | -- handles updating the state based on responses from the backend, and 3 | module Model where 4 | 5 | import Control.Exception (catch, throwIO, SomeException) 6 | import Data.Function (on) 7 | import Data.List (partition) 8 | import qualified Data.List as L 9 | import qualified Data.Text as T 10 | import Data.Text.Encoding (encodeUtf8) 11 | import qualified Data.Vector as V 12 | import Import 13 | import qualified JavaScript.Ace as Ace 14 | import JavaScript.IFrame (setIFrameUrl) 15 | import JavaScript.TermJs (writeTerminal) 16 | import Model.Protocol 17 | import Model.Server (lookupPort) 18 | import View.PosMap (emptyPosMap, spanToRange) 19 | import React.Internal (appState) 20 | import SchoolOfHaskell.Scheduler.API (ContainerReceipt, PortMappings) 21 | 22 | -- | Given the number of snippets on the page, this creates the 23 | -- initial state and App. It needs to know the number of snippets in 24 | -- order to initialize the Ace components. 25 | getApp :: Int -> IO App 26 | getApp cnt = do 27 | snippets <- V.replicateM cnt $ do 28 | editor <- getDefUnmanaged 29 | return Snippet 30 | { _snippetEditor = editor 31 | , _snippetPosMap = emptyPosMap 32 | , _snippetTypeInfo = Nothing 33 | } 34 | termjs <- getDefUnmanaged 35 | web <- getDefUnmanaged 36 | docs <- getDefUnmanaged 37 | let state = State 38 | { _stateSnippets = snippets 39 | , _stateConsole = termjs 40 | , _stateWeb = web 41 | , _stateDocs = docs 42 | , _stateStatus = InitialStatus 43 | , _stateRunning = NotRunning 44 | , _stateTab = BuildTab 45 | , _stateBackend = Nothing 46 | } 47 | makeApp state id 48 | 49 | -- | Runs the SoH client application. 50 | runApp :: Text -> PortMappings -> ContainerReceipt -> App -> IO void 51 | runApp host ports receipt app = withUrl host ports receipt $ \backend -> do 52 | setTVarIO (appState app) stateBackend (Just backend) 53 | version <- expectWelcome backend 54 | putStrLn $ "Connection established with ide-backend " ++ show version 55 | let state = appState app 56 | br <- waitForTVarIO state (^? (stateStatus . _BuildRequested)) 57 | mainLoop backend state br `catch` \ex -> do 58 | consoleErrorText $ 59 | "Exited mainLoop with exception " <> tshow (ex :: SomeException) 60 | throwIO ex 61 | 62 | -- | This is the main loop. It takes the current 'BuildRequest', 63 | -- compiles the code, runs it, and then waits for queries or further 64 | -- build requests. These build requests re-enter this 'mainLoop' 65 | -- function. As is implied by its @IO void@ return type, it never 66 | -- returns. 67 | mainLoop 68 | :: Backend 69 | -> TVar State 70 | -> BuildRequest 71 | -> IO void 72 | mainLoop backend state br = do 73 | (sid, bi) <- compileCode backend state br 74 | -- Kill the running process, if there is one. 75 | killProcess backend state sid bi 76 | when (buildSuccess bi) $ runConsole backend state 77 | br' <- runQueries backend state 78 | mainLoop backend state br' 79 | 80 | -- | Whether there are no errors in a 'BuildInfo'. 81 | buildSuccess :: BuildInfo -> Bool 82 | buildSuccess bi = null (buildErrors bi) && null (buildServerDieds bi) 83 | 84 | -- | Compiles a set of files and retrieves the resulting error 85 | -- / warning messages. 86 | compileCode 87 | :: Backend 88 | -> TVar State 89 | -> BuildRequest 90 | -> IO (SnippetId, BuildInfo) 91 | compileCode backend state (BuildRequest sid files) = do 92 | -- TODO: clear ide-backend state before the rest of the updates? 93 | let requestUpdate (fp, txt) = RequestUpdateSourceFile fp $ 94 | ByteString64 (encodeUtf8 txt) 95 | -- Show the build's progress and wait for it to finish. 96 | updateSession backend 97 | (map requestUpdate files) 98 | (setTVarIO state stateStatus . Building sid) 99 | -- Retrieve the errors 100 | sourceErrors <- getSourceErrors backend 101 | let partitionKind k = partition ((==k) . errorKind) 102 | let (errors, partitionKind KindWarning -> (warnings, serverDieds)) = 103 | partitionKind KindError sourceErrors 104 | buildInfo = BuildInfo 105 | { buildErrors = errors 106 | , buildWarnings = warnings 107 | , buildServerDieds = serverDieds 108 | } 109 | setTVarIO state stateStatus $ Built sid buildInfo 110 | when (not (buildSuccess buildInfo)) $ switchTab state BuildTab 111 | return (sid, buildInfo) 112 | 113 | -- | Runs the user's program and directs stdout to the console. 114 | runConsole :: Backend -> TVar State -> IO () 115 | runConsole backend state = do 116 | switchTab state ConsoleTab 117 | port <- requestOpenPort backend 118 | -- TODO: Other env variables from old SoH? APPROOT, 119 | -- FP_ENVIRONMENT_NAME, FP_ENVIRONMENT_TYPE, IMAGE_DIR, and etc 120 | updateSession backend 121 | [RequestUpdateEnv [("PORT", Just (show port))]] 122 | (\_ -> return ()) 123 | let appendConsole x = do 124 | terminal' <- readUnmanagedOrFail state (^? stateConsole) 125 | writeTerminal terminal' x 126 | setProcessHandler backend $ \case 127 | ProcessOutput output -> appendConsole (T.pack output) 128 | ProcessDone result -> do 129 | setTVarIO state stateRunning NotRunning 130 | appendConsole $ "\r\nProcess done: " <> T.pack (show result) <> "\r\n" 131 | ProcessListening -> do 132 | webFrame <- readUnmanagedOrFail state (^? stateWeb) 133 | let url = "http://" <> backendHost backend <> ":" <> tshow port 134 | setIFrameUrl webFrame url 135 | switchTab state WebTab 136 | requestRun backend "Main" "main" 137 | setTVarIO state stateRunning Running 138 | requestPortListening backend port 139 | 140 | -- | Waits for queries and performs them. Once a build is requested 141 | -- this stops waiting for queries and yields the 'BuildRequest'. 142 | runQueries :: Backend -> TVar State -> IO BuildRequest 143 | runQueries backend state = do 144 | req <- waitForUserRequest 145 | case req of 146 | Left br -> return br 147 | Right (sid, QueryInfo ss) -> do 148 | runDocQuery backend state ss 149 | mtys <- runTypeQuery backend state sid ss 150 | update $ set (ixSnippet sid . snippetTypeInfo) mtys 151 | -- TODO: allow the client to restrict their stack-ide request to 152 | -- just the innermost type info. 153 | runQueries backend state 154 | where 155 | waitForUserRequest :: IO (Either BuildRequest (SnippetId, Query)) 156 | waitForUserRequest = waitForTVarIO state $ \s -> 157 | case s ^. stateStatus of 158 | BuildRequested br -> Just (Left br) 159 | QueryRequested sid _ query -> Just (Right (sid, query)) 160 | _ -> Nothing 161 | update :: (State -> State) -> IO () 162 | update f = modifyTVarIO state id $ over stateStatus backToIdle . f 163 | backToIdle :: Status -> Status 164 | backToIdle (QueryRequested sid info _) = Built sid info 165 | backToIdle x = x 166 | 167 | runDocQuery :: Backend -> TVar State -> SourceSpan -> IO () 168 | runDocQuery backend state ss = do 169 | infos <- getSpanInfo backend ss 170 | navigateDoc state $ 171 | fmap (\(ResponseSpanInfo si _) -> getIdInfo si) 172 | (listToMaybe infos) 173 | where 174 | getIdInfo (SpanId x) = x 175 | getIdInfo (SpanQQ x) = x 176 | 177 | runTypeQuery :: Backend -> TVar State -> SnippetId -> SourceSpan -> IO (Maybe ([ResponseAnnExpType], Int, Int, Ace.MarkerId)) 178 | runTypeQuery backend state sid ss = do 179 | tys <- getAnnExpTypes backend ss 180 | addTyPos $ 181 | listToMaybe $ 182 | L.groupBy ((==) `on` (\(ResponseAnnExpType _ ss') -> ss')) tys 183 | where 184 | addTyPos Nothing = return Nothing 185 | addTyPos (Just []) = return Nothing 186 | addTyPos (Just tys@((ResponseAnnExpType _ ss'):_)) = do 187 | -- FIXME: shouldn't have all this view-gnostic stuff.. 188 | s <- atomically $ readTVar state 189 | -- Note: this means that if there has been an edit within the 190 | -- span, then the type info won't show for it. Lets see if this 191 | -- is bothersome. 192 | forM (spanToRange s sid ss') $ \range -> do 193 | editor <- getEditor s sid 194 | removeOldTypeInfoMarker s sid editor 195 | -- Add new highlight marker. 196 | mid <- Ace.addMarker editor range "type-info-expr" "info" False 197 | -- Compute its positioning. 198 | (sx, _) <- Ace.getCharPosition editor (Ace.start range) 199 | (_, y) <- Ace.getCharPosition editor (Ace.end range) 200 | let multiline = Ace.row (Ace.start range) /= Ace.row (Ace.end range) 201 | x = if multiline then 12 else sx 202 | return (tys, x, y + 12, mid) 203 | 204 | -- | Send process kill request, and wait for the process to stop. 205 | -- 206 | -- Note: the status on invoking this ought to be 'Built'. The fields 207 | -- of 'Built' are passed in to the function. 208 | killProcess :: Backend -> TVar State -> SnippetId -> BuildInfo -> IO () 209 | killProcess backend state sid bi = do 210 | sendProcessKill backend 211 | setTVarIO state stateStatus (KillRequested sid bi) 212 | waitForTVarIO state $ \s -> 213 | if (s ^. stateRunning) == NotRunning then Just () else Nothing 214 | setTVarIO state stateStatus (Built sid bi) 215 | 216 | -------------------------------------------------------------------------------- 217 | -- Mutation functions invoked by View (a.k.a. "the controller") 218 | 219 | runSnippetCode :: TVar State -> SnippetId -> IO () 220 | runSnippetCode state sid = do 221 | editor <- readEditor state sid 222 | code <- Ace.getValue editor 223 | runCode state (BuildRequest sid [("main.hs", code)]) 224 | 225 | -- | Runs the user's code. 226 | runCode :: TVar State -> BuildRequest -> IO () 227 | runCode state br@(BuildRequest sid _) = atomically $ modifyTVar state $ \s -> 228 | case s ^. stateStatus of 229 | Building _ _ -> s 230 | _ -> s & stateStatus .~ BuildRequested br 231 | & (ixSnippet sid . snippetPosMap) .~ emptyPosMap 232 | 233 | -- | Runs a query. 234 | runQuery :: TVar State -> SnippetId -> Query -> IO () 235 | runQuery state sid query = 236 | modifyTVarIO state stateStatus $ \oldStatus -> 237 | case oldStatus of 238 | -- TODO: Consider whether we want some other behavior when a 239 | -- query is requested for a non-current snippet. Seems like we 240 | -- should let the user know why the query isn't being performed. 241 | Built sid' info | sid' == sid -> QueryRequested sid info query 242 | _ -> oldStatus 243 | 244 | clearTypeInfo :: TVar State -> SnippetId -> IO () 245 | clearTypeInfo state sid = do 246 | s <- atomically $ readTVar state 247 | removeOldTypeInfoMarker s sid =<< getEditor s sid 248 | atomically $ modifyTVar state (ixSnippet sid . snippetTypeInfo .~ Nothing) 249 | 250 | -- | Remove the old highlight marker if there is one. 251 | removeOldTypeInfoMarker :: State -> SnippetId -> Ace.Editor -> IO () 252 | removeOldTypeInfoMarker s sid editor = do 253 | let moldInfo = s ^? ixSnippet sid . snippetTypeInfo . _Just 254 | case moldInfo of 255 | Nothing -> return () 256 | Just (_, _, _, mid) -> Ace.removeMarker editor mid 257 | 258 | -- | Sets the id-info which the haddock iframe should use for its url. 259 | navigateDoc :: TVar State -> Maybe IdInfo -> IO () 260 | navigateDoc state minfo = do 261 | docs <- readUnmanagedOrFail state (^? stateDocs) 262 | setIFrameUrl docs (fromMaybe noDocsUrl (hackageLink =<< minfo)) 263 | 264 | hackageLink :: IdInfo -> Maybe Text 265 | hackageLink (IdInfo IdProp{..} idScope) = 266 | if idScope == Binder || idScope == Local 267 | then Nothing 268 | else Just $ 269 | "http://hackage.haskell.org/package/" <> 270 | packageName <> 271 | maybe "" ("-" <>) (fmap cleanPackageVersion packageVersion) <> 272 | "/docs/" <> 273 | dotToDash moduleName <> 274 | ".html#" <> 275 | haddockSpaceMarks idSpace <> 276 | ":" <> 277 | idName 278 | where 279 | ModuleId {..} = fromMaybe idDefinedIn idHomeModule 280 | PackageId {..} = modulePackage 281 | dotToDash = T.map (\c -> if c == '.' then '-' else c) 282 | 283 | -- | Show approximately what Haddock adds to documentation URLs. 284 | haddockSpaceMarks :: IdNameSpace -> Text 285 | haddockSpaceMarks VarName = "v" 286 | haddockSpaceMarks DataName = "v" 287 | haddockSpaceMarks TvName = "t" 288 | haddockSpaceMarks TcClsName = "t" 289 | 290 | -- | Switches which tab is currently focused. 291 | switchTab :: TVar State -> Tab -> IO () 292 | switchTab state = setTVarIO state stateTab 293 | 294 | -- | Reset the UI's status as if compilation hadn't yet happened. 295 | closeControls :: TVar State -> IO () 296 | closeControls stateVar = setTVarIO stateVar stateStatus InitialStatus 297 | -------------------------------------------------------------------------------- /soh-client/src/Model/Protocol.hs: -------------------------------------------------------------------------------- 1 | -- | This module provides an API for interacting with 2 | -- stack-ide over the websocket interface provided by soh-runner. 3 | -- 4 | -- This API wraps up the different patterns of sending requests and 5 | -- expecting responses, such that the code which uses it mostly does 6 | -- not need to worry about violating any protocol invariants. 7 | -- 8 | -- The only current invariant that needs to be preserved is that all 9 | -- the functions which expect a response can't be executed 10 | -- concurrently. In particular, this applies to all of the queries, 11 | -- 'updateSession', and 'expectWelcome'. Process starting, stdin, 12 | -- stdout, and killing can all be done concurrently. 13 | -- 14 | -- In the future, a runtime check for this might be added. However, 15 | -- for now this is enforced by the single-threaded nature of "Model". 16 | module Model.Protocol 17 | ( Backend 18 | , withUrl 19 | -- * Commands 20 | , updateSession 21 | , requestRun 22 | -- * Queries 23 | , getSourceErrors 24 | , getSpanInfo 25 | , getExpTypes 26 | , getAnnExpTypes 27 | -- * Process IO 28 | , setProcessHandler 29 | , sendProcessInput 30 | , sendProcessKill 31 | -- * Misc 32 | , expectWelcome 33 | -- * Runner commands 34 | , requestOpenPort 35 | , requestPortListening 36 | ) where 37 | 38 | import Control.Concurrent.Async (race) 39 | import Control.Concurrent.STM 40 | import Data.Aeson (ToJSON, FromJSON, eitherDecodeStrict, encode) 41 | import Data.ByteString.Lazy (toStrict) 42 | import Data.Function (fix) 43 | import Data.IORef 44 | import Data.Text.Encoding (encodeUtf8, decodeUtf8) 45 | import qualified Data.UUID.Types as UUID 46 | import Data.Void (absurd) 47 | import Import 48 | import qualified JavaScript.WebSockets as WS 49 | import Model.Server (lookupPort) 50 | import SchoolOfHaskell.Runner.API 51 | import SchoolOfHaskell.Scheduler.API 52 | 53 | -- | Given the URL of the SoH container, this creates a websockets 54 | -- connection to it. 55 | withUrl :: Text -> PortMappings -> ContainerReceipt -> (Backend -> IO a) -> IO a 56 | withUrl backendHost backendPortMappings (ContainerReceipt uuid) f = 57 | let port = lookupPort defaultBackendPort backendPortMappings 58 | url = "ws://" <> backendHost <> ":" <> tshow port in 59 | WS.withUrl url $ \conn -> do 60 | -- Send the receipt to the backend. If it's rejected, then an 61 | -- exception is thrown. 62 | let receiptText = decodeUtf8 (UUID.toASCIIBytes uuid) 63 | sendJson conn (RunnerRequestAuth receiptText) 64 | authResponse <- receiveJson conn 65 | case authResponse of 66 | RunnerResponseAuthSuccess -> return () 67 | _ -> fail "Didn't receive expected authentication success from runner." 68 | -- Initialize state of the 'Backend' type, and fork off threads for 69 | -- handling communication with the backend. 70 | backendRequestChan <- newTChanIO 71 | backendResponseChan <- newTChanIO 72 | backendProcessHandler <- newIORef $ \_ -> 73 | consoleWarnText "backendProcessHandler not yet set" 74 | let sendThread = showExceptions "sendThread" $ forever $ 75 | atomically (readTChan backendRequestChan) >>= sendJson conn 76 | receiveThread = showExceptions "receiveThread" $ forever $ do 77 | response <- receiveJson conn 78 | let enqueueResponse = atomically (writeTChan backendResponseChan response) 79 | case response of 80 | RunnerResponseAuthSuccess -> 81 | fail "Didn't expect to receive auth response while running" 82 | RunnerResponseAuthFailure -> 83 | fail "Didn't expect to receive auth response while running" 84 | RunnerResponsePortIsListening -> 85 | readIORef backendProcessHandler >>= ($ ProcessListening) 86 | RunnerResponseOpenPort {} -> 87 | enqueueResponse 88 | RunnerResponseClient (NoSeq response') -> 89 | case response' of 90 | ResponseProcessOutput bs -> 91 | readIORef backendProcessHandler >>= ($ ProcessOutput bs) 92 | ResponseProcessDone rr -> 93 | readIORef backendProcessHandler >>= ($ ProcessDone rr) 94 | -- This is expected to happen due to always requesting 95 | -- kill before running. 96 | ResponseNoProcessError -> 97 | consoleWarnText "No running process" 98 | ResponseLog msg -> 99 | consoleLogText msg 100 | _ -> 101 | enqueueResponse 102 | RunnerResponseClient HasSeq{} -> 103 | consoleErrorText "Didn't expect sequenced response from server." 104 | result <- receiveThread `race` sendThread `race` f Backend {..} 105 | case result of 106 | Left (Left x) -> absurd x 107 | Left (Right x) -> absurd x 108 | Right x -> return x 109 | 110 | -------------------------------------------------------------------------------- 111 | -- Commands 112 | 113 | -- | Sends updates to the backend. The backend will send back 114 | -- progress updates until it finishes compilation. These progress 115 | -- updates are provided to the callback function. Once compilation is 116 | -- finished, 'Nothing' is sent to the callback and this function 117 | -- returns. 118 | updateSession :: Backend -> [RequestSessionUpdate] -> (UpdateStatus -> IO ()) -> IO () 119 | updateSession backend updates f = do 120 | sendRequest backend (RequestUpdateSession updates) 121 | fix $ \loop -> do 122 | x <- expectResponse backend 123 | (^? _RunnerResponseClient . _NoSeq . _ResponseUpdateSession) 124 | "ResponseUpdateSession" 125 | f x 126 | case x of 127 | UpdateStatusProgress _ -> loop 128 | _ -> return () 129 | 130 | -- | Requests that the backend run the user's code. The module nad 131 | -- identifier to run are taken as parameters. 132 | requestRun :: Backend -> ModuleName -> Identifier -> IO () 133 | requestRun backend mn ident = sendRequest backend $ RequestRun True mn ident 134 | 135 | -------------------------------------------------------------------------------- 136 | -- Queries 137 | 138 | -- | Gets the source errors of the last compilation. 139 | getSourceErrors :: Backend -> IO [SourceError] 140 | getSourceErrors backend = 141 | queryBackend backend 142 | RequestGetSourceErrors 143 | _ResponseGetSourceErrors 144 | "ResponseGetSourceErrors" 145 | 146 | -- | Gets the span info of the last __error-free__ compile. Span info 147 | -- tells you where an identifier came from. 148 | getSpanInfo :: Backend -> SourceSpan -> IO [ResponseSpanInfo] 149 | getSpanInfo backend ss = 150 | queryBackend backend 151 | (RequestGetSpanInfo ss) 152 | _ResponseGetSpanInfo 153 | "ResponseGetSpanInfo" 154 | 155 | -- | Gets the type info of the last __error-free__ compile. This 156 | -- tells you the type info 157 | getExpTypes :: Backend -> SourceSpan -> IO [ResponseExpType] 158 | getExpTypes backend ss = 159 | queryBackend backend 160 | (RequestGetExpTypes ss) 161 | _ResponseGetExpTypes 162 | "ResponseGetExpTypes" 163 | 164 | -- | Gets the annotated type info of the last __error-free__ compile. These 165 | -- annotations add identifier info to the type info, so that doc links 166 | -- can be provided in the type info. 167 | getAnnExpTypes :: Backend -> SourceSpan -> IO [ResponseAnnExpType] 168 | getAnnExpTypes backend ss = 169 | queryBackend backend 170 | (RequestGetAnnExpTypes ss) 171 | _ResponseGetAnnExpTypes 172 | "ResponseGetAnnExpTypes" 173 | 174 | -- Pattern of sending a request and expecting a response, common to 175 | -- the queries above. 176 | queryBackend :: Backend -> Request -> Prism' Response a -> String -> IO a 177 | queryBackend backend request p expected = do 178 | sendRequest backend request 179 | expectResponse backend (^? _RunnerResponseClient . _NoSeq . p) expected 180 | 181 | -------------------------------------------------------------------------------- 182 | -- Process IO 183 | 184 | -- | Sets the callback which is used to handle process output. Stdout 185 | -- is provided as 'Right' values, and the 'Left' values let you know 186 | -- that the process exited. 187 | setProcessHandler :: Backend -> (ProcessOutput -> IO ()) -> IO () 188 | setProcessHandler = atomicWriteIORef . backendProcessHandler 189 | 190 | -- | Sends stdin to the process. 191 | sendProcessInput :: Backend -> String -> IO () 192 | sendProcessInput backend = sendRequest backend . RequestProcessInput 193 | 194 | -- | Sends a SIGINT signal to the process, equivalent of using Ctrl-C. 195 | sendProcessKill :: Backend -> IO () 196 | sendProcessKill backend = sendRequest backend RequestProcessKill 197 | 198 | -------------------------------------------------------------------------------- 199 | -- Misc 200 | 201 | -- | Expects the welcome message which is sent by stack-ide once the 202 | -- connection is established. 203 | expectWelcome :: Backend -> IO VersionInfo 204 | expectWelcome backend = 205 | expectResponse backend (^? _RunnerResponseClient . _NoSeq . _ResponseWelcome) "ResponseWelcome" 206 | 207 | -------------------------------------------------------------------------------- 208 | -- SoH Runner Commands 209 | 210 | requestOpenPort :: Backend -> IO Int 211 | requestOpenPort backend = do 212 | sendRequest' backend RunnerRequestOpenPort 213 | expectResponse backend (^? _RunnerResponseOpenPort) "RunnerResponseOpenPort" 214 | 215 | requestPortListening :: Backend -> Int -> IO () 216 | requestPortListening backend = sendRequest' backend . RunnerRequestPortListening 217 | 218 | -------------------------------------------------------------------------------- 219 | -- Backend IO 220 | 221 | sendRequest :: Backend -> Request -> IO () 222 | sendRequest backend = sendRequest' backend . RunnerRequestClient . NoSeq 223 | 224 | sendRequest' :: Backend -> RunnerRequest -> IO () 225 | sendRequest' backend = atomically . writeTChan (backendRequestChan backend) 226 | 227 | receiveResponse :: Backend -> IO RunnerResponse 228 | receiveResponse = atomically . readTChan . backendResponseChan 229 | 230 | expectResponse :: Backend -> (RunnerResponse -> Maybe a) -> String -> IO a 231 | expectResponse backend f expected = do 232 | response <- receiveResponse backend 233 | case f response of 234 | Nothing -> fail $ 235 | "Protocol error: expected " ++ expected ++ 236 | " instead of " ++ show response 237 | Just x -> return x 238 | 239 | -------------------------------------------------------------------------------- 240 | -- Sending and receiving JSON 241 | 242 | -- TODO: fewer conversions... 243 | sendJson :: ToJSON a => WS.Connection -> a -> IO () 244 | sendJson conn = sendText conn . decodeUtf8 . toStrict . encode 245 | 246 | sendText :: WS.Connection -> Text -> IO () 247 | sendText conn req = do 248 | connected <- WS.sendText conn req 249 | when (not connected) $ fail "Websocket disconnected" 250 | 251 | receiveJson :: FromJSON a => WS.Connection -> IO a 252 | receiveJson conn = do 253 | t <- WS.receiveText conn 254 | case eitherDecodeStrict (encodeUtf8 t) of 255 | Left err -> fail $ "JSON decode error: " ++ err 256 | Right x -> return x 257 | -------------------------------------------------------------------------------- /soh-client/src/Model/Server.hs: -------------------------------------------------------------------------------- 1 | -- | Handles communicating with the server, to initialize the SoH 2 | -- container. 3 | module Model.Server 4 | ( listContainers 5 | , createContainer 6 | , getContainerDetailById 7 | , getContainerDetailByReceipt 8 | , stopContainerById 9 | , stopContainerByReceipt 10 | , pollForContainerAddress 11 | , schedulerHost 12 | , lookupPort 13 | , AjaxException(..) 14 | ) where 15 | 16 | import Control.Concurrent (threadDelay) 17 | import Control.Exception (throwIO, Exception) 18 | import Control.Lens 19 | import qualified Data.Aeson as Aeson 20 | import Data.ByteString.Lazy (toStrict) 21 | import Data.List (find) 22 | import Data.Text (pack) 23 | import Data.Text.Encoding (encodeUtf8, decodeUtf8) 24 | import Data.Typeable (Typeable) 25 | import qualified Data.UUID.Types as UUID 26 | import Import 27 | import qualified JavaScript.JQuery as JQ 28 | import qualified JavaScript.JQuery.Internal as JQ 29 | import SchoolOfHaskell.Scheduler.API 30 | 31 | listContainers :: IO [ContainerId] 32 | listContainers = 33 | sendRequestJsonResponse "containers" "" JQ.GET 34 | 35 | createContainer :: ContainerSpec -> IO ContainerReceipt 36 | createContainer spec = 37 | sendRequestJsonResponse "containers" (encode spec) JQ.POST 38 | 39 | getContainerDetail :: Text -> IO ContainerDetail 40 | getContainerDetail k = 41 | sendRequestJsonResponse ("containers/" <> encodeURIComponent k) "" JQ.GET 42 | 43 | stopContainer :: Text -> IO () 44 | stopContainer k = 45 | sendRequestJsonResponse ("containers/" <> encodeURIComponent k) "" JQ.DELETE 46 | 47 | getContainerDetailById :: ContainerId -> IO ContainerDetail 48 | getContainerDetailById cid = 49 | getContainerDetail (cid ^. ciID) 50 | 51 | getContainerDetailByReceipt :: ContainerReceipt -> IO ContainerDetail 52 | getContainerDetailByReceipt cr = 53 | getContainerDetail (pack (UUID.toString (cr ^. crID))) 54 | 55 | stopContainerById :: ContainerId -> IO () 56 | stopContainerById cid = 57 | stopContainer (cid ^. ciID) 58 | 59 | stopContainerByReceipt :: ContainerReceipt -> IO () 60 | stopContainerByReceipt cr = 61 | stopContainer (pack (UUID.toString (cr ^. crID))) 62 | 63 | pollForContainerAddress :: Int -> IO ContainerDetail -> IO (Text, PortMappings) 64 | pollForContainerAddress n getContainer 65 | | n <= 0 = fail "Ran out of retries while initializing soh-runner container" 66 | | otherwise = do 67 | detail <- getContainer 68 | case detail ^. cdAddress of 69 | Nothing -> do 70 | -- Container is pending - wait a bit and try again. 71 | threadDelay (1000 * 1000) 72 | pollForContainerAddress (n - 1) getContainer 73 | Just address -> return address 74 | 75 | -- TODO: allow page to determine scheduler Host. 76 | -- | isNull schedulerHost' || isUndefined schedulerHost' = 77 | -- | otherwise = Just (fromJSString schedulerHost') 78 | -- 79 | -- foreign import javascript unsafe 80 | -- "window['schedulerHost']" 81 | -- schedulerHost' :: JSString 82 | 83 | -- FIXME: when looking up the backend port, there is no reasonable 84 | -- recovery if it isn't in the association list. So, once we have 85 | -- logic for connection retry, this will need to be a variety of 86 | -- exception which aborts retry. 87 | -- 88 | -- (Not a big deal though, it shouldn't occur). 89 | lookupPort :: Int -> PortMappings -> Int 90 | lookupPort innerPort (PortMappings xs) = 91 | fromMaybe (error ("Couldn't find port mapping for " ++ show innerPort)) 92 | (snd <$> find ((innerPort ==) . fst) xs) 93 | 94 | 95 | sendRequestJsonResponse :: Aeson.FromJSON a => Text -> JSString -> JQ.Method -> IO a 96 | sendRequestJsonResponse route body method = 97 | decode <$> sendRequest route body method 98 | 99 | sendRequest :: Text -> JSString -> JQ.Method -> IO JSString 100 | sendRequest route body method = 101 | ajax (schedulerHost <> "/" <> route) body settings 102 | where 103 | settings = JQ.AjaxSettings 104 | { JQ.asContentType = "application/json" 105 | , JQ.asCache = False 106 | , JQ.asIfModified = False 107 | , JQ.asMethod = method 108 | } 109 | 110 | encode :: Aeson.ToJSON a => a -> JSString 111 | encode = toJSString . decodeUtf8 . toStrict . Aeson.encode 112 | 113 | decode :: Aeson.FromJSON a => JSString -> a 114 | decode s = 115 | case Aeson.eitherDecodeStrict (encodeUtf8 (fromJSString s)) of 116 | Left e -> error e 117 | Right x -> x 118 | 119 | -- Copied from ghcjs-jquery with the following modifications: 120 | -- 121 | -- * Throws errors when status is >= 300. 122 | -- 123 | -- * Uses JSStrings instead of converting to and from Text. 124 | -- 125 | -- * Sends a raw body rather than parameters. 126 | -- 127 | -- * 'Accept' : 'application/json' 128 | -- 129 | ajax :: Text -> JSString -> JQ.AjaxSettings -> IO JSString 130 | ajax url d s = do 131 | os <- toJSRef s 132 | setProp ("data"::JSString) d os 133 | setProp ("processData"::JSString) (toJSBool False) os 134 | headers <- newObj 135 | setProp ("headers"::JSString) headers os 136 | setProp ("Accept"::JSString) ("application/json"::JSString) headers 137 | arr <- JQ.jq_ajax (toJSString url) os 138 | status <- fromMaybe 0 <$> (fromJSRef =<< getProp ("status"::JSString) arr) 139 | if status >= 300 140 | then do 141 | statusTextRef <- getProp ("statusText"::JSString) arr 142 | -- NOTE: without these checks, I get weird runtime exceptions 143 | -- for some statuses (e.g. 502).. 144 | statusText <- if isNull statusTextRef || isUndefined statusTextRef 145 | then return "" 146 | else fromMaybe "" <$> fromJSRef statusTextRef 147 | throwIO (AjaxException status statusText) 148 | else getProp ("data"::JSString) arr 149 | 150 | data AjaxException = AjaxException 151 | { aeStatus :: Int 152 | , aeStatusText :: Text 153 | } deriving (Show, Typeable) 154 | 155 | instance Exception AjaxException 156 | 157 | encodeURIComponent :: Text -> Text 158 | encodeURIComponent = fromJSString . encodeURIComponent' . toJSString 159 | 160 | foreign import javascript unsafe "encodeURIComponent" 161 | encodeURIComponent' :: JSString -> JSString 162 | -------------------------------------------------------------------------------- /soh-client/src/Types.hs: -------------------------------------------------------------------------------- 1 | module Types where 2 | 3 | import Control.Concurrent.STM (TChan) 4 | import Control.Lens (makeLenses, makePrisms, makeWrapped) 5 | import Data.IORef (IORef) 6 | import Data.Text (Text) 7 | import Data.Typeable (Typeable) 8 | import Data.Vector (Vector) 9 | import IdeSession.Types.Public 10 | import JavaScript.Ace (Editor, Range, MarkerId) 11 | import JavaScript.IFrame 12 | import JavaScript.TermJs (TermJs) 13 | import JavaScript.Unmanaged (Unmanaged) 14 | import Prelude 15 | import SchoolOfHaskell.Runner.API (RunnerRequest, RunnerResponse) 16 | import SchoolOfHaskell.Scheduler.API (PortMappings) 17 | import Stack.Ide.JsonAPI 18 | 19 | -- | The application state. Ideally, this would entirely consist of 20 | -- pure data. 21 | -- 22 | -- However, for simplicity and efficiency it also contains some 23 | -- references to mutable javascript objects. These fields, currently 24 | -- those which involve 'Unmanaged' and 'Backend', are mutated on 25 | -- initialization, and otherwise always point to the same object. 26 | data State = State 27 | { _stateSnippets :: !(Vector Snippet) 28 | -- ^ State of the code editors. 29 | , _stateConsole :: !(Unmanaged TermJs) 30 | -- ^ TermJs console component, used for interacting with processes 31 | -- and GHCI. 32 | , _stateWeb :: !(Unmanaged IFrame) 33 | -- ^ IFrame component used for snippet web output. 34 | , _stateDocs :: !(Unmanaged IFrame) 35 | -- ^ IFrame component used for browsing documentation. 36 | , _stateStatus :: !Status 37 | -- ^ Status of the backend - whether it's building, idle, or 38 | -- performing a query. 39 | , _stateRunning :: !Running 40 | -- ^ Whether or not a process is running. 41 | , _stateTab :: !Tab 42 | -- ^ Which tab is currently focused. 43 | , _stateBackend :: !(Maybe Backend) 44 | -- ^ Connection to the backend, used for compiling code, running 45 | -- it, and querying information about it. 46 | } deriving (Eq, Show, Typeable) 47 | 48 | -- | State of a SoH editor. 49 | data Snippet = Snippet 50 | { _snippetEditor :: !(Unmanaged Editor) 51 | -- ^ Ace editor component for this snippet. 52 | , _snippetPosMap :: !PosMap 53 | -- ^ Tracks how edits affect source positions, since the last 54 | -- compile. 55 | , _snippetTypeInfo :: !(Maybe ([ResponseAnnExpType], Int, Int, MarkerId)) 56 | -- ^ When this is set to a 'Just' value, type info is displayed 57 | -- inline in the snippet. 58 | } deriving (Eq, Show, Typeable) 59 | 60 | data Status 61 | = InitialStatus 62 | -- ^ Initial status, before code has been compiled. 63 | | BuildRequested !BuildRequest 64 | -- ^ A build has been requested. 'Model.mainLoop' / 65 | -- 'Model.runQueries' waits for this status and then sends off the 66 | -- request to the backend. 67 | | Building !SnippetId !UpdateStatus 68 | -- ^ This indicates progress on the build, which occurs after the 69 | -- build request. 70 | | Built !SnippetId !BuildInfo 71 | -- ^ Once the build completes, we get the build info (errors and 72 | -- warnings). 73 | | QueryRequested !SnippetId !BuildInfo !Query 74 | -- ^ After the build has completed, 'Model.runQueries' waits for 75 | -- this status, and then sends the query to the backend. 76 | | KillRequested !SnippetId !BuildInfo 77 | -- ^ Status while we're waiting for the process to be killed. 78 | deriving (Eq, Show, Typeable) 79 | 80 | -- | Errors and warnings which result from a compile. 81 | data BuildInfo = BuildInfo 82 | { buildErrors :: ![SourceError] 83 | , buildWarnings :: ![SourceError] 84 | , buildServerDieds :: ![SourceError] 85 | } 86 | deriving (Eq, Show, Typeable) 87 | 88 | -- | Query to be sent to the backend. 89 | data Query 90 | = QueryInfo !SourceSpan 91 | deriving (Eq, Show, Typeable) 92 | 93 | -- | Whether or not the backend is currently running a process. 94 | data Running 95 | = NotRunning 96 | | Running 97 | deriving (Eq, Show, Typeable) 98 | 99 | -- | Identifiers for the different tabs in the controls. 100 | data Tab 101 | = BuildTab 102 | | ConsoleTab 103 | | DocsTab 104 | | WebTab 105 | deriving (Eq, Show, Typeable) 106 | 107 | -- | A request to build a set of files. 108 | data BuildRequest = BuildRequest !SnippetId [(FilePath, Text)] 109 | deriving (Eq, Show, Typeable) 110 | 111 | -- | State of the connection with the backend. See "Model.Protocol". 112 | data Backend = Backend 113 | { backendHost :: Text 114 | , backendPortMappings :: PortMappings 115 | , backendRequestChan :: TChan RunnerRequest 116 | , backendResponseChan :: TChan RunnerResponse 117 | , backendProcessHandler :: IORef (ProcessOutput -> IO ()) 118 | } deriving (Typeable) 119 | 120 | instance Eq Backend where 121 | _ == _ = True 122 | 123 | instance Show Backend where 124 | showsPrec _ _ = showString "Backend conn waiter" 125 | 126 | data ProcessOutput 127 | = ProcessOutput String 128 | | ProcessDone RunResult 129 | | ProcessListening 130 | 131 | -- | Stores a list of source code location changes. See "View.PosMap". 132 | -- 133 | -- Note: Newer changes are towards the front of the list. 134 | newtype PosMap = PosMap { unPosMap :: [PosChange] } 135 | deriving (Eq, Show, Typeable) 136 | 137 | -- | Stores the position change caused by a text replacement. 138 | data PosChange = PosChange 139 | { oldRange :: !Range 140 | -- ^ Range of the deleted text, in the old state of the buffer. 141 | , newRange :: !Range 142 | -- ^ Range of the added text, in the new state of the buffer. 143 | } 144 | deriving (Eq, Show, Typeable) 145 | 146 | -- | ID of a snippet - each snippet in the page is given an ID 147 | -- number. 148 | newtype SnippetId = SnippetId { unSnippetId :: Int } 149 | deriving (Eq, Show, Enum, Ord, Typeable) 150 | 151 | $(makeLenses ''State) 152 | $(makeLenses ''Snippet) 153 | $(makePrisms ''Status) 154 | $(makeWrapped ''PosMap) 155 | -------------------------------------------------------------------------------- /soh-client/src/View.hs: -------------------------------------------------------------------------------- 1 | -- | This module defines how the SoH editor and controls are rendered. 2 | module View (renderControls, renderEditor) where 3 | 4 | import Control.Lens.Extras (is) 5 | import Import 6 | import qualified JavaScript.Ace as Ace 7 | import JavaScript.IFrame 8 | import JavaScript.TermJs 9 | import Model (runQuery, runSnippetCode, switchTab, closeControls, clearTypeInfo) 10 | import View.Build 11 | import View.Console 12 | import View.PosMap (handleChange, selectionToSpan) 13 | import View.TypeInfo 14 | 15 | renderControls 16 | :: UComponent TermJs 17 | -> UComponent IFrame 18 | -> State 19 | -> React () 20 | renderControls termjs iframe state = do 21 | let status = state ^. stateStatus 22 | case status of 23 | InitialStatus -> return () 24 | _ -> do 25 | class_ "soh-visible" 26 | -- Set the position of the controls. 27 | div_ $ do 28 | class_ "controls-bar" 29 | renderTab state ConsoleTab "" "Console" 30 | renderTab state WebTab "" "Web" 31 | renderTab state DocsTab "" "Docs" 32 | renderTab state BuildTab (buildStatusClass status) $ do 33 | text (buildStatusText status) 34 | renderCloseButton 35 | renderTabContent state ConsoleTab $ consoleTab termjs 36 | renderTabContent state DocsTab $ buildIFrame iframe stateDocs (Just noDocsUrl) 37 | renderTabContent state WebTab $ buildIFrame iframe stateWeb Nothing 38 | renderTabContent state BuildTab $ buildTab status 39 | 40 | -------------------------------------------------------------------------------- 41 | -- Editor 42 | 43 | renderEditor 44 | :: UComponent Ace.Editor 45 | -> UComponent TermJs 46 | -> UComponent IFrame 47 | -> SnippetId 48 | -> JSString 49 | -> Bool 50 | -> State 51 | -> React () 52 | renderEditor ace termjs iframe sid initialValue inlineControls state = do 53 | let isCurrent = currentSnippet state == Just sid 54 | class_ "soh-container" 55 | div_ $ do 56 | class_ $ addWhen isCurrent "soh-current" 57 | $ addWhen (not inlineControls) "soh-remote-controls" 58 | $ "soh-snippet" 59 | buildUnmanaged ace (ixSnippet sid . snippetEditor) $ \stateVar q -> do 60 | editor <- Ace.makeEditor q 61 | Ace.setMaxLinesInfty editor 62 | Ace.setValue editor initialValue 63 | debounce 100 (handleSelectionChange stateVar sid) >>= 64 | Ace.onSelectionChange editor 65 | Ace.onChange editor (handleChange stateVar sid) 66 | Ace.addCommand editor "run" "Ctrl-Enter" "Command-Enter" $ runSnippetCode stateVar sid 67 | return editor 68 | renderRunButton sid isCurrent (state ^. stateStatus) 69 | forM_ (join (state ^? ixSnippet sid . snippetTypeInfo)) $ \(typs, x, y, _) -> 70 | -- TODO: remove this ugly hack! We sometimes get lots of type 71 | -- infos for the same span due to TH. 72 | when (length typs < 4) $ typePopup typs x y 73 | when (isCurrent && inlineControls) $ div_ $ do 74 | id_ "soh-controls" 75 | class_ "soh-inline-controls" 76 | div_ $ renderControls termjs iframe state 77 | 78 | handleSelectionChange :: TVar State -> SnippetId -> IO () 79 | handleSelectionChange stateVar sid = do 80 | state <- readTVarIO stateVar 81 | selection <- Ace.getSelection =<< getEditor state sid 82 | -- Only show types for selections that contain multiple chars. 83 | if Ace.anchor selection == Ace.lead selection 84 | then clearTypeInfo stateVar sid 85 | -- Compute the source span of the query at the time of compilation. 86 | else case selectionToSpan state sid selection of 87 | Nothing -> do 88 | clearTypeInfo stateVar sid 89 | -- FIXME: UI for this. 90 | putStrLn "No span for this query" 91 | Just ss -> runQuery stateVar sid (QueryInfo ss) 92 | 93 | renderRunButton :: SnippetId -> Bool -> Status -> React () 94 | renderRunButton sid isCurrent s = div_ $ do 95 | let building = is _BuildRequested s || is _Building s 96 | working = building && isCurrent 97 | class_ $ addWhen working "building" 98 | $ "run glyphicon" 99 | title_ $ if working then "Compiling code..." else "Compile and run code (Ctrl-Enter / Command-Enter)" 100 | onClick $ \_ state -> runSnippetCode state sid 101 | 102 | -------------------------------------------------------------------------------- 103 | -- Tabs 104 | 105 | renderTab :: State -> Tab -> Text -> React () -> React () 106 | renderTab state tab extraClasses f = div_ $ do 107 | class_ $ 108 | addWhen (state ^. stateTab == tab) "tab-focused" 109 | ("tab " <> tabClass tab <> " " <> extraClasses) 110 | onClick (\_ -> flip switchTab tab) 111 | f 112 | 113 | renderTabContent :: State -> Tab -> React () -> React () 114 | renderTabContent state tab f = div_ $ do 115 | class_ $ 116 | addWhen (state ^. stateTab == tab) "tab-content-focused" 117 | ("tab-content " <> tabClass tab <> "-content") 118 | f 119 | 120 | tabClass :: Tab -> Text 121 | tabClass BuildTab = "build-tab" 122 | tabClass ConsoleTab = "console-tab" 123 | tabClass DocsTab = "docs-tab" 124 | tabClass WebTab = "web-tab" 125 | 126 | renderCloseButton :: React () 127 | renderCloseButton = div_ $ do 128 | class_ "soh-close-btn" 129 | onClick $ \_ -> closeControls 130 | -------------------------------------------------------------------------------- /soh-client/src/View/Annotation.hs: -------------------------------------------------------------------------------- 1 | -- | This module provides utilities for rendering the 'Ann' type of the 2 | -- API. 'Ann' gives extra structure to textual information provided by 3 | -- the backend, by adding nested annotations atop the text. 4 | -- 5 | -- In the current School of Haskell code, 'Ann' is used for source 6 | -- errors and type info. This allows things like links to docs for 7 | -- identifiers, and better styling for source errors. 8 | -- 9 | -- This module also provides utilities for getting highlight spans from 10 | -- code, via Ace. This allows the display of annotated info to highlight 11 | -- the involved expressions / types, and pass these 'ClassSpans' into 12 | -- 'renderAnn'. 13 | module View.Annotation 14 | ( -- * Annotations 15 | renderAnn 16 | , annText 17 | -- * Rendering IdInfo links 18 | , renderCodeAnn 19 | -- * Highlighting Code 20 | , getHighlightSpans 21 | , getExpHighlightSpans 22 | , getTypeHighlightSpans 23 | -- ** Utilities 24 | , NoNewlines 25 | , unNoNewlines 26 | , mkNoNewlines 27 | , mayMkNoNewlines 28 | ) where 29 | 30 | import qualified Data.Text as T 31 | import GHCJS.Foreign 32 | import GHCJS.Marshal 33 | import GHCJS.Types 34 | import Import hiding (ix, to) 35 | import Model (switchTab, navigateDoc) 36 | 37 | -------------------------------------------------------------------------------- 38 | -- Annotations 39 | 40 | -- | This renders an 'Ann' type, given a function for rendering the 41 | -- annotations. 42 | -- 43 | -- This rendering function takes the annotation, and is given the 44 | -- 'React' rendering of the nested content. This allows it to add 45 | -- parent DOM nodes / attributes, in order to apply the effect of the 46 | -- annotation. 47 | -- 48 | -- It also takes a 'ClassSpans' value, which is used at the leaf 49 | -- level, to slice up the spans of text, adding additional class 50 | -- annotations. This is used to add the results of code highlighting 51 | -- to annotated info. 52 | renderAnn 53 | :: forall a. 54 | ClassSpans 55 | -> Ann a 56 | -> (forall b. a -> React b -> React b) 57 | -> React () 58 | renderAnn spans0 x0 f = void $ go 0 spans0 x0 59 | where 60 | go :: Int -> ClassSpans -> Ann a -> React (Int, ClassSpans) 61 | go ix spans (Ann ann inner) = f ann $ go ix spans inner 62 | go ix spans (AnnGroup []) = return (ix, spans) 63 | go ix spans (AnnGroup (x:xs)) = do 64 | (ix', spans') <- go ix spans x 65 | go ix' spans' (AnnGroup xs) 66 | go ix spans (AnnLeaf txt) = do 67 | forM_ (sliceSpans ix txt spans) $ \(chunk, mclass) -> span_ $ do 68 | forM_ mclass class_ 69 | text chunk 70 | return (end, dropWhile (\(_, end', _) -> end' <= end) spans) 71 | where 72 | end = ix + T.length txt 73 | 74 | annText :: Ann a -> Text 75 | annText (Ann _ x) = annText x 76 | annText (AnnGroup xs) = T.concat (map annText xs) 77 | annText (AnnLeaf x) = x 78 | 79 | -------------------------------------------------------------------------------- 80 | -- Rendering IdInfo links 81 | 82 | -- | Renders a 'CodeAnn'. This function is intended to be passed in 83 | -- to 'renderAnn', or used to implement a function which is passed 84 | -- into it. 85 | renderCodeAnn :: CodeAnn -> React a -> React a 86 | renderCodeAnn (CodeIdInfo info) inner = span_ $ do 87 | class_ "docs-link" 88 | title_ (displayIdInfo info) 89 | onClick $ \_ state -> do 90 | navigateDoc state (Just info) 91 | switchTab state DocsTab 92 | inner 93 | 94 | -------------------------------------------------------------------------------- 95 | -- Highlighting code 96 | 97 | type ClassSpans = [(Int, Int, Text)] 98 | 99 | -- NOTE: prefixing for expressions doesn't seem to make a difference 100 | -- for the current highlighter, but it might in the future. 101 | 102 | -- | Get the highlight spans of an expression. 103 | getExpHighlightSpans :: NoNewlines -> IO ClassSpans 104 | getExpHighlightSpans = getHighlightSpansWithPrefix $ mkNoNewlines "x = " 105 | 106 | -- | Get the highlight spans of a type. 107 | getTypeHighlightSpans :: NoNewlines -> IO ClassSpans 108 | getTypeHighlightSpans = getHighlightSpansWithPrefix $ mkNoNewlines "x :: " 109 | 110 | getHighlightSpansWithPrefix :: NoNewlines -> NoNewlines -> IO ClassSpans 111 | getHighlightSpansWithPrefix prefix codeLine = do 112 | let offset = T.length (unNoNewlines prefix) 113 | spans <- getHighlightSpans "ace/mode/haskell" (prefix <> codeLine) 114 | return $ 115 | dropWhile (\(_, to, _) -> to <= 0) $ 116 | map (\(fr, to, x) -> (fr - offset, to - offset, x)) spans 117 | 118 | getHighlightSpans :: Text -> NoNewlines -> IO ClassSpans 119 | getHighlightSpans mode (NoNewlines codeLine) = 120 | highlightCodeHTML (toJSString mode) (toJSString codeLine) >>= 121 | indexArray 0 >>= 122 | fromJSRef >>= 123 | maybe (fail "Failed to access highlighted line html") return >>= 124 | divFromInnerHTML >>= 125 | spanContainerToSpans >>= 126 | fromJSRef >>= 127 | maybe (fail "Failed to marshal highlight spans") return 128 | 129 | foreign import javascript "function() { var node = document.createElement('div'); node.innerHTML = $1; return node; }()" 130 | divFromInnerHTML :: JSString -> IO (JSRef Element) 131 | 132 | foreign import javascript "highlightCodeHTML" 133 | highlightCodeHTML :: JSString -> JSString -> IO (JSArray JSString) 134 | 135 | foreign import javascript "spanContainerToSpans" 136 | spanContainerToSpans :: JSRef Element -> IO (JSRef ClassSpans) 137 | 138 | -------------------------------------------------------------------------------- 139 | -- NoNewlines: utility for code highlighting 140 | 141 | -- TODO: should probably use source spans / allow new lines instead 142 | -- of having this newtype... 143 | 144 | -- | This newtype enforces the invariant that the stored 'Text' doesn't 145 | -- have the character \"\\n\". 146 | newtype NoNewlines = NoNewlines Text 147 | deriving (Eq, Show, Monoid) 148 | 149 | unNoNewlines :: NoNewlines -> Text 150 | unNoNewlines (NoNewlines x) = x 151 | 152 | mkNoNewlines :: Text -> NoNewlines 153 | mkNoNewlines = fromMaybe (error "mkNoNewlines failed") . mayMkNoNewlines 154 | 155 | mayMkNoNewlines :: Text -> Maybe NoNewlines 156 | mayMkNoNewlines x | "\n" `T.isInfixOf` x = Nothing 157 | mayMkNoNewlines x = Just (NoNewlines x) 158 | -------------------------------------------------------------------------------- /soh-client/src/View/Build.hs: -------------------------------------------------------------------------------- 1 | -- | This module defines how the build tab is rendered. 2 | module View.Build 3 | ( buildStatusText 4 | , buildStatusClass 5 | , buildTab 6 | ) where 7 | 8 | import qualified JavaScript.Ace as Ace 9 | import Import 10 | import View.PosMap (spanToSelection) 11 | 12 | -- TODO: bring this back 13 | -- ghciButton :: React () 14 | -- ghciButton = div_ $ do 15 | -- class_ "ghci-button btn btn-default" 16 | -- text "GHCI" 17 | -- onClick $ \_ state -> do 18 | -- editor <- readUnmanagedOrFail state (^. stateAce) 19 | -- code <- Ace.getValue editor 20 | -- --FIXME: when using GHCI, don't display build errors / messages 21 | -- --from it. 22 | -- --FIXME: it'd also be good if ide-backend-client held on to the 23 | -- --old session, so that info is still available. 24 | -- let code' = T.unlines 25 | -- [ "import System.Process (rawSystem)" 26 | -- , "main = do" 27 | -- , " writeFile \"main.hs\" " <> tshow code 28 | -- , " ec <- rawSystem \"ghci\" [\"main.hs\"]" 29 | -- , " putStrLn $ \"GHCI exited with \" ++ show ec" 30 | -- ] 31 | -- runCode state [("main.hs", code')] 32 | 33 | buildStatusText :: Status -> Text 34 | buildStatusText InitialStatus = "Unbuilt" 35 | buildStatusText (BuildRequested _) = "Sending" 36 | buildStatusText (Building _ (UpdateStatusProgress progress)) = 37 | "Building (" <> 38 | tshow (progressStep progress) <> 39 | "/" <> 40 | tshow (progressNumSteps progress) <> 41 | ")" 42 | buildStatusText (Building _ UpdateStatusDone) = "Fetching" 43 | buildStatusText (Building _ UpdateStatusRequiredRestart) = "Restarting" 44 | buildStatusText (Building _ _) = "Backend Error" 45 | buildStatusText (Built _ info) = infoStatusText info 46 | buildStatusText (QueryRequested _ info _) = infoStatusText info 47 | buildStatusText (KillRequested _ _) = "Killing" 48 | 49 | -- TODO: use these classes to add style to the build tab. 50 | buildStatusClass :: Status -> Text 51 | buildStatusClass InitialStatus {} = "" 52 | buildStatusClass BuildRequested {} = "soh-build-requested" 53 | buildStatusClass Building {} = "soh-building" 54 | buildStatusClass Built {} = "soh-built" 55 | buildStatusClass QueryRequested {} = "soh-query-requested" 56 | buildStatusClass KillRequested {} = "soh-kill-requested" 57 | 58 | infoStatusText :: BuildInfo -> Text 59 | infoStatusText BuildInfo {..} 60 | | not (null buildServerDieds) = 61 | "Server Died" 62 | | not (null buildErrors) = 63 | "Errors" 64 | | not (null buildWarnings) = 65 | "Warnings" 66 | | otherwise = 67 | "Built" 68 | 69 | buildTab :: Status -> React () 70 | buildTab InitialStatus = return () 71 | buildTab (BuildRequested _) = return () 72 | buildTab (Building _ (UpdateStatusProgress progress)) = forM_ (progressParsedMsg progress) text 73 | buildTab (Building _ UpdateStatusDone) = "Build done. Requesting compile info.." 74 | buildTab (Building _ (UpdateStatusFailed err)) = text $ "Build failed: " <> err 75 | buildTab (Building _ UpdateStatusRequiredRestart) = "Required a backend restart.." 76 | buildTab (Building _ (UpdateStatusErrorRestart err)) = text $ "Backend error: " <> err <> "\nRestarting..." 77 | buildTab (Building _ UpdateStatusFailedToRestart) = text $ "Backend failed to restart" 78 | buildTab (Built sid info) = buildInfo sid info 79 | buildTab (QueryRequested sid info _) = buildInfo sid info 80 | buildTab (KillRequested sid info) = buildInfo sid info 81 | 82 | buildInfo :: SnippetId -> BuildInfo -> React () 83 | buildInfo sid info 84 | -- TODO: victory picture instead of text? 85 | | null (sourceErrors info) = 86 | text "Successful build - no errors or warnings!" 87 | | otherwise = 88 | forM_ (sourceErrors info) $ \SourceError{..} -> div_ $ do 89 | -- FIXME: have some explanatory text or victory picture when there 90 | -- are no errors or warnings. 91 | class_ $ "message " <> case errorKind of 92 | KindError -> "kind-error" 93 | KindServerDied -> "kind-error" 94 | KindWarning -> "kind-warning" 95 | span_ $ do 96 | case errorSpan of 97 | TextSpan {} -> class_ "error-text-span" 98 | ProperSpan ss -> do 99 | class_ "error-proper-span" 100 | text $ tshow errorSpan 101 | onClick $ \_ stateVar -> do 102 | -- FIXME: use a more lenient version of spanToSelection 103 | -- which allows for removals within the span. Possibly 104 | -- have the implementation function always compute the 105 | -- lenient version, but flag that some portion of the 106 | -- range was edited. 107 | -- 108 | -- If the above isn't done, then at least the UI 109 | -- should mention why it isn't selecting anything on 110 | -- click, in this case. 111 | state <- readTVarIO stateVar 112 | editor <- getEditor state sid 113 | case spanToSelection state sid ss of 114 | Nothing -> putStrLn "No span for error" 115 | Just sel -> do 116 | Ace.setSelection editor sel 117 | Ace.focus editor 118 | span_ $ do 119 | class_ "error-msg" 120 | text errorMsg 121 | 122 | sourceErrors :: BuildInfo -> [SourceError] 123 | sourceErrors info = 124 | buildServerDieds info ++ 125 | buildErrors info ++ 126 | buildWarnings info 127 | -------------------------------------------------------------------------------- /soh-client/src/View/Console.hs: -------------------------------------------------------------------------------- 1 | -- | This module defines how the console tab is rendered. 2 | module View.Console (consoleTab) where 3 | 4 | import Model.Protocol (sendProcessInput) 5 | import Data.Text (unpack) 6 | import Import 7 | import JavaScript.TermJs 8 | 9 | consoleTab :: UComponent TermJs -> React () 10 | consoleTab termJs = do 11 | buildUnmanaged termJs stateConsole $ \state q -> do 12 | terminal <- initTerminal q 13 | onTerminalData terminal $ \input -> do 14 | mbackend <- viewTVarIO state stateBackend 15 | forM_ mbackend $ \backend -> 16 | sendProcessInput backend (unpack input) 17 | return terminal 18 | -------------------------------------------------------------------------------- /soh-client/src/View/PosMap.hs: -------------------------------------------------------------------------------- 1 | -- | This module allows for the tracking of source span changes. 2 | -- 3 | -- This allows us to associate information from a prior editor state 4 | -- with the current editor state. It provides a map from old source 5 | -- spans to new source spans, such that you can ask "Where was this 6 | -- span when the code was compiled?" - mapping spans backward in time. 7 | -- The current implementation simply stores a list of span 8 | -- replacements, and then replays history, offseting the input span. 9 | -- 10 | -- It also allows you to map spans forward in time, from the compiled 11 | -- state to the current state. This lets you take spans yielded by 12 | -- the compiler and map them to spans in the buffer. 13 | -- 14 | -- Often, it isn't possible to map a span forwards / backwards, 15 | -- because an edit has happened with that region. In order to still 16 | -- get a result, we'd need to have defaults for these circumstances. 17 | module View.PosMap 18 | ( 19 | -- * School-of-haskell specific utilities 20 | handleChange 21 | , selectionToSpan 22 | , spanToSelection 23 | , rangeToSpan 24 | , spanToRange 25 | -- * Implementation 26 | , emptyPosMap 27 | , rangeMapForward 28 | , rangeMapBackward 29 | , posMapForward 30 | , posMapBackward 31 | , changeEventToPosChange 32 | ) where 33 | 34 | import JavaScript.Ace 35 | import Data.List (foldl') 36 | import Import 37 | 38 | -------------------------------------------------------------------------------- 39 | -- School-of-haskell specific utilities 40 | 41 | -- | Given the event generated by a text change in the Ace editor, 42 | -- this updates the 'PosMap' for the snippet. 43 | handleChange :: TVar State -> SnippetId -> ChangeEvent -> IO () 44 | handleChange state sid ev = 45 | modifyTVarIO state 46 | (ixSnippet sid . snippetPosMap . _Wrapped) 47 | (changeEventToPosChange ev :) 48 | 49 | selectionToSpan :: State -> SnippetId -> Selection -> Maybe SourceSpan 50 | selectionToSpan state sid = 51 | rangeToSpan state sid . selectionToRange 52 | 53 | spanToSelection :: State -> SnippetId -> SourceSpan -> Maybe Selection 54 | spanToSelection state sid = 55 | fmap rangeToSelection . spanToRange state sid 56 | 57 | rangeToSpan :: State -> SnippetId -> Range -> Maybe SourceSpan 58 | rangeToSpan state sid range = do 59 | posMap <- state ^? ixSnippet sid . snippetPosMap 60 | rangeToSpan' "main.hs" <$> (rangeMapBackward posMap range) 61 | 62 | spanToRange :: State -> SnippetId -> SourceSpan -> Maybe Range 63 | spanToRange state sid ss = do 64 | posMap <- state ^? ixSnippet sid . snippetPosMap 65 | rangeMapForward posMap r 66 | where 67 | -- TODO: do something with the filepath. 68 | (_fp, r) = spanToRange' ss 69 | 70 | rangeToSpan' :: FilePath -> Range -> SourceSpan 71 | rangeToSpan' fp Range{..} = SourceSpan 72 | { spanFilePath = fp 73 | , spanFromLine = row start + 1 74 | , spanFromColumn = column start + 1 75 | , spanToLine = row end + 1 76 | , spanToColumn = column end + 1 77 | } 78 | 79 | spanToRange' :: SourceSpan -> (FilePath, Range) 80 | spanToRange' SourceSpan{..} = (spanFilePath, range) 81 | where 82 | range = Range 83 | { start = Pos (spanFromLine - 1) (spanFromColumn - 1) 84 | , end = Pos (spanToLine - 1) (spanToColumn - 1) 85 | } 86 | 87 | -------------------------------------------------------------------------------- 88 | -- Implementation 89 | 90 | -- TODO: this is rather inefficient. Adjustments will get slower and 91 | -- slower as more edits are added since the last compile. I think a 92 | -- more efficient implementation of this would use something like 93 | -- "Data.FingerTree". A rough sketch I haven't thought that much 94 | -- about: 95 | -- 96 | -- type PosMap = FingerTree PosMeasure PosChange 97 | -- 98 | -- data PosMeasure = PosMeasure 99 | -- { posInOld :: Pos -- ^ For the fingertree subtree, this stores the start 100 | -- position of the leftmost node. This allows us to 101 | -- search for a particular position in the old state. 102 | -- , posInNew :: Pos -- ^ Similarly to the above, but for the new state. 103 | -- } 104 | 105 | emptyPosMap :: PosMap 106 | emptyPosMap = PosMap [] 107 | 108 | -- | Maps a range forwards in time. In other words, given a range 109 | -- from before the user edited the code, this figures out how that 110 | -- range would be shifted. If an edit occurred within the range, then 111 | -- 'Nothing' is yielded. 112 | rangeMapForward :: PosMap -> Range -> Maybe Range 113 | rangeMapForward = 114 | mapImpl oldRange newRange shiftRange compareRange . reverse . unPosMap 115 | 116 | -- | Maps a range forwards in time. In other words, given a range in 117 | -- the current code state, this figures out where that range came 118 | -- from, before the user's edits. If an edit occurred within the 119 | -- range, then 'Nothing' is yielded. 120 | rangeMapBackward :: PosMap -> Range -> Maybe Range 121 | rangeMapBackward = 122 | mapImpl newRange oldRange shiftRange compareRange . unPosMap 123 | 124 | -- | Similar to 'rangeMapForward', but instead maps a position. 125 | posMapForward :: PosMap -> Pos -> Maybe Pos 126 | posMapForward = 127 | mapImpl oldRange newRange shiftPos comparePosWithRange . reverse . unPosMap 128 | 129 | -- | Similar to 'rangeMapBackward', but instead maps a position. 130 | posMapBackward :: PosMap -> Pos -> Maybe Pos 131 | posMapBackward = 132 | mapImpl newRange oldRange shiftPos comparePosWithRange . unPosMap 133 | 134 | mapImpl 135 | :: (PosChange -> Range) 136 | -> (PosChange -> Range) 137 | -> (DeltaPos -> a -> a) 138 | -> (a -> Range -> RangeOrdering) 139 | -> [PosChange] 140 | -> a 141 | -> Maybe a 142 | mapImpl before after shift comp changes p0 = 143 | foldl' go (Just p0) changes 144 | where 145 | go Nothing _ = Nothing 146 | go (Just x) change = 147 | case x `comp` (before change) of 148 | -- Replacements don't affect positions that come earlier in the buffer. 149 | Before -> Just x 150 | -- If the position is inside an interval that got replaced, 151 | -- then it maps to Nothing. 152 | Intersecting -> Nothing 153 | -- The replacement moved this position, so offset it. 154 | After -> Just $ shift delta x 155 | where 156 | delta = end (after change) `subtractPos` end (before change) 157 | 158 | changeEventToPosChange :: ChangeEvent -> PosChange 159 | changeEventToPosChange ev = 160 | case ev of 161 | InsertLines range _ -> PosChange 162 | { oldRange = startRange range, newRange = range } 163 | InsertText range _ -> PosChange 164 | { oldRange = startRange range, newRange = range } 165 | RemoveLines range _ _ -> PosChange 166 | { oldRange = range , newRange = startRange range } 167 | RemoveText range _ -> PosChange 168 | { oldRange = range , newRange = startRange range } 169 | where 170 | startRange range = Range (start range) (start range) 171 | -------------------------------------------------------------------------------- /soh-client/src/View/TypeInfo.hs: -------------------------------------------------------------------------------- 1 | module View.TypeInfo (typePopup) where 2 | 3 | import qualified Data.Text as T 4 | import Import 5 | import React.Internal (internalLiftIOReact) 6 | import View.Annotation 7 | 8 | -- | Show the type popup. 9 | typePopup :: [ResponseAnnExpType] -> Int -> Int -> React () 10 | typePopup typs x y = div_ $ do 11 | class_ "type-popup" 12 | style "top" $ T.pack (show (y + 14)) <> "px" 13 | style "left" $ T.pack (show x) <> "px" 14 | forM_ typs $ \(ResponseAnnExpType typ _) -> do 15 | spans' <- internalLiftIOReact $ 16 | getTypeHighlightSpans (mkNoNewlines (annText typ)) 17 | div_ $ do 18 | class_ "type-info highlighted-haskell ace-tomorrow" 19 | div_ $ do 20 | class_ "ace_line" 21 | void $ renderAnn spans' typ renderCodeAnn 22 | -------------------------------------------------------------------------------- /soh-client/stack.yaml: -------------------------------------------------------------------------------- 1 | resolver: lts-3.6 2 | compiler: ghcjs-0.1.0.20150924_ghc-7.10.2 3 | compiler-check: match-exact 4 | packages: 5 | - . 6 | - ../deps/ghcjs-from-typescript/ghcjs-ace 7 | - ../deps/ghcjs-jquery 8 | - ../deps/ghcjs-react 9 | - ../deps/stack-ide/stack-ide-api 10 | - ../deps/stack-ide/ide-backend/ide-backend-common 11 | - ../soh-runner-api 12 | - ../soh-scheduler-api 13 | extra-deps: 14 | - ghcjs-dom-0.1.1.3 15 | - ghcjs-websockets-0.3.0.5 16 | -------------------------------------------------------------------------------- /soh-client/util.js: -------------------------------------------------------------------------------- 1 | // Based on https://github.com/ajaxorg/ace/blob/master/lib/ace/ext/static_highlight.js 2 | 3 | var EditSession = ace.require("ace/edit_session").EditSession; 4 | var TextLayer = ace.require("ace/layer/text").Text; 5 | 6 | function highlightCodeHTML(mode, input) { 7 | var session = new EditSession(""); 8 | session.setUseWorker(false); 9 | session.setMode(mode); 10 | 11 | var textLayer = new TextLayer(document.createElement("div")); 12 | textLayer.setSession(session); 13 | textLayer.config = { 14 | characterWidth: 10, 15 | lineHeight: 20 16 | }; 17 | 18 | session.setValue(input); 19 | 20 | var results = []; 21 | var length = session.getLength(); 22 | for(var ix = 0; ix < length; ix++) { 23 | var lineBuilder = []; 24 | textLayer.$renderLine(lineBuilder, ix, true, false); 25 | results.push(lineBuilder.join("")); 26 | } 27 | 28 | textLayer.destroy(); 29 | 30 | return results; 31 | } 32 | 33 | // Given a container of spans (such as a line of code highlighted by 34 | // ace), yields a list of text intervals with class.. 35 | function spanContainerToSpans(el) { 36 | var results = []; 37 | var j = 0; 38 | for(var i = 0; i < el.childNodes.length; i++) { 39 | var child = el.childNodes[i]; 40 | switch (child.nodeType) { 41 | case 1: // element 42 | var txt = child.innerText; 43 | var className = child.getAttribute("class") 44 | var to = j + txt.length; 45 | results.push([j, to, className]); 46 | j = to; 47 | break; 48 | case 3: // text node 49 | j += child.nodeValue.length; 50 | break; 51 | } 52 | } 53 | return results; 54 | } 55 | 56 | function positionControlsOnResize(sohControls, sohColumn) { 57 | $(sohControls).css('position', 'fixed'); 58 | $(sohControls).css('bottom', '0'); 59 | var positionControls = debounce(100, function () { 60 | $(sohControls).css('left', $(sohColumn).offset().left - $(window).scrollLeft()); 61 | $(sohControls).css('width', $(sohColumn).width()); 62 | }); 63 | positionControls(); 64 | $(window).resize(positionControls); 65 | $(window).scroll(positionControls); 66 | } 67 | 68 | // Debounce the given function 69 | function debounce(ms, f){ 70 | var t; 71 | return function(x){ 72 | clearTimeout(t); 73 | t = setTimeout(function(){ 74 | f(x); 75 | },ms); 76 | }; 77 | } 78 | -------------------------------------------------------------------------------- /soh-runner-api/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 FP Complete 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /soh-runner-api/soh-runner-api.cabal: -------------------------------------------------------------------------------- 1 | name: soh-runner-api 2 | version: 0.0.1 3 | license: MIT 4 | license-file: LICENSE 5 | author: FP Complete Developers 6 | maintainer: dev@fpcomplete.com 7 | copyright: 2015 FP Complete Corporation 8 | category: System 9 | build-type: Simple 10 | cabal-version: >=1.10 11 | 12 | library 13 | default-language: Haskell2010 14 | hs-source-dirs: src 15 | exposed-modules: SchoolOfHaskell.Runner.API 16 | ghc-options: -Wall 17 | build-depends: base >= 4.5 && < 5, 18 | stack-ide-api, 19 | aeson, 20 | text 21 | -------------------------------------------------------------------------------- /soh-runner-api/src/SchoolOfHaskell/Runner/API.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE TemplateHaskell #-} 2 | {-# LANGUAGE OverloadedStrings #-} 3 | module SchoolOfHaskell.Runner.API 4 | ( RunnerRequest (..) 5 | , RunnerResponse (..) 6 | , defaultBackendPort 7 | ) where 8 | 9 | import Data.Aeson.TH 10 | import Data.Text (Text) 11 | import Stack.Ide.JsonAPI (Sequenced, Request, Response) 12 | import Prelude hiding ((.), id) 13 | 14 | data RunnerRequest = 15 | RunnerRequestAuth Text 16 | | RunnerRequestOpenPort 17 | | RunnerRequestPortListening Int 18 | | RunnerRequestClient (Sequenced Request) 19 | deriving (Show) 20 | 21 | data RunnerResponse = 22 | RunnerResponseAuthFailure 23 | | RunnerResponseAuthSuccess 24 | | RunnerResponseOpenPort Int 25 | | RunnerResponsePortIsListening 26 | | RunnerResponseClient (Sequenced Response) 27 | deriving (Show) 28 | 29 | $(fmap concat $ mapM (deriveJSON defaultOptions) 30 | [ ''RunnerRequest 31 | , ''RunnerResponse 32 | ]) 33 | 34 | ------------------------------------------------------------------------------ 35 | -- Constants 36 | 37 | -- | Default port used by soh-runner to listen for websocket connections. 38 | defaultBackendPort :: Int 39 | defaultBackendPort = 4000 40 | -------------------------------------------------------------------------------- /soh-runner/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 FP Complete 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /soh-runner/main/Main.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE TemplateHaskell #-} 2 | module Main (main) where 3 | 4 | import SchoolOfHaskell.Runner (runner, Settings(Settings)) 5 | import Options.Applicative.Simple 6 | import Paths_soh_runner (version) 7 | import Data.Text (pack) 8 | import SchoolOfHaskell.Runner.API (defaultBackendPort) 9 | 10 | main :: IO () 11 | main = id . snd =<< 12 | simpleOptions 13 | $(simpleVersion version) 14 | "soh-runner" 15 | "School of Haskell stack runner" 16 | (pure ()) 17 | (addCommand 18 | "run" 19 | "Run the soh-runner server" 20 | runner 21 | settingsParser) 22 | 23 | settingsParser :: Parser Settings 24 | settingsParser = 25 | Settings 26 | <$> option auto (long "port" <> short 'p' <> metavar "PORT" <> value defaultBackendPort) 27 | <*> option (fmap pack str) (long "receipt" <> short 'r' <> metavar "RECEIPT") 28 | <*> optional (option auto (long "lifetime-seconds" <> metavar "SECONDS")) 29 | <*> switch (long "verbose" <> short 'v') 30 | -------------------------------------------------------------------------------- /soh-runner/soh-runner.cabal: -------------------------------------------------------------------------------- 1 | name: soh-runner 2 | version: 0.0.1 3 | license: MIT 4 | license-file: LICENSE 5 | author: FP Complete Developers 6 | maintainer: dev@fpcomplete.com 7 | copyright: 2015 FP Complete Corporation 8 | category: Development 9 | build-type: Simple 10 | cabal-version: >=1.10 11 | 12 | library 13 | default-language: Haskell2010 14 | hs-source-dirs: src 15 | exposed-modules: SchoolOfHaskell.Runner 16 | Paths_soh_runner 17 | ghc-options: -Wall 18 | build-depends: base >=4.7 && <5 19 | , aeson 20 | , async 21 | , bytestring 22 | , conduit-combinators 23 | , directory 24 | , http-types 25 | , ide-backend 26 | , soh-runner-api 27 | , stack-ide 28 | , stack-ide-api 29 | , text 30 | , wai 31 | , wai-websockets 32 | , warp 33 | , websockets 34 | , unix 35 | 36 | executable soh-runner 37 | ghc-options: -threaded -O2 -rtsopts -with-rtsopts=-N -Wall 38 | default-language: Haskell2010 39 | hs-source-dirs: main 40 | main-is: Main.hs 41 | other-modules: Paths_soh_runner 42 | build-depends: base >=4.7 && <5 43 | , optparse-simple 44 | , soh-runner 45 | , soh-runner-api 46 | , text 47 | -------------------------------------------------------------------------------- /soh-runner/src/SchoolOfHaskell/Runner.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE OverloadedStrings #-} 2 | {-# LANGUAGE RecordWildCards #-} 3 | {-# LANGUAGE ViewPatterns #-} 4 | {-# LANGUAGE TupleSections #-} 5 | module SchoolOfHaskell.Runner (Settings(..), runner) where 6 | 7 | import Conduit (foldC, sourceHandle, ($$)) 8 | import Control.Concurrent (threadDelay) 9 | import Control.Concurrent.Async (async, cancel) 10 | import Control.Exception (SomeException, AsyncException(ThreadKilled), try, finally, fromException) 11 | import Control.Monad (void, when) 12 | import Data.Aeson (encode, eitherDecode) 13 | import Data.Foldable (forM_) 14 | import Data.IORef 15 | import Data.Text (Text) 16 | import qualified Data.Text as T 17 | import Data.Text.Encoding (decodeUtf8) 18 | import IdeSession (defaultSessionInitParams, sessionConfigFromEnv, SessionConfig(..)) 19 | import qualified Network.HTTP.Types as H 20 | import qualified Network.Wai as W 21 | import qualified Network.Wai.Handler.Warp as Warp 22 | import qualified Network.Wai.Handler.WebSockets as WaiWS 23 | import qualified Network.WebSockets as WS 24 | import Numeric (showHex) 25 | import SchoolOfHaskell.Runner.API 26 | import Stack.Ide 27 | import Stack.Ide.CmdLine 28 | import qualified System.IO as IO 29 | import System.Posix.Process (getProcessID) 30 | import System.Posix.Types (CPid(..)) 31 | import System.Timeout (timeout) 32 | import Data.List (find) 33 | 34 | data Settings = Settings 35 | { settingsPort :: Int 36 | , settingsReceipt :: Text 37 | , settingsLifetime :: Maybe Int 38 | , settingsVerbose :: Bool 39 | } 40 | 41 | runner :: Settings -> IO () 42 | runner settings@Settings {..} = do 43 | sessionConfig' <- sessionConfigFromEnv 44 | let sessionConfig = sessionConfig' { configLocalWorkingDir = Nothing } 45 | -- Halts the server after a duration of time, if we have a 46 | -- lifetime limit. This is a temporary solution to the problem 47 | -- of garbage collecting containers. 48 | lifetime 49 | | Just secs <- settingsLifetime = 50 | void . timeout (secs * 1000 * 1000) 51 | | otherwise = id 52 | warpSettings = Warp.setPort settingsPort Warp.defaultSettings 53 | app = runnerApp settings sessionConfig 54 | lifetime $ Warp.runSettings warpSettings $ \req sendResponse -> sendResponse $ 55 | case WaiWS.websocketsApp WS.defaultConnectionOptions app req of 56 | Just res -> res 57 | Nothing -> W.responseLBS H.status404 58 | [ ("Content-Type", "text/plain") ] 59 | "Not Found: expected a websockets connection" 60 | 61 | runnerApp :: Settings -> SessionConfig -> WS.PendingConnection -> IO () 62 | runnerApp Settings{..} sessionConfig pending = do 63 | putStrLn $ "Accepting connection from client: " ++ 64 | show (WS.pendingRequest pending) 65 | conn <- WS.acceptRequest pending 66 | WS.forkPingThread conn 30 67 | putStrLn "Accepted connection and forked ping thread" 68 | let send = WS.sendTextData conn . encode 69 | receive = do 70 | input <- WS.receiveData conn 71 | return $ eitherDecode input 72 | startPort <- getStartPortFromProcessID 73 | initial <- receive 74 | case initial of 75 | Right (RunnerRequestAuth receipt') 76 | | receipt' /= settingsReceipt -> do 77 | putStrLn "Authentication failed" 78 | send RunnerResponseAuthFailure 79 | | otherwise -> do 80 | putStrLn "Authentication succeeded" 81 | send RunnerResponseAuthSuccess 82 | listenThreadRef <- newIORef Nothing 83 | let sendResponse = send . RunnerResponseClient 84 | receiveRequest = do 85 | ereq <- receive 86 | case ereq of 87 | Left err -> return $ Left err 88 | Right (RunnerRequestClient x) -> return $ Right x 89 | -- FIXME: The issue with this is that by the time the 90 | -- process starts, the port might not be open anymore. 91 | Right RunnerRequestOpenPort -> do 92 | ntp <- getProcNetTcp 93 | case findOpenPort ntp startPort of 94 | Nothing -> return $ Left $ 95 | "Couldn't find an open port?!? (really shouldn't happen)" 96 | Just port -> do 97 | send $ RunnerResponseOpenPort port 98 | receiveRequest 99 | Right (RunnerRequestPortListening port) -> do 100 | thread <- async $ do 101 | waitForProcessListening port 102 | send RunnerResponsePortIsListening 103 | mold <- atomicModifyIORef listenThreadRef (Just thread, ) 104 | forM_ mold cancel 105 | receiveRequest 106 | Right req -> return $ Left $ 107 | "Didn't expect runner request: " ++ show req 108 | logMessage loc source level str = 109 | when settingsVerbose $ sendLog clientIO loc source level str 110 | clientIO = ClientIO {..} 111 | clientOpts = Options 112 | { optInitParams = defaultSessionInitParams 113 | , optConfig = sessionConfig 114 | , optVerbose = False 115 | , optVersion = False 116 | } 117 | sendExceptions clientIO $ startEmptySession clientIO clientOpts 118 | `finally` do 119 | mthread <- readIORef listenThreadRef 120 | forM_ mthread cancel 121 | _ -> send RunnerResponseAuthFailure 122 | 123 | -- | Returns when some process is listening to the port. 124 | waitForProcessListening :: Int -> IO () 125 | waitForProcessListening port = loop 120 126 | where 127 | loop :: Int -> IO () 128 | loop gen = do 129 | epnt <- try getProcNetTcp 130 | isListening <- case epnt of 131 | Left (fromException -> Just ThreadKilled) -> return False 132 | Left err -> do 133 | -- FIXME: some better error logging than this. 134 | putStrLn $ "Exception while listening for port: " ++ 135 | show (err :: SomeException) 136 | return False 137 | Right pnt -> return (isPortListening pnt port) 138 | if isListening 139 | then return () 140 | else do 141 | threadDelay $ if gen <= 0 then 5000000 else 250000 142 | loop (max 0 (gen - 1)) 143 | 144 | newtype ProcNetTcp = ProcNetTcp [Text] 145 | 146 | getProcNetTcp :: IO ProcNetTcp 147 | getProcNetTcp = 148 | ProcNetTcp . T.lines . decodeUtf8 <$> 149 | localReadFile "/proc/net/tcp" 150 | where 151 | -- Fun fact: files in the /proc filesystem will often report their 152 | -- size as 0. Because of this, the standard Data.ByteString.readFile 153 | -- will read in an empty ByteString. Instead, we use conduit here. 154 | localReadFile fp = 155 | IO.withBinaryFile fp IO.ReadMode $ \h -> 156 | sourceHandle h $$ foldC 157 | 158 | -- This could be faster, but it should rarely do enough iterations to 159 | -- justify storing ProcNetTcp in a fancier datatype. 160 | findOpenPort :: ProcNetTcp -> Int -> Maybe Int 161 | findOpenPort pnt start = find (not . isPortListening pnt) [start..maxDynPort] 162 | 163 | getStartPortFromProcessID :: IO Int 164 | getStartPortFromProcessID = do 165 | CPid x <- getProcessID 166 | -- Leave a couple slots between consecutive processes. 256 is 167 | -- subtracted from the range so that there are still a bunch of ports 168 | -- left for 'findOpenPort' to search through. 169 | return (minDynPort + ((fromIntegral x * 3) `mod` ((maxDynPort - minDynPort) - 256))) 170 | 171 | minDynPort, maxDynPort :: Int 172 | minDynPort = 49152 173 | maxDynPort = 65535 174 | 175 | isPortListening :: ProcNetTcp -> Int -> Bool 176 | isPortListening (ProcNetTcp ls) port = any findPort ls 177 | where 178 | goalPortHex = 179 | T.toUpper $ T.takeEnd 4 $ T.pack ("0000" ++ showHex port "") 180 | findPort line = 181 | case T.words line of 182 | (_sl:(T.stripPrefix ":" . T.dropWhile (/= ':') -> Just portHex):_) -> 183 | portHex == goalPortHex 184 | _ -> False 185 | -------------------------------------------------------------------------------- /soh-scheduler-api/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Tim Dysinger 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included 12 | in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 17 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 18 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 19 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 20 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /soh-scheduler-api/soh-scheduler-api.cabal: -------------------------------------------------------------------------------- 1 | name: soh-scheduler-api 2 | version: 0.1.2 3 | license: MIT 4 | license-file: LICENSE 5 | author: FP Complete Developers 6 | maintainer: dev@fpcomplete.com 7 | copyright: 2015 FP Complete Corporation 8 | category: System 9 | build-type: Simple 10 | cabal-version: >=1.10 11 | 12 | library 13 | default-language: Haskell2010 14 | hs-source-dirs: src 15 | exposed-modules: SchoolOfHaskell.Scheduler.API 16 | ghc-options: -Wall 17 | build-depends: aeson 18 | , base >=4.7 && <5 19 | , lens 20 | , text 21 | , uuid-types 22 | -------------------------------------------------------------------------------- /soh-scheduler-api/src/SchoolOfHaskell/Scheduler/API.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE TemplateHaskell #-} 2 | {-# LANGUAGE DeriveDataTypeable #-} 3 | {-# LANGUAGE GeneralizedNewtypeDeriving #-} 4 | 5 | module SchoolOfHaskell.Scheduler.API where 6 | 7 | import Control.Applicative ((<$>), (<*>)) 8 | import Control.Lens (makeLenses) 9 | import Data.Aeson (ToJSON(..), FromJSON(..)) 10 | import Data.Aeson.TH (deriveJSON, defaultOptions, fieldLabelModifier) 11 | import Data.Data (Data) 12 | import Data.Text (Text) 13 | import Data.Typeable (Typeable) 14 | import Data.UUID.Types (UUID) 15 | import qualified Data.UUID.Types as UUID 16 | 17 | newtype ContainerSpec = 18 | ContainerSpec {_csImageName :: Text} 19 | deriving (Eq, Show, Data, Typeable) 20 | 21 | newtype ContainerReceipt = 22 | ContainerReceipt {_crID :: UUID} 23 | deriving (Eq, Data, Typeable) 24 | 25 | instance Show ContainerReceipt where 26 | show = show . _crID 27 | 28 | newtype ContainerId = 29 | ContainerId {_ciID :: Text} 30 | deriving (Eq, Show, Ord, Data, Typeable) 31 | 32 | data ContainerDetail = 33 | ContainerDetail {_cdID :: Text 34 | ,_cdAddress :: Maybe (Text, PortMappings) 35 | ,_cdStatus :: Maybe Text} 36 | deriving (Eq, Show, Data, Typeable) 37 | 38 | instance ToJSON UUID where 39 | toJSON = toJSON . UUID.toString 40 | 41 | instance FromJSON UUID where 42 | parseJSON val = do 43 | str <- parseJSON val 44 | case UUID.fromString str of 45 | Nothing -> fail "Failed to parse UUID from JSON" 46 | Just x -> return x 47 | 48 | newtype PortMappings = PortMappings [(Int,Int)] 49 | deriving (Eq, Show, Data, Typeable, ToJSON, FromJSON) 50 | 51 | ------------------------------------------------------------------------------ 52 | -- Constants 53 | 54 | -- | Receipt used for local development. 55 | devReceipt :: ContainerReceipt 56 | devReceipt = ContainerReceipt (UUID.fromWords 0 0 0 0) 57 | 58 | ------------------------------------------------------------------------------ 59 | -- Lenses and aeson instances 60 | 61 | $(let opts n = defaultOptions { fieldLabelModifier = drop n } in 62 | concat <$> mapM (\(n, x) -> (++) <$> makeLenses x <*> deriveJSON (opts n) x) 63 | [ (3, ''ContainerSpec) 64 | , (3, ''ContainerReceipt) 65 | , (3, ''ContainerId) 66 | , (3, ''ContainerDetail) 67 | ]) 68 | -------------------------------------------------------------------------------- /soh-scheduler/.dockerignore: -------------------------------------------------------------------------------- 1 | .cabal-sandbox/lib 2 | .git 3 | cabal.sandbox.config 4 | dist 5 | -------------------------------------------------------------------------------- /soh-scheduler/Dockerfile: -------------------------------------------------------------------------------- 1 | #-*- mode:conf; -*- 2 | 3 | FROM ubuntu:15.04 4 | MAINTAINER Tim Dysinger 5 | 6 | # DEPENDENCIES 7 | ENV DEBIAN_FRONTEND noninteractive 8 | RUN apt-get update \ 9 | && apt-get install -y net-tools 10 | RUN echo "Acquire::http { Proxy \"http://$(netstat -nr|grep '^0\.0\.0\.0'|awk '{print $2}'):3142\"; };" \ 11 | | tee /etc/apt/apt.conf.d/02proxy 12 | RUN apt-get update \ 13 | && apt-get install -y netbase ca-certificates libgmp10 \ 14 | && apt-get clean 15 | RUN update-ca-certificates 16 | RUN rm /etc/apt/apt.conf.d/02proxy 17 | 18 | # EXECUTABLE 19 | ADD ./.cabal-sandbox/bin/sohs /usr/bin/sohs 20 | CMD /usr/bin/sohs 21 | RUN mkdir /static 22 | VOLUME /static 23 | EXPOSE 3000 24 | -------------------------------------------------------------------------------- /soh-scheduler/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Tim Dysinger 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included 12 | in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 17 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 18 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 19 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 20 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /soh-scheduler/Makefile: -------------------------------------------------------------------------------- 1 | default: docker 2 | 3 | cabal.config: 4 | @wget http://www.stackage.org/lts/cabal.config 5 | 6 | cabal.sandbox.config: 7 | @cabal sandbox init 8 | 9 | .cabal-sandbox/bin/sohs: | cabal.config cabal.sandbox.config 10 | @cabal install -j 11 | 12 | docker: | .cabal-sandbox/bin/sohs 13 | @docker build -t sohs . 14 | 15 | clean: 16 | @cabal clean 17 | 18 | dist-clean: 19 | @rm -rf .cabal-sandbox cabal*.config dist 20 | 21 | .PHONY: default docker clean dist-clean 22 | -------------------------------------------------------------------------------- /soh-scheduler/Setup.hs: -------------------------------------------------------------------------------- 1 | import Distribution.Simple 2 | main = defaultMain 3 | -------------------------------------------------------------------------------- /soh-scheduler/main/Main.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE NoImplicitPrelude #-} 2 | {-# LANGUAGE OverloadedStrings #-} 3 | {-# LANGUAGE TemplateHaskell #-} 4 | 5 | import BasePrelude hiding (Handler, catch, mask, try) 6 | import Data.Time (getCurrentTime) 7 | import Distribution.PackageDescription.TH 8 | (PackageDescription(package), PackageIdentifier(pkgVersion), 9 | packageVariable) 10 | import Language.Haskell.TH (runIO, stringE) 11 | import Options.Applicative 12 | import SchoolOfHaskell.Scheduler 13 | 14 | -- | Main entry point. 15 | main :: IO () 16 | main = (void . join . execParser) optParser 17 | where optParser = 18 | info (helper <*> 19 | subparser (discoverCmd <> profileCmd <> keysCmd <> sessionCmd)) 20 | (fullDesc <> 21 | header ("soh-scheduler " <> packageVersion <> " " <> buildDate) <> 22 | progDesc "School of Haskell Container Scheduler") 23 | discoverCmd = 24 | command "discover" 25 | (info (helper <*> 26 | (startDiscoverEnv <$> regionOpt <*> clusterOpt)) 27 | (fullDesc <> 28 | progDesc "Discover the AWS Credentials")) 29 | keysCmd = 30 | command "keys" 31 | (info (helper <*> 32 | (startKeysEnv <$> accessKeyOpt <*> secretKeyOpt <*> regionOpt <*> clusterOpt)) 33 | (fullDesc <> 34 | progDesc "Use a Specific AWS Key/Secret")) 35 | profileCmd = 36 | command "profile" 37 | (info (helper <*> 38 | (startProfileEnv <$> profileOpt <*> regionOpt <*> clusterOpt)) 39 | (fullDesc <> 40 | progDesc "Use IAM Role Credentials")) 41 | sessionCmd = 42 | command "session" 43 | (info (helper <*> 44 | (startSessionEnv <$> accessKeyOpt <*> secretKeyOpt <*> 45 | sessionTokenOpt <*> regionOpt <*> clusterOpt)) 46 | (fullDesc <> 47 | progDesc "Use an Existing AWS Session")) 48 | profileOpt = 49 | strOption (long "profile" <> 50 | short 'p' <> 51 | metavar "PROFILE" <> 52 | help "AWS IAM Profile Name") 53 | accessKeyOpt = 54 | strOption (long "access-key" <> 55 | short 'a' <> 56 | metavar "ACCESS" <> 57 | help "AWS Access Key ID") 58 | secretKeyOpt = 59 | strOption (long "secret-key" <> 60 | short 's' <> 61 | metavar "SECRET" <> 62 | help "AWS Secret Access Key") 63 | sessionTokenOpt = 64 | strOption (long "session-token" <> 65 | short 't' <> 66 | metavar "TOKEN" <> 67 | help "AWS Session Token") 68 | regionOpt = 69 | strOption (long "region" <> 70 | short 'r' <> 71 | metavar "REGION" <> 72 | help "AWS Region") 73 | clusterOpt = 74 | strOption (long "cluster" <> 75 | short 'c' <> 76 | metavar "CLUSTER" <> 77 | help "AWS ECS Arn") 78 | 79 | -- | Embed the project package version number in the code. 80 | packageVersion :: String 81 | packageVersion = 82 | $(packageVariable (pkgVersion . package)) 83 | 84 | -- | Embed the build date in the code. 85 | buildDate :: String 86 | buildDate = 87 | $(stringE =<< 88 | runIO (show `fmap` Data.Time.getCurrentTime)) 89 | -------------------------------------------------------------------------------- /soh-scheduler/soh-scheduler.cabal: -------------------------------------------------------------------------------- 1 | name: soh-scheduler 2 | version: 0.1.3 3 | license: MIT 4 | license-file: LICENSE 5 | author: FP Complete Developers 6 | maintainer: dev@fpcomplete.com 7 | copyright: 2015 FP Complete Corporation 8 | category: System 9 | build-type: Simple 10 | cabal-version: >=1.10 11 | 12 | Flag no-cache 13 | Description: Disable caching of static files 14 | Default: False 15 | 16 | library 17 | default-language: Haskell2010 18 | hs-source-dirs: src 19 | exposed-modules: SchoolOfHaskell.Scheduler 20 | other-modules: SchoolOfHaskell.Scheduler.Web 21 | SchoolOfHaskell.Scheduler.AWS 22 | SchoolOfHaskell.Scheduler.Types 23 | ghc-options: -Wall 24 | if flag(no-cache) 25 | cpp-options: -DNO_CACHE 26 | build-depends: aeson 27 | , airship 28 | , amazonka 29 | , amazonka-core 30 | , amazonka-ec2 31 | , amazonka-ecs 32 | , base >=4.7 && <5 33 | , base-prelude 34 | , bytestring 35 | , conduit 36 | , conduit-extra 37 | , containers 38 | , exceptions 39 | , http-media 40 | , http-types 41 | , lens 42 | , monad-logger 43 | , mtl 44 | , resourcet 45 | , soh-scheduler-api 46 | , text 47 | , time 48 | , transformers 49 | , unordered-containers 50 | , uuid 51 | , wai 52 | , wai-cors 53 | , warp 54 | 55 | executable sohs 56 | ghc-options: -threaded -O2 -rtsopts -with-rtsopts=-N -Wall 57 | default-language: Haskell2010 58 | hs-source-dirs: main 59 | main-is: Main.hs 60 | build-depends: base >=4.7 && <5 61 | , base-prelude 62 | , cabal-file-th 63 | , optparse-applicative 64 | , soh-scheduler 65 | , template-haskell 66 | , time 67 | -------------------------------------------------------------------------------- /soh-scheduler/src/SchoolOfHaskell/Scheduler.hs: -------------------------------------------------------------------------------- 1 | module SchoolOfHaskell.Scheduler 2 | ( 3 | -- Scheduler Types 4 | Env 5 | , Settings 6 | , Err 7 | -- Container Types 8 | , ContainerDetail 9 | , ContainerId 10 | , ContainerReceipt 11 | , ContainerSpec 12 | -- Scheduler Env 13 | , discoverEnv 14 | , keysEnv 15 | , profileEnv 16 | , sessionEnv 17 | -- Scheduler Settings 18 | , mkSettings 19 | , setCluster 20 | -- Container Spec 21 | , mkContainerSpec 22 | , setImageName 23 | -- Container API 24 | , createContainer 25 | , listContainers 26 | , getContainerDetail 27 | , stopContainer 28 | , cleanupContainers 29 | -- Web Interface API 30 | , startDiscoverEnv 31 | , startKeysEnv 32 | , startProfileEnv 33 | , startSessionEnv 34 | ) where 35 | 36 | import SchoolOfHaskell.Scheduler.API 37 | import SchoolOfHaskell.Scheduler.Types 38 | import SchoolOfHaskell.Scheduler.AWS 39 | import SchoolOfHaskell.Scheduler.Web 40 | -------------------------------------------------------------------------------- /soh-scheduler/src/SchoolOfHaskell/Scheduler/AWS.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE DeriveDataTypeable #-} 2 | {-# LANGUAGE FlexibleContexts #-} 3 | {-# LANGUAGE KindSignatures #-} 4 | {-# LANGUAGE NoImplicitPrelude #-} 5 | {-# LANGUAGE OverloadedStrings #-} 6 | {-# LANGUAGE RankNTypes #-} 7 | {-# LANGUAGE TemplateHaskell #-} 8 | {-# LANGUAGE TupleSections #-} 9 | 10 | module SchoolOfHaskell.Scheduler.AWS where 11 | 12 | import BasePrelude hiding (getEnv, (&)) 13 | import Control.Lens 14 | import Control.Monad.Catch 15 | import Control.Monad.Error 16 | import Control.Monad.Logger 17 | import Control.Monad.Trans.AWS hiding (Env) 18 | import Control.Monad.Trans.Resource 19 | import Data.Conduit 20 | import qualified Data.Conduit.List as Conduit 21 | import Data.Text (Text) 22 | import qualified Data.Text as T 23 | import Data.Text.Encoding (encodeUtf8) 24 | import Data.UUID (UUID) 25 | import qualified Data.UUID as UUID 26 | import qualified Data.UUID.V4 as UUID 27 | import qualified Network.AWS.Data as AWS 28 | import Network.AWS.EC2 29 | import Network.AWS.ECS 30 | import SchoolOfHaskell.Scheduler.API 31 | import SchoolOfHaskell.Scheduler.Types 32 | 33 | discoverEnv :: forall (m :: * -> *). 34 | (Applicative m,Functor m,MonadIO m) 35 | => Text -> m Env 36 | discoverEnv region' = 37 | do parsedRegion <- hoistFromText region' 38 | Env <$> 39 | liftIO (getEnv parsedRegion Discover) 40 | 41 | profileEnv :: forall (m :: * -> *). 42 | (Applicative m,Functor m,MonadIO m) 43 | => Text -> Text -> m Env 44 | profileEnv profile region' = 45 | do parsedRegion <- hoistFromText region' 46 | Env <$> 47 | liftIO (getEnv parsedRegion (FromProfile profile)) 48 | 49 | keysEnv :: forall (m :: * -> *). 50 | (Applicative m,Functor m,MonadIO m) 51 | => Text -> Text -> Text -> m Env 52 | keysEnv access sekret region' = 53 | do parsedRegion <- hoistFromText region' 54 | Env <$> 55 | liftIO (getEnv parsedRegion 56 | (FromKeys (AccessKey (encodeUtf8 access)) 57 | (SecretKey (encodeUtf8 sekret)))) 58 | 59 | sessionEnv :: forall (m :: * -> *). 60 | (Applicative m,Functor m,MonadIO m) 61 | => Text -> Text -> Text -> Text -> m Env 62 | sessionEnv access sekret token region' = 63 | do parsedRegion <- hoistFromText region' 64 | Env <$> 65 | liftIO (getEnv parsedRegion 66 | (FromSession (AccessKey (encodeUtf8 access)) 67 | (SecretKey (encodeUtf8 sekret)) 68 | (SecurityToken (encodeUtf8 token)))) 69 | 70 | mkSettings :: Text -> Env -> Settings 71 | mkSettings = Settings 72 | 73 | setCluster :: Text -> Settings -> Settings 74 | setCluster = set ssCluster 75 | 76 | mkContainerSpec :: Text -> ContainerSpec 77 | mkContainerSpec = ContainerSpec 78 | 79 | setImageName :: Text -> ContainerSpec -> ContainerSpec 80 | setImageName = set csImageName 81 | 82 | createContainer :: forall (m :: * -> *). 83 | (MonadBaseControl IO m,MonadCatch m,MonadIO m) 84 | => Settings 85 | -> ContainerSpec 86 | -> m (Either Err ContainerReceipt) 87 | createContainer settings spec = 88 | do ident <- liftIO UUID.nextRandom 89 | results <- 90 | runAWST (settings ^. ssEnv ^. env) 91 | (send (runTask (spec ^. csImageName) & 92 | (rtStartedBy ?~ 93 | fromString (UUID.toString ident)) & 94 | (rtOverrides ?~ 95 | (taskOverride & toContainerOverrides .~ 96 | [containerOverride & coCommand .~ 97 | [T.pack (UUID.toString ident)]])) & 98 | (rtCount ?~ 1))) 99 | return (either (Left . ContainerProviderErr) 100 | (\r -> 101 | case r ^. rtrFailures of 102 | fs@(_:_) -> 103 | Left (ContainerFailureErr fs) 104 | [] -> 105 | Right (ContainerReceipt ident)) 106 | results) 107 | 108 | listContainers :: forall (m :: * -> *). 109 | (MonadBaseControl IO m,MonadCatch m,MonadIO m) 110 | => Settings -> m (Either Err [ContainerId]) 111 | listContainers settings = 112 | -- ListTasks will only return max 100 hits but also doesn't 113 | -- have an `instance AWSPager` so we have to do our own here. 114 | -- http://docs.aws.amazon.com/AmazonECS/latest/APIReference/API_ListTasks.html 115 | let req = 116 | listTasks & 117 | (ltCluster ?~ 118 | (settings ^. ssCluster)) 119 | in pager req [] =<< 120 | runAWST (settings ^. ssEnv ^. env) 121 | (send req) 122 | where pager _ _ (Left err) = 123 | return (Left (ContainerProviderErr err)) 124 | pager req accum (Right result) 125 | | isJust (result ^. ltrNextToken) = 126 | pager req 127 | (accum ++ 128 | map ContainerId (result ^. ltrTaskArns)) =<< 129 | runAWST (settings ^. ssEnv ^. env) 130 | (send (req & 131 | (ltNextToken .~ 132 | (result ^. ltrNextToken)))) 133 | pager _ accum (Right result) = 134 | return (Right (accum ++ 135 | map ContainerId (result ^. ltrTaskArns))) 136 | 137 | class ContainerBy a where 138 | getContainerDetail :: forall (m :: * -> *). 139 | (MonadBaseControl IO m,MonadCatch m,MonadIO m,MonadLogger m) 140 | => Settings 141 | -> a 142 | -> m (Either Err ContainerDetail) 143 | stopContainer :: forall (m :: * -> *). 144 | (MonadBaseControl IO m,MonadCatch m,MonadIO m,MonadLogger m) 145 | => Settings -> a -> m (Either Err ()) 146 | 147 | instance ContainerBy ContainerReceipt where 148 | getContainerDetail settings (ContainerReceipt ident) = 149 | do cid <- 150 | getContainerIdFromUUID settings ident 151 | case cid of 152 | Left e -> return (Left e) 153 | Right cid' -> 154 | getContainerDetail settings cid' 155 | stopContainer settings (ContainerReceipt ident) = 156 | do cid <- 157 | getContainerIdFromUUID settings ident 158 | case cid of 159 | Left e -> return (Left e) 160 | Right cid' -> 161 | stopContainer settings cid' 162 | 163 | instance ContainerBy ContainerId where 164 | getContainerDetail settings (ContainerId ident) = 165 | do descrResult <- 166 | runAWST (settings ^. ssEnv ^. env) 167 | (send (describeTasks & 168 | (dtCluster ?~ 169 | (settings ^. ssCluster)) & 170 | (dtTasks .~ 171 | [ident]))) 172 | case descrResult of 173 | Left e -> 174 | return (Left (ContainerProviderErr e)) 175 | Right tasks 176 | | (tasks ^. dtrTasks) == 177 | [] -> 178 | return (Left (ContainerAbsentErr)) 179 | Right tasks -> 180 | case tasks ^. dtrFailures of 181 | fs@(_:_) -> 182 | return (Left (ContainerFailureErr fs)) 183 | [] -> 184 | case tasks ^. dtrTasks of 185 | [] -> 186 | return (Left (ContainerAbsentErr)) 187 | (t:ts) -> 188 | do unless (length ts == 0) 189 | ($logWarn ("More than 1 AWS ECS Task " <> 190 | "for SoH container " <> ident <> " " <> 191 | fromString (show (t : ts)))) 192 | addrPort <- 193 | addrPortForTask settings t ident 194 | return (either Left 195 | (\a -> 196 | Right (ContainerDetail ident 197 | a 198 | (t ^. tLastStatus))) 199 | addrPort) 200 | stopContainer settings (ContainerId ident) = 201 | do result <- 202 | runAWST (settings ^. ssEnv ^. env) 203 | (send (stopTask ident & 204 | (stCluster ?~ 205 | (settings ^. ssCluster)))) 206 | case result of 207 | Left e -> 208 | return (Left (ContainerProviderErr e)) 209 | Right _ -> return (Right ()) 210 | 211 | cleanupContainers :: forall (m :: * -> *). 212 | (MonadBaseControl IO m,MonadCatch m,MonadIO m) 213 | => Settings -> m () 214 | cleanupContainers se = error "Not Implemented!" 215 | 216 | ------------------------------------------------------------------------------ 217 | 218 | addrPortForTask :: forall (m :: * -> *). 219 | (MonadBaseControl IO m,MonadCatch m,MonadIO m,MonadLogger m) 220 | => Settings 221 | -> Task 222 | -> Text 223 | -> m (Either Err (Maybe (Text,PortMappings))) 224 | addrPortForTask settings task' ident = 225 | do results <- 226 | runAWST (settings ^. ssEnv ^. env) 227 | (do let instanceArns = 228 | toListOf (tContainerInstanceArn . folded) task' 229 | instanceIds <- 230 | toListOf ((dcirContainerInstances . folded) . 231 | (ciEc2InstanceId . folded)) <$> 232 | send (describeContainerInstances & 233 | (dciContainerInstances .~ instanceArns)) 234 | paginate (describeInstances & 235 | (di1InstanceIds .~ instanceIds)) $$ 236 | Conduit.concatMap 237 | (toListOf ((dirReservations . folded) . 238 | (rInstances . folded) . 239 | (i1PublicIpAddress . folded))) =$= 240 | Conduit.consume) 241 | case results of 242 | Left e -> 243 | return (Left (ContainerProviderErr e)) 244 | Right [] -> return (Right Nothing) 245 | Right (host:hosts) -> 246 | do unless (null hosts) 247 | ($logWarn ("More than 1 AWS ECS EC2 Instance" <> 248 | "for SoH container " <> ident <> " " <> 249 | fromString (show (host : hosts)))) 250 | let bs = toListOf ((tContainers . folded) . 251 | (cNetworkBindings . folded)) 252 | task' 253 | pms = mapMaybe (\x -> (,) <$> x ^. nbHostPort 254 | <*> x ^. nbContainerPort) 255 | bs 256 | return (Right (Just (host, PortMappings pms))) 257 | 258 | hoistFromText :: forall (m :: * -> *) a. 259 | (Applicative m,AWS.FromText a) 260 | => Text -> m a 261 | hoistFromText txt = 262 | case AWS.fromText txt of 263 | Left e -> throw (ParseErr e) 264 | Right x -> pure x 265 | 266 | getContainerIdFromUUID :: forall (m :: * -> *). 267 | (MonadBaseControl IO m,MonadCatch m,MonadIO m,MonadLogger m) 268 | => Settings -> UUID -> m (Either Err ContainerId) 269 | getContainerIdFromUUID settings ident = 270 | do let txt = fromString (UUID.toString ident) 271 | arnsResult <- 272 | runAWST (settings ^. ssEnv ^. env) 273 | (view ltrTaskArns <$> 274 | send (listTasks & 275 | (ltCluster ?~ 276 | (settings ^. ssCluster)) & 277 | (ltStartedBy ?~ txt))) 278 | -- NOTE: we don't care about pagination here. We only wanted 1 279 | -- Task for the UUID. "Pages" of results isn't our expectation & 280 | -- we would consider any result with >1 Task suspect. 281 | case arnsResult of 282 | Left err -> 283 | return (Left (ContainerProviderErr err)) 284 | Right [] -> 285 | return (Left (ContainerAbsentErr)) 286 | Right (arn:arns) -> 287 | do unless (null arns) 288 | ($logWarn ("More than 1 AWS ECS Task " <> 289 | "for SoH container " <> txt <> " " <> 290 | fromString (show (arn : arns)))) 291 | return (Right (ContainerId arn)) 292 | -------------------------------------------------------------------------------- /soh-scheduler/src/SchoolOfHaskell/Scheduler/Types.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE DeriveDataTypeable #-} 2 | {-# LANGUAGE NoImplicitPrelude #-} 3 | {-# LANGUAGE TemplateHaskell #-} 4 | 5 | module SchoolOfHaskell.Scheduler.Types where 6 | 7 | import BasePrelude 8 | import Control.Lens 9 | import qualified Control.Monad.Trans.AWS as AWS 10 | import Data.Text (Text) 11 | import Network.AWS.ECS 12 | 13 | newtype Env = 14 | Env {_env :: AWS.Env} 15 | 16 | $(makeLenses ''Env) 17 | 18 | data Settings = 19 | Settings {_ssCluster :: Text 20 | ,_ssEnv :: Env} 21 | 22 | $(makeLenses ''Settings) 23 | 24 | data Err 25 | = ContainerAbsentErr 26 | | ContainerProviderErr AWS.Error 27 | | ContainerHostMissingErr 28 | | ContainerPortMissingErr 29 | | ContainerFailureErr [Failure] 30 | | ParseErr String 31 | deriving (Show,Typeable) 32 | 33 | instance Exception Err 34 | -------------------------------------------------------------------------------- /soh-scheduler/src/SchoolOfHaskell/Scheduler/Web.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE CPP #-} 2 | {-# LANGUAGE KindSignatures #-} 3 | {-# LANGUAGE NoImplicitPrelude #-} 4 | {-# LANGUAGE OverloadedStrings #-} 5 | {-# LANGUAGE RankNTypes #-} 6 | {-# LANGUAGE TemplateHaskell #-} 7 | 8 | module SchoolOfHaskell.Scheduler.Web where 9 | 10 | import Airship 11 | import Airship.Resource.Static (StaticOptions(..), staticResource) 12 | import BasePrelude hiding (Handler, catch, mask, try) 13 | import Control.Lens 14 | import Control.Monad.IO.Class 15 | import Control.Monad.Logger 16 | import Control.Monad.Trans.Class (lift) 17 | import Data.Aeson (encode, eitherDecode) 18 | import Data.ByteString.Builder (lazyByteString) 19 | import qualified Data.HashMap.Strict as HM 20 | import Data.Text (Text) 21 | import qualified Data.Text as T 22 | import Data.Time.Clock 23 | import qualified Data.UUID as UUID 24 | import Network.HTTP.Media (MediaType) 25 | import qualified Network.HTTP.Types as HTTP 26 | import Network.Wai.Handler.Warp 27 | (runSettings, defaultSettings, setPort, setHost) 28 | import SchoolOfHaskell.Scheduler.API 29 | import SchoolOfHaskell.Scheduler.Types 30 | import SchoolOfHaskell.Scheduler.AWS 31 | import Network.Wai.Middleware.Cors (CorsResourcePolicy(..), cors, simpleCorsResourcePolicy) 32 | import qualified Network.Wai as Wai 33 | 34 | data State = 35 | State {_sSettings :: Settings} 36 | $(makeLenses ''State) 37 | 38 | startDiscoverEnv :: String -> String -> IO () 39 | startDiscoverEnv region cluster = 40 | start (fromString cluster) =<< 41 | discoverEnv (fromString region) 42 | 43 | startProfileEnv :: String -> String -> String -> IO () 44 | startProfileEnv profile region cluster = 45 | start (fromString cluster) =<< 46 | profileEnv (fromString profile) (fromString region) 47 | 48 | startKeysEnv :: String -> String -> String -> String -> IO () 49 | startKeysEnv access secret region cluster = 50 | start (fromString cluster) =<< 51 | keysEnv (fromString access) (fromString secret) (fromString region) 52 | 53 | startSessionEnv :: String -> String -> String -> String -> String -> IO () 54 | startSessionEnv access secret token region cluster = 55 | start (fromString cluster) =<< 56 | sessionEnv (fromString access) 57 | (fromString secret) 58 | (fromString token) 59 | (fromString region) 60 | 61 | ------------------------------------------------------------------------------ 62 | 63 | start :: Text -> Env -> IO () 64 | start ecs env' = 65 | do static <- staticResource staticOptions "static" 66 | let state = 67 | State (mkSettings ecs env') 68 | runSettings 69 | (setPort 3000 (setHost "0.0.0.0" defaultSettings)) 70 | (cors corsPolicy 71 | (resourceToWai 72 | (do "static" star #> static 73 | "containers" #> containerIndex 74 | "containers" 75 | var "id" #> 76 | containerDetail) 77 | resource404 78 | state)) 79 | 80 | -- This allows pages served by other domains to access this API. 81 | -- 82 | -- Note: It doesn't seem to be necessary, but ideally this would do 83 | -- per-route computation of fields like 'corsMethods' 84 | corsPolicy :: Wai.Request -> Maybe CorsResourcePolicy 85 | corsPolicy _ = Just $ simpleCorsResourcePolicy 86 | { corsMethods = HTTP.methodDelete : corsMethods simpleCorsResourcePolicy 87 | , corsRequestHeaders = "Content-Type" : corsRequestHeaders simpleCorsResourcePolicy 88 | } 89 | 90 | containerIndex :: forall m. 91 | MonadIO m 92 | => Resource State m 93 | containerIndex = 94 | resource {allowedMethods = 95 | return [HTTP.methodGet,HTTP.methodPost] 96 | ,contentTypesAccepted = 97 | return [(jsonMIME,return ())] 98 | ,contentTypesProvided = 99 | let cIndex = 100 | do state <- getState 101 | results <- 102 | liftIO (runStdoutLoggingT 103 | (listContainers (state ^. sSettings))) 104 | case results of 105 | Left e -> 106 | do putResponseBody (ResponseBuilder (fromString (show e))) 107 | halt HTTP.status502 108 | Right ids -> 109 | return (ResponseBuilder (lazyByteString (encode ids))) 110 | in return [(jsonMIME,cIndex)] 111 | ,processPost = 112 | do state <- getState 113 | req <- request 114 | body <- 115 | lift (entireRequestBody req) 116 | case eitherDecode body of 117 | Left e -> 118 | do putResponseBody (ResponseBuilder (fromString (show e))) 119 | halt HTTP.status400 120 | Right spec' -> 121 | do receipt <- 122 | liftIO (runStdoutLoggingT 123 | (createContainer 124 | (state ^. sSettings) 125 | spec')) 126 | case receipt of 127 | Left e -> 128 | do putResponseBody (ResponseBuilder (fromString (show e))) 129 | halt HTTP.status502 130 | Right receipt' -> 131 | do putResponseBody (ResponseBuilder (lazyByteString (encode receipt'))) 132 | return (PostProcess (return ()))} 133 | 134 | containerDetail :: forall m. 135 | MonadIO m 136 | => Resource State m 137 | containerDetail = 138 | resource {allowedMethods = 139 | return [HTTP.methodGet,HTTP.methodDelete] 140 | ,contentTypesProvided = 141 | do let cIndex = 142 | do state <- getState 143 | results <- 144 | withReceiptOrId 145 | (\id' -> 146 | liftIO (runStdoutLoggingT 147 | (getContainerDetail 148 | (state ^. sSettings) 149 | id'))) 150 | (\rcpt -> 151 | liftIO (runStdoutLoggingT 152 | (getContainerDetail 153 | (state ^. sSettings) 154 | rcpt))) 155 | case results of 156 | Left e -> 157 | do putResponseBody (ResponseBuilder (fromString (show e))) 158 | halt HTTP.status502 159 | Right detail -> 160 | return (ResponseBuilder (lazyByteString (encode detail))) 161 | return [(jsonMIME,cIndex)] 162 | ,deleteResource = 163 | do do state <- getState 164 | results <- 165 | withReceiptOrId 166 | (\id' -> 167 | liftIO (runStdoutLoggingT 168 | (stopContainer (state ^. sSettings) 169 | id'))) 170 | (\rcpt -> 171 | liftIO (runStdoutLoggingT 172 | (stopContainer (state ^. sSettings) 173 | rcpt))) 174 | case results of 175 | Left e -> 176 | do putResponseBody (ResponseBuilder (fromString (show e))) 177 | return False 178 | Right _ -> return True} 179 | 180 | resource :: forall s m. 181 | MonadIO m 182 | => Resource s m 183 | resource = 184 | defaultResource {knownContentType = 185 | contentTypeMatches [jsonMIME] 186 | ,lastModified = Just <$> liftIO getCurrentTime} 187 | 188 | resource404 :: forall s m. 189 | Resource s m 190 | resource404 = 191 | defaultResource {knownContentType = 192 | contentTypeMatches [jsonMIME] 193 | ,resourceExists = return False} 194 | 195 | jsonMIME :: MediaType 196 | jsonMIME = "application/json" 197 | 198 | withReceiptOrId :: forall a (m :: * -> *). 199 | Monad m 200 | => (ContainerReceipt -> Webmachine State m a) 201 | -> (ContainerId -> Webmachine State m a) 202 | -> Webmachine State m a 203 | withReceiptOrId forReceipt forId = 204 | do p <- params 205 | let id' = p HM.! "id" 206 | case (UUID.fromString (T.unpack id')) of 207 | Nothing -> forId (ContainerId id') 208 | Just rcpt -> 209 | forReceipt (ContainerReceipt rcpt) 210 | 211 | staticOptions :: StaticOptions 212 | staticOptions = 213 | #if NO_CACHE 214 | NoCache 215 | #else 216 | Cache 217 | #endif 218 | -------------------------------------------------------------------------------- /soh-scheduler/stack.yaml: -------------------------------------------------------------------------------- 1 | flags: {} 2 | packages: 3 | - '.' 4 | extra-deps: [ airship-0.3.0.0 5 | , wai-cors-0.2.3 6 | ] 7 | resolver: lts-2.14 8 | packages: 9 | - ../soh-scheduler-api 10 | -------------------------------------------------------------------------------- /stack.yaml: -------------------------------------------------------------------------------- 1 | resolver: lts-3.6 2 | extra-deps: 3 | - wai-cors-0.2.3 4 | packages: 5 | - ./deps/stack-ide/stack-ide 6 | - ./deps/stack-ide/stack-ide-api 7 | - ./deps/stack-ide/ide-backend/ide-backend 8 | - ./deps/stack-ide/ide-backend/ide-backend-server 9 | - ./deps/stack-ide/ide-backend/ide-backend-common 10 | - ./soh-runner 11 | - ./soh-runner-api 12 | - ./soh-scheduler 13 | - ./soh-scheduler-api 14 | # FIXME: turn this into an extra-dep once 15 | # https://github.com/helium/airship/pull/47 is merged and released to hackage. 16 | - location: 17 | git: git@github.com:mgsloan/airship 18 | commit: 8dd9d28ae3cce4dee27aa751b98762e22df3aaec 19 | --------------------------------------------------------------------------------