From cc5550dcfa62c2058cde2ad9a5efd5b150985d4b Mon Sep 17 00:00:00 2001 From: x1unix Date: Mon, 20 Jan 2020 18:31:53 +0200 Subject: [PATCH 01/17] server: do not log program syntax error from GoPlayground --- pkg/goplay/errors.go | 14 ++++++++++++++ pkg/goplay/types.go | 2 +- pkg/langserver/server.go | 10 +++++++++- 3 files changed, 24 insertions(+), 2 deletions(-) create mode 100644 pkg/goplay/errors.go diff --git a/pkg/goplay/errors.go b/pkg/goplay/errors.go new file mode 100644 index 00000000..41d1a596 --- /dev/null +++ b/pkg/goplay/errors.go @@ -0,0 +1,14 @@ +package goplay + +type CompileFailedError struct { + msg string +} + +func (c CompileFailedError) Error() string { + return c.msg +} + +func IsCompileError(err error) bool { + _, ok := err.(CompileFailedError) + return ok +} diff --git a/pkg/goplay/types.go b/pkg/goplay/types.go index 8ffdc4c4..1b9d9d20 100644 --- a/pkg/goplay/types.go +++ b/pkg/goplay/types.go @@ -17,7 +17,7 @@ func (r *FmtResponse) HasError() error { return nil } - return errors.New(r.Error) + return CompileFailedError{msg: r.Error} } // CompileEvent represents individual diff --git a/pkg/langserver/server.go b/pkg/langserver/server.go index f6f54963..68af5e51 100644 --- a/pkg/langserver/server.go +++ b/pkg/langserver/server.go @@ -107,7 +107,7 @@ func (s *Service) goImportsCode(w http.ResponseWriter, r *http.Request) ([]byte, } if err = resp.HasError(); err != nil { - Errorf(http.StatusBadRequest, err.Error()) + Errorf(http.StatusBadRequest, err.Error()).Write(w) return nil, err, false } @@ -118,6 +118,10 @@ func (s *Service) goImportsCode(w http.ResponseWriter, r *http.Request) ([]byte, func (s *Service) FormatCode(w http.ResponseWriter, r *http.Request) { code, err, _ := s.goImportsCode(w, r) if err != nil { + if goplay.IsCompileError(err) { + return + } + s.log.Error(err) return } @@ -128,6 +132,10 @@ func (s *Service) FormatCode(w http.ResponseWriter, r *http.Request) { func (s *Service) Compile(w http.ResponseWriter, r *http.Request) { src, err, changed := s.goImportsCode(w, r) if err != nil { + if goplay.IsCompileError(err) { + return + } + s.log.Error(err) return } From df9a17c371305a6e4b07b1844a599b5d9e9e15d7 Mon Sep 17 00:00:00 2001 From: x1unix Date: Mon, 20 Jan 2020 18:33:17 +0200 Subject: [PATCH 02/17] server: remove redundant debug log lines --- pkg/goplay/client.go | 2 -- pkg/langserver/server.go | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/pkg/goplay/client.go b/pkg/goplay/client.go index 89486ade..e31e14da 100644 --- a/pkg/goplay/client.go +++ b/pkg/goplay/client.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "fmt" - "go.uber.org/zap" "io" "net/http" "net/url" @@ -24,7 +23,6 @@ var ErrSnippetTooLarge = fmt.Errorf("code snippet too large (max %d bytes)", max func doRequest(ctx context.Context, method, url, contentType string, body io.Reader) ([]byte, error) { url = goPlayURL + "/" + url - zap.S().Debug("doRequest ", url) req, err := http.NewRequestWithContext(ctx, method, url, body) if err != nil { return nil, err diff --git a/pkg/langserver/server.go b/pkg/langserver/server.go index 68af5e51..04e8f674 100644 --- a/pkg/langserver/server.go +++ b/pkg/langserver/server.go @@ -157,6 +157,6 @@ func (s *Service) Compile(w http.ResponseWriter, r *http.Request) { result.Formatted = string(src) } - s.log.Debugw("resp from compiler", "res", res) + s.log.Debugw("response from compiler", "res", res) WriteJSON(w, result) } From 0b36ded99a2afb2719627a5722d3fb49e0e7a3a8 Mon Sep 17 00:00:00 2001 From: x1unix Date: Mon, 20 Jan 2020 18:51:09 +0200 Subject: [PATCH 03/17] server: add share handler --- pkg/goplay/client.go | 9 +++++---- pkg/goplay/methods.go | 24 ++++++++++++++++++++++++ pkg/langserver/request.go | 4 ++++ pkg/langserver/server.go | 17 +++++++++++++++++ 4 files changed, 50 insertions(+), 4 deletions(-) diff --git a/pkg/goplay/client.go b/pkg/goplay/client.go index e31e14da..f90ea0f4 100644 --- a/pkg/goplay/client.go +++ b/pkg/goplay/client.go @@ -34,15 +34,16 @@ func doRequest(ctx context.Context, method, url, contentType string, body io.Rea return nil, err } - var bodyBytes bytes.Buffer - _, err = io.Copy(&bodyBytes, io.LimitReader(response.Body, maxSnippetSize+1)) + bodyBytes := &bytes.Buffer{} + _, err = io.Copy(bodyBytes, io.LimitReader(response.Body, maxSnippetSize+1)) defer response.Body.Close() if err != nil { return nil, err } - if bodyBytes.Len() > maxSnippetSize { - return nil, ErrSnippetTooLarge + if err = ValidateContentLength(bodyBytes); err != nil { + return nil, err } + return bodyBytes.Bytes(), nil } diff --git a/pkg/goplay/methods.go b/pkg/goplay/methods.go index bc72e078..44895624 100644 --- a/pkg/goplay/methods.go +++ b/pkg/goplay/methods.go @@ -5,9 +5,33 @@ import ( "encoding/json" "fmt" "go.uber.org/zap" + "io" + "net/http" "net/url" ) +type lener interface { + Len() int +} + +func ValidateContentLength(r lener) error { + if r.Len() > maxSnippetSize { + return ErrSnippetTooLarge + } + + return nil +} + +func Share(ctx context.Context, src io.Reader) (string, error) { + resp, err := doRequest(ctx, http.MethodPost, "share", "text/plain", src) + if err != nil { + return "", err + } + + shareID := string(resp) + return shareID, nil +} + func GoImports(ctx context.Context, src []byte) (*FmtResponse, error) { form := url.Values{} form.Add("imports", "true") diff --git a/pkg/langserver/request.go b/pkg/langserver/request.go index 7b62caf1..e6f2c23d 100644 --- a/pkg/langserver/request.go +++ b/pkg/langserver/request.go @@ -13,6 +13,10 @@ import ( "github.com/x1unix/go-playground/pkg/analyzer" ) +type ShareResponse struct { + SnippetID string `json:"snippetID"` +} + type SuggestionRequest struct { PackageName string `json:"packageName"` Value string `json:"value"` diff --git a/pkg/langserver/server.go b/pkg/langserver/server.go index 04e8f674..a756d67a 100644 --- a/pkg/langserver/server.go +++ b/pkg/langserver/server.go @@ -27,6 +27,7 @@ func (s *Service) Mount(r *mux.Router) { r.Path("/suggest").HandlerFunc(s.GetSuggestion) r.Path("/compile").Methods(http.MethodPost).HandlerFunc(s.Compile) r.Path("/format").Methods(http.MethodPost).HandlerFunc(s.FormatCode) + r.Path("/share").Methods(http.MethodPost).HandlerFunc(s.Share) } func (s *Service) lookupBuiltin(val string) (*SuggestionsResponse, error) { @@ -129,6 +130,22 @@ func (s *Service) FormatCode(w http.ResponseWriter, r *http.Request) { WriteJSON(w, CompilerResponse{Formatted: string(code)}) } +func (s *Service) Share(w http.ResponseWriter, r *http.Request) { + shareID, err := goplay.Share(r.Context(), r.Body) + defer r.Body.Close() + if err != nil { + if err == goplay.ErrSnippetTooLarge { + Errorf(http.StatusRequestEntityTooLarge, err.Error()).Write(w) + return + } + + s.log.Error("failed to share code: ", err) + NewErrorResponse(err).Write(w) + } + + WriteJSON(w, ShareResponse{SnippetID: shareID}) +} + func (s *Service) Compile(w http.ResponseWriter, r *http.Request) { src, err, changed := s.goImportsCode(w, r) if err != nil { From 8ba1736da4d69610634528d932ab0dae5c93d37d Mon Sep 17 00:00:00 2001 From: x1unix Date: Mon, 20 Jan 2020 21:46:26 +0200 Subject: [PATCH 04/17] server: add get snippet action --- cmd/playground/main.go | 2 +- pkg/goplay/client.go | 24 +++++++++++++++++++++--- pkg/goplay/errors.go | 4 ++++ pkg/goplay/methods.go | 23 +++++++++++++++++++++++ pkg/langserver/server.go | 22 ++++++++++++++++++++++ 5 files changed, 71 insertions(+), 4 deletions(-) diff --git a/cmd/playground/main.go b/cmd/playground/main.go index 0e0b521a..eeb4198f 100644 --- a/cmd/playground/main.go +++ b/cmd/playground/main.go @@ -68,7 +68,7 @@ func start(packagesFile, addr, goRoot string, debug bool) error { var handler http.Handler if debug { - zap.S().Warn("Debug mode enabled, CORS disabled") + zap.S().Info("Debug mode enabled, CORS disabled") handler = langserver.NewCORSDisablerWrapper(r) } else { handler = r diff --git a/pkg/goplay/client.go b/pkg/goplay/client.go index f90ea0f4..dba3113e 100644 --- a/pkg/goplay/client.go +++ b/pkg/goplay/client.go @@ -21,14 +21,32 @@ const ( var ErrSnippetTooLarge = fmt.Errorf("code snippet too large (max %d bytes)", maxSnippetSize) +func newRequest(ctx context.Context, method, queryPath string, body io.Reader) (*http.Request, error) { + uri := goPlayURL + "/" + queryPath + req, err := http.NewRequestWithContext(ctx, method, uri, body) + if err != nil { + return nil, err + } + + req.Header.Add("User-Agent", userAgent) + return req, nil +} + +func getRequest(ctx context.Context, queryPath string) (*http.Response, error) { + req, err := newRequest(ctx, http.MethodGet, queryPath, nil) + if err != nil { + return nil, nil + } + + return http.DefaultClient.Do(req) +} + func doRequest(ctx context.Context, method, url, contentType string, body io.Reader) ([]byte, error) { - url = goPlayURL + "/" + url - req, err := http.NewRequestWithContext(ctx, method, url, body) + req, err := newRequest(ctx, method, url, body) if err != nil { return nil, err } req.Header.Add("Content-Type", contentType) - req.Header.Add("User-Agent", userAgent) response, err := http.DefaultClient.Do(req) if err != nil { return nil, err diff --git a/pkg/goplay/errors.go b/pkg/goplay/errors.go index 41d1a596..92af7464 100644 --- a/pkg/goplay/errors.go +++ b/pkg/goplay/errors.go @@ -1,5 +1,9 @@ package goplay +import "errors" + +var ErrSnippetNotFound = errors.New("snippet not found") + type CompileFailedError struct { msg string } diff --git a/pkg/goplay/methods.go b/pkg/goplay/methods.go index 44895624..35252cb6 100644 --- a/pkg/goplay/methods.go +++ b/pkg/goplay/methods.go @@ -6,6 +6,7 @@ import ( "fmt" "go.uber.org/zap" "io" + "io/ioutil" "net/http" "net/url" ) @@ -22,6 +23,28 @@ func ValidateContentLength(r lener) error { return nil } +func GetSnippet(ctx context.Context, snippetID string) ([]byte, error) { + resp, err := getRequest(ctx, "p/"+snippetID+".go") + if err != nil { + return nil, err + } + + defer resp.Body.Close() + switch resp.StatusCode { + case http.StatusOK: + snippet, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + return snippet, nil + case http.StatusNotFound: + return nil, ErrSnippetNotFound + default: + return nil, fmt.Errorf("error from Go Playground server - %d %s", resp.StatusCode, resp.Status) + } +} + func Share(ctx context.Context, src io.Reader) (string, error) { resp, err := doRequest(ctx, http.MethodPost, "share", "text/plain", src) if err != nil { diff --git a/pkg/langserver/server.go b/pkg/langserver/server.go index a756d67a..30748f6b 100644 --- a/pkg/langserver/server.go +++ b/pkg/langserver/server.go @@ -28,6 +28,7 @@ func (s *Service) Mount(r *mux.Router) { r.Path("/compile").Methods(http.MethodPost).HandlerFunc(s.Compile) r.Path("/format").Methods(http.MethodPost).HandlerFunc(s.FormatCode) r.Path("/share").Methods(http.MethodPost).HandlerFunc(s.Share) + r.Path("/snippet/{id}").Methods(http.MethodGet).HandlerFunc(s.GetSnippet) } func (s *Service) lookupBuiltin(val string) (*SuggestionsResponse, error) { @@ -146,6 +147,27 @@ func (s *Service) Share(w http.ResponseWriter, r *http.Request) { WriteJSON(w, ShareResponse{SnippetID: shareID}) } +func (s *Service) GetSnippet(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + snippetID := vars["id"] + snippet, err := goplay.GetSnippet(r.Context(), snippetID) + if err != nil { + if err == goplay.ErrSnippetNotFound { + Errorf(http.StatusNotFound, "snippet %q not found", snippetID).Write(w) + return + } + + s.log.Errorw("failed to get snippet", + "snippetID", snippetID, + "err", err, + ) + NewErrorResponse(err).Write(w) + return + } + + w.Write(snippet) +} + func (s *Service) Compile(w http.ResponseWriter, r *http.Request) { src, err, changed := s.goImportsCode(w, r) if err != nil { From 44c2da866e5c381c919c4ecbce6aad96326ce4c7 Mon Sep 17 00:00:00 2001 From: x1unix Date: Tue, 21 Jan 2020 03:50:57 +0200 Subject: [PATCH 05/17] ui: use redux-thunk for non-pure actions --- web/package.json | 3 + web/src/App.tsx | 36 +++++----- web/src/Header.tsx | 69 ++++++++------------ web/src/Playground.css | 10 +++ web/src/Playground.tsx | 15 +++++ web/src/Preview.tsx | 4 +- web/src/editor/CodeEditor.tsx | 9 ++- web/src/editor/actions.ts | 65 ------------------- web/src/services/config.ts | 15 ++++- web/src/services/routes.ts | 10 +++ web/src/store/actions.ts | 39 ++++++++++- web/src/store/configure.ts | 26 ++++++++ web/src/store/dispatch.ts | 110 +++++++++++++++++++++---------- web/src/store/helpers.ts | 20 ++++++ web/src/store/index.ts | 5 +- web/src/store/reducers.ts | 106 +++++++++++++++++------------- web/src/store/state.ts | 27 ++++---- web/yarn.lock | 119 ++++++++++++++++++++++++++++++++-- 18 files changed, 459 insertions(+), 229 deletions(-) create mode 100644 web/src/Playground.css create mode 100644 web/src/Playground.tsx delete mode 100644 web/src/editor/actions.ts create mode 100644 web/src/services/routes.ts create mode 100644 web/src/store/configure.ts create mode 100644 web/src/store/helpers.ts diff --git a/web/package.json b/web/package.json index b99c8044..d0c554d4 100644 --- a/web/package.json +++ b/web/package.json @@ -8,6 +8,7 @@ "@testing-library/user-event": "^7.1.2", "axios": "^0.19.1", "circular-dependency-plugin": "^5.2.0", + "connected-react-router": "^6.6.1", "file-saver": "^2.0.2", "monaco-editor": "^0.19.3", "monaco-editor-webpack-plugin": "^1.8.2", @@ -17,8 +18,10 @@ "react-dom": "^16.12.0", "react-monaco-editor": "^0.33.0", "react-redux": "^7.1.3", + "react-router-dom": "^5.1.2", "react-scripts": "3.3.0", "redux": "^4.0.5", + "redux-thunk": "^2.3.0", "typescript": "^3.7.4" }, "scripts": { diff --git a/web/src/App.tsx b/web/src/App.tsx index 742c5d91..5e454a77 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,30 +1,30 @@ import React from 'react'; import { Provider } from 'react-redux'; -import { loadTheme } from '@uifabric/styling'; -import {Fabric} from 'office-ui-fabric-react/lib/Fabric' +import {Fabric} from 'office-ui-fabric-react/lib/Fabric'; +import { ConnectedRouter } from 'connected-react-router'; +import {BrowserRouter as Router, Switch, Route} from "react-router-dom"; -import { store } from './store'; -import { Header } from './Header'; -import { LightTheme, DarkTheme } from './services/colors'; -import CodeEditor from './editor/CodeEditor'; +import { configureStore } from './store'; +import { history } from './store/configure'; +import Playground from './Playground'; import './App.css'; -import Preview from './Preview'; +import config from './services/config' -const changeFabricTheme = () => { - const { darkMode } = store.getState(); - loadTheme(darkMode ? DarkTheme : LightTheme); -}; -store.subscribe(changeFabricTheme); -changeFabricTheme(); +const store = configureStore(); +config.sync(); function App() { return ( - -
- - - + + + + + + + + + ); } diff --git a/web/src/Header.tsx b/web/src/Header.tsx index 87188037..d196c0f4 100644 --- a/web/src/Header.tsx +++ b/web/src/Header.tsx @@ -2,17 +2,18 @@ import React from 'react'; import './Header.css' import { CommandBar, ICommandBarItemProps } from 'office-ui-fabric-react/lib/CommandBar'; import { Spinner, SpinnerSize } from 'office-ui-fabric-react/lib/Spinner'; -import * as actions from './editor/actions'; -import { Connect, dispatchToggleTheme } from './store'; import { getTheme } from '@uifabric/styling'; +import { + Connect, + newImportFileDispatcher, + formatFileDispatcher, + runFileDispatcher, + saveFileDispatcher, + dispatchToggleTheme +} from './store'; - -interface HeaderState { - loading: boolean -} - -@Connect(s => ({darkMode: s.darkMode})) -export class Header extends React.Component<{darkMode?: boolean}, HeaderState> { +@Connect(s => ({darkMode: s.settings.darkMode, loading: s.status?.loading})) +export class Header extends React.Component { private fileInput?: HTMLInputElement; constructor(props) { @@ -29,22 +30,13 @@ export class Header extends React.Component<{darkMode?: boolean}, HeaderState> { this.fileInput = fileElement; } - async onItemSelect() { + onItemSelect() { const file = this.fileInput?.files?.item(0); if (!file) { return; } - try { - await actions.loadFile(file); - } catch (err) { - console.error(err); - } - } - - onFileSave() { - actions.saveEditorContents() - .catch(err => console.error('failed to save file: %s', err)) + this.props.dispatch(newImportFileDispatcher(file)); } get menuItems(): ICommandBarItemProps[] { @@ -55,6 +47,16 @@ export class Header extends React.Component<{darkMode?: boolean}, HeaderState> { iconProps: {iconName: 'OpenFile'}, onClick: () => this.fileInput?.click(), }, + { + key: 'run', + text: 'Run', + ariaLabel: 'Run', + iconProps: {iconName: 'Play'}, + disabled: this.props.loading, + onClick: () => { + this.props.dispatch(runFileDispatcher); + } + }, { key: 'share', text: 'Share', @@ -66,7 +68,7 @@ export class Header extends React.Component<{darkMode?: boolean}, HeaderState> { text: 'Download', iconProps: {iconName: 'Download'}, onClick: () => { - this.onFileSave(); + this.props.dispatch(saveFileDispatcher); }, } ]; @@ -74,30 +76,15 @@ export class Header extends React.Component<{darkMode?: boolean}, HeaderState> { get asideItems(): ICommandBarItemProps[] { return [ - { - key: 'run', - text: 'Run', - ariaLabel: 'Run', - iconOnly: true, - iconProps: {iconName: 'Play'}, - disabled: this.state.loading, - onClick: () => { - this.setState({loading: true}); - actions.buildAndRun() - .finally(() => this.setState({loading: false})); - } - }, { key: 'format', text: 'Format', ariaLabel: 'Format', iconOnly: true, - disabled: this.state.loading, + disabled: this.props.loading, iconProps: {iconName: 'Code'}, onClick: () => { - this.setState({loading: true}); - actions.reformatCode() - .finally(() => this.setState({loading: false})); + this.props.dispatch(formatFileDispatcher); } }, { @@ -107,7 +94,7 @@ export class Header extends React.Component<{darkMode?: boolean}, HeaderState> { iconOnly: true, iconProps: {iconName: this.props.darkMode ? 'Brightness' : 'ClearNight'}, onClick: () => { - dispatchToggleTheme(); + this.props.dispatch(dispatchToggleTheme) }, } ]; @@ -124,11 +111,11 @@ export class Header extends React.Component<{darkMode?: boolean}, HeaderState> { render() { return
Golang Logo - {this.state.loading ? ( + {this.props.loading ? ( ) : ( +
+ + + ; + } +} \ No newline at end of file diff --git a/web/src/Preview.tsx b/web/src/Preview.tsx index 3f13e87f..d87818c0 100644 --- a/web/src/Preview.tsx +++ b/web/src/Preview.tsx @@ -1,7 +1,7 @@ import React from 'react'; import './Preview.css'; import { EDITOR_FONTS } from './editor/props'; -import { Connect } from './store/state'; +import { Connect } from './store'; import {EvalEvent} from './services/api'; import EvalEventView from './EvalEventView'; import { getTheme } from '@uifabric/styling'; @@ -11,7 +11,7 @@ interface PreviewProps { events?: EvalEvent[] } -@Connect(s => ({lastError: s.lastError, events: s.events, darkMode: s.darkMode})) +@Connect(s => ({darkMode: s.settings.darkMode, ...s.status})) export default class Preview extends React.Component { get styles() { const { palette } = getTheme(); diff --git a/web/src/editor/CodeEditor.tsx b/web/src/editor/CodeEditor.tsx index 6feebbce..d9f60774 100644 --- a/web/src/editor/CodeEditor.tsx +++ b/web/src/editor/CodeEditor.tsx @@ -1,8 +1,7 @@ import React from 'react'; import MonacoEditor from 'react-monaco-editor'; import {editor} from 'monaco-editor'; -import { Connect } from '../store/state'; -import { dispatchFileChange } from '../store'; +import { Connect, newFileChangeAction } from '../store'; // import { connect } from 'react-redux'; import { DEFAULT_EDITOR_OPTIONS, LANGUAGE_GOLANG } from './props'; @@ -11,14 +10,14 @@ interface CodeEditorState { code?: string } -@Connect(s => ({code: s.code, darkMode: s.darkMode})) -export default class CodeEditor extends React.Component<{code?: string, darkMode?: boolean}, CodeEditorState> { +@Connect(s => ({code: s.editor.code, darkMode: s.settings.darkMode})) +export default class CodeEditor extends React.Component { editorDidMount(editor: editor.IStandaloneCodeEditor, monaco: any) { editor.focus(); } onChange(newValue: string, e: editor.IModelContentChangedEvent) { - dispatchFileChange(newValue); + this.props.dispatch(newFileChangeAction(newValue)); } render() { diff --git a/web/src/editor/actions.ts b/web/src/editor/actions.ts deleted file mode 100644 index 1f096598..00000000 --- a/web/src/editor/actions.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { - store, - dispatchImportFile, - dispatchBuildError, - dispatchBuildResult, dispatchFileChange -} from '../store'; -import client from '../services/api'; -import { saveAs } from 'file-saver'; - -export const loadFile = (f: File) => { - return new Promise((res, rej) => { - const reader = new FileReader(); - reader.onload = e => { - const data = e.target?.result as string; - dispatchImportFile(f.name, data); - res(data); - }; - - reader.onerror = e => { - rej(e); - }; - - reader.readAsText(f, 'UTF-8'); - }); -}; - -export const saveEditorContents = () => { - return new Promise((res, rej) => { - try { - const {fileName, code } = store.getState(); - const blob = new Blob([code], {type: 'text/plain;charset=utf-8'}); - saveAs(blob, fileName); - res(); - } catch (err) { - rej(err) - } - }) -}; - -export const buildAndRun = async () => { - try { - const {code} = store.getState(); - const res = await client.evaluateCode(code); - - dispatchBuildResult(res); - } catch (err) { - console.log('compile error', {err}); - dispatchBuildError(err.message); - } -}; - -export const reformatCode = async() => { - try { - const {code} = store.getState(); - const res = await client.formatCode(code); - - dispatchFileChange(res.formatted ?? code) - } catch (err) { - console.log('GoImports error', {err}); - dispatchBuildError(err.message); - } -}; - -export const runFile = () => {}; - diff --git a/web/src/services/config.ts b/web/src/services/config.ts index 4b883241..f8ad1bb4 100644 --- a/web/src/services/config.ts +++ b/web/src/services/config.ts @@ -1,12 +1,23 @@ +import {loadTheme} from '@uifabric/styling'; +import {DarkTheme, LightTheme} from "./colors"; + const DARK_THEME_KEY = 'ui.darkTheme.enabled'; +function setThemeStyles(isDark: boolean) { + loadTheme(isDark ? DarkTheme : LightTheme); +} + export default { get darkThemeEnabled(): boolean { const v = localStorage.getItem(DARK_THEME_KEY); return v === 'true'; }, - set darkThemeEnabled(val: boolean) { - localStorage.setItem(DARK_THEME_KEY, val ? 'true' : 'false'); + set darkThemeEnabled(enable: boolean) { + setThemeStyles(enable); + localStorage.setItem(DARK_THEME_KEY, enable ? 'true' : 'false'); + }, + sync() { + setThemeStyles(this.darkThemeEnabled); } }; \ No newline at end of file diff --git a/web/src/services/routes.ts b/web/src/services/routes.ts new file mode 100644 index 00000000..87a1f9ec --- /dev/null +++ b/web/src/services/routes.ts @@ -0,0 +1,10 @@ +const snippetRegex = /\/snippet\/([A-Za-z0-9]+)$/; + +export function getSnippetID(): string | null { + const matches = snippetRegex.exec(window.location.pathname); + if (!matches || !matches[1]) { + return null; + } + + return matches[1]; +} \ No newline at end of file diff --git a/web/src/store/actions.ts b/web/src/store/actions.ts index 28b00844..1441e287 100644 --- a/web/src/store/actions.ts +++ b/web/src/store/actions.ts @@ -1,6 +1,9 @@ +import {CompilerResponse} from "../services/api"; + export enum ActionType { IMPORT_FILE = 'IMPORT_FILE', FILE_CHANGE = 'FILE_CHANGE', + LOADING = 'LOADING', COMPILE_RESULT = 'COMPILE_RESULT', COMPILE_FAIL = 'COMPILE_FAIL', TOGGLE_THEME = 'TOGGLE_THEME' @@ -16,6 +19,40 @@ export interface FileImportArgs { contents: string } -export type ActionTypes = keyof typeof ActionType; +export const newImportFileAction = (fileName: string, contents: string) => + ({ + type: ActionType.IMPORT_FILE, + payload: {fileName, contents}, + }); + +export const newFileChangeAction = (contents: string) => + ({ + type: ActionType.FILE_CHANGE, + payload: contents, + }); + +export const newBuildResultAction = (resp: CompilerResponse) => + ({ + type: ActionType.COMPILE_RESULT, + payload: resp, + }); + +export const newBuildErrorAction = (err: string) => + ({ + type: ActionType.COMPILE_FAIL, + payload: err, + }); + +export const newToggleThemeAction = () => + ({ + type: ActionType.TOGGLE_THEME, + payload: null, + }); + +export const newLoadingAction = () => + ({ + type: ActionType.LOADING, + payload: null, + }); diff --git a/web/src/store/configure.ts b/web/src/store/configure.ts new file mode 100644 index 00000000..0369883c --- /dev/null +++ b/web/src/store/configure.ts @@ -0,0 +1,26 @@ +import { createBrowserHistory } from 'history' +import { applyMiddleware, compose, createStore, Store } from 'redux' +import thunk from 'redux-thunk' +import { routerMiddleware } from 'connected-react-router' + +import { createRootReducer, getInitialState } from './reducers' +import {Action} from "./actions"; +import {State} from "./state"; + +const composeEnhancers = (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; + +export const history = createBrowserHistory(); + +export function configureStore(): Store { + const preloadedState = getInitialState(); + return createStore( + createRootReducer(history), + preloadedState as any, + composeEnhancers( + applyMiddleware( + routerMiddleware(history), + thunk, + ), + ), + ); +} \ No newline at end of file diff --git a/web/src/store/dispatch.ts b/web/src/store/dispatch.ts index 672a0194..d599b851 100644 --- a/web/src/store/dispatch.ts +++ b/web/src/store/dispatch.ts @@ -1,38 +1,82 @@ -import {store} from './state'; -import {CompilerResponse} from '../services/api'; -import {Action, ActionType, FileImportArgs} from './actions'; - -export function dispatchImportFile(fileName: string, contents: string) { - store.dispatch>({ - type: ActionType.IMPORT_FILE, - payload: {fileName, contents}, - }); -} +import { saveAs } from 'file-saver'; +import { + newBuildErrorAction, + newBuildResultAction, + newImportFileAction, + newLoadingAction, + newToggleThemeAction +} from './actions'; +import {State} from "./state"; +import client from '../services/api'; +import config from '../services/config'; -export function dispatchFileChange(contents: string) { - store.dispatch>({ - type: ActionType.FILE_CHANGE, - payload: contents, - }); -} +type StateProvider = () => State +type DispatchFn = (Action) => any +type Dispatcher = (DispatchFn, StateProvider) => void -export function dispatchBuildResult(resp: CompilerResponse) { - store.dispatch>({ - type: ActionType.COMPILE_RESULT, - payload: resp, - }); -} +///////////////////////////// +// Dispatchers // +///////////////////////////// + +export function newImportFileDispatcher(f: File): Dispatcher { + return (dispatch: DispatchFn, _: StateProvider) => { + const reader = new FileReader(); + reader.onload = e => { + const data = e.target?.result as string; + dispatch(newImportFileAction(f.name, data)); + }; + + reader.onerror = e => { + // TODO: replace with a nice modal + alert(`Failed to import a file: ${e}`) + }; -export function dispatchBuildError(err: string) { - store.dispatch>({ - type: ActionType.COMPILE_FAIL, - payload: err, - }); + reader.readAsText(f, 'UTF-8'); + }; } -export function dispatchToggleTheme() { - store.dispatch({ - type: ActionType.TOGGLE_THEME, - payload: null, - }); -} \ No newline at end of file +export const saveFileDispatcher: Dispatcher = + (_: DispatchFn, getState: StateProvider) => { + try { + const {fileName, code } = getState().editor; + const blob = new Blob([code], {type: 'text/plain;charset=utf-8'}); + saveAs(blob, fileName); + } catch (err) { + // TODO: replace with a nice modal + alert(`Failed to save a file: ${err}`) + } + }; + +export const runFileDispatcher: Dispatcher = + async (dispatch: DispatchFn, getState: StateProvider) => { + dispatch(newLoadingAction()); + try { + const {code} = getState().editor; + const res = await client.evaluateCode(code); + dispatch(newBuildResultAction(res)); + } catch (err) { + dispatch(newBuildErrorAction(err.message)); + } + }; + +export const formatFileDispatcher: Dispatcher = + async (dispatch: DispatchFn, getState: StateProvider) => { + dispatch(newLoadingAction()); + try { + const {code} = getState().editor; + const res = await client.formatCode(code); + + if (res.formatted) { + dispatch(newBuildResultAction(res)); + } + } catch (err) { + dispatch(newBuildErrorAction(err.message)); + } + }; + +export const dispatchToggleTheme: Dispatcher = + (dispatch: DispatchFn, getState: StateProvider) => { + const { darkMode } = getState().settings; + config.darkThemeEnabled = !darkMode; + dispatch(newToggleThemeAction()) + }; diff --git a/web/src/store/helpers.ts b/web/src/store/helpers.ts new file mode 100644 index 00000000..cab520a8 --- /dev/null +++ b/web/src/store/helpers.ts @@ -0,0 +1,20 @@ +import {Action, ActionType} from './actions'; + +export type Reducer = (s: S, a: Action) => S; +export type ActionReducers = {[k in keyof typeof ActionType | string]: Reducer}; + +/** + * Maps reducers by action type + * @param reducers Key value pair of action type and reducer + * @param initialState Initial state + */ +export function mapByAction(reducers: ActionReducers, initialState: T): Reducer { + return (state: T = initialState, action: Action) => { + if (reducers[action.type]) { + const newState = {...state}; + return reducers[action.type](newState, action); + } + + return state; + }; +} \ No newline at end of file diff --git a/web/src/store/index.ts b/web/src/store/index.ts index 51a31022..4eb69b69 100644 --- a/web/src/store/index.ts +++ b/web/src/store/index.ts @@ -1,3 +1,4 @@ -export { ActionType } from './actions'; -export { store, Connect } from './state'; +export * from './actions'; +export * from './state'; export * from './dispatch'; +export { configureStore } from './configure'; diff --git a/web/src/store/reducers.ts b/web/src/store/reducers.ts index 21219d5a..abe87aa2 100644 --- a/web/src/store/reducers.ts +++ b/web/src/store/reducers.ts @@ -1,55 +1,73 @@ +import { connectRouter } from 'connected-react-router'; +import { combineReducers } from 'redux'; + import {Action, ActionType, FileImportArgs} from './actions'; -import {DEMO_CODE} from "../editor/props"; -import {State} from "./state"; +import {DEMO_CODE} from '../editor/props'; +import {EditorState, SettingsState, State, StatusState} from './state'; import { CompilerResponse } from '../services/api'; import localConfig from '../services/config' +import {mapByAction} from './helpers'; -const initialState = { - fileName: 'main.go', - code: DEMO_CODE, - darkMode: localConfig.darkThemeEnabled -}; - -type Reducer = (s: State, a: Action) => State; +const reducers = { + editor: mapByAction({ + [ActionType.FILE_CHANGE]: (s: EditorState, a: Action) => { + s.code = a.payload; + return s; + }, + [ActionType.IMPORT_FILE]: (s: EditorState, a: Action) => { + const {contents, fileName} = a.payload; + console.log('Loaded file "%s"', fileName); + return { + code: contents, + fileName, + }; + }, + [ActionType.COMPILE_RESULT]: (s: EditorState, a: Action) => { + if (a.payload.formatted) { + s.code = a.payload.formatted; + } -const reducers: {[k in ActionType]: Reducer} = { - [ActionType.FILE_CHANGE]: (s: State, a: Action) => { - s.code = a.payload; - return s; - }, - [ActionType.IMPORT_FILE]: (s: State, a: Action) => { - console.log('Loaded file "%s"', a.payload.fileName); - s.code = a.payload.contents; - s.fileName = a.payload.fileName; - return s; - }, - [ActionType.COMPILE_RESULT]: (s: State, a: Action) => { - s.lastError = null; - s.events = a.payload.events; - - if (a.payload.formatted) { - s.code = a.payload.formatted; + return s; + }, + }, {fileName: 'main.go', code: DEMO_CODE}), + status: mapByAction({ + [ActionType.COMPILE_RESULT]: (s: StatusState, a: Action) => { + return { + loading: false, + lastError: null, + events: a.payload.events, + } + }, + [ActionType.COMPILE_FAIL]: (s: StatusState, a: Action) => { + return {...s, loading: false, lastError: a.payload} + }, + [ActionType.LOADING]: (s: StatusState, a: Action) => { + return {...s, loading: true} + }, + }, {loading: false}), + settings: mapByAction({ + [ActionType.TOGGLE_THEME]: (s: SettingsState, a: Action) => { + s.darkMode = !s.darkMode; + localConfig.darkThemeEnabled = s.darkMode; + return s; } + }, {darkMode: localConfig.darkThemeEnabled}) +}; - return s; +export const getInitialState = (): State => ({ + status: { + loading: false, }, - [ActionType.COMPILE_FAIL]: (s: State, a: Action) => { - s.lastError = a.payload; - return s + editor: { + fileName: 'main.go', + code: DEMO_CODE }, - [ActionType.TOGGLE_THEME]: (s: State, a: Action) => { - s.darkMode = !s.darkMode; - localConfig.darkThemeEnabled = s.darkMode; - return s; + settings: { + darkMode: localConfig.darkThemeEnabled }, -}; - -export function rootReducer(state = initialState, action: Action) { - const newState = Object.assign({}, state) as State; - const r = reducers[action.type]; - if (!r) { - return newState; - } +}); - return reducers[action.type](newState, action); -} +export const createRootReducer = history => combineReducers({ + router: connectRouter(history), + ...reducers, +}); diff --git a/web/src/store/state.ts b/web/src/store/state.ts index 6b4edd6c..b1c4030c 100644 --- a/web/src/store/state.ts +++ b/web/src/store/state.ts @@ -1,23 +1,26 @@ -import {compose, createStore, Store} from 'redux'; import { connect } from 'react-redux'; - -import { Action } from './actions'; -import { rootReducer } from './reducers'; import { EvalEvent } from '../services/api'; -export interface State { - fileName: string +export interface EditorState { + fileName: string, code: string - lastError?: string | null +} + +export interface StatusState { + loading: boolean, + lastError?: string | null, events?: EvalEvent[] +} + +export interface SettingsState { darkMode: boolean } -const composeEnhancers = (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; -export const store = createStore( - rootReducer, - composeEnhancers(), -) as Store; +export interface State { + editor: EditorState + status?: StatusState, + settings: SettingsState +} export function Connect(fn: (state: State) => any) { return function (constructor: Function) { diff --git a/web/yarn.lock b/web/yarn.lock index 3059c6bf..f870b611 100644 --- a/web/yarn.lock +++ b/web/yarn.lock @@ -923,7 +923,7 @@ dependencies: regenerator-runtime "^0.13.2" -"@babel/runtime@^7.5.1", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.2", "@babel/runtime@^7.6.3", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.6": +"@babel/runtime@^7.1.2", "@babel/runtime@^7.4.0", "@babel/runtime@^7.5.1", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.2", "@babel/runtime@^7.6.3", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.6": version "7.8.3" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.8.3.tgz#0811944f73a6c926bb2ad35e918dcc1bfab279f1" integrity sha512-fVHx1rzEmwB130VTkLnxR+HmxcTjGzH12LYQcFFoBwakMd3aOMD4OsRN7tGG/UOYE2ektgFrS8uACAoRk1CY0w== @@ -3001,6 +3001,15 @@ connect-history-api-fallback@^1.6.0: resolved "https://registry.yarnpkg.com/connect-history-api-fallback/-/connect-history-api-fallback-1.6.0.tgz#8b32089359308d111115d81cad3fceab888f97bc" integrity sha512-e54B99q/OUoH64zYYRf3HBP5z24G38h5D3qXu23JGRoigpX5Ss4r9ZnDk3g0Z8uQC2x2lPaJ+UlWBc1ZWBWdLg== +connected-react-router@^6.6.1: + version "6.6.1" + resolved "https://registry.yarnpkg.com/connected-react-router/-/connected-react-router-6.6.1.tgz#f6b7717abf959393fab6756c8d43af1a57d622da" + integrity sha512-a/SE3HgpZABCxr083bfAMpgZwUzlv1RkmOV71+D4I77edoR/peg7uJMHOgqWnXXqGD7lo3Y2ZgUlXtMhcv8FeA== + dependencies: + immutable "^3.8.1" + prop-types "^15.7.2" + seamless-immutable "^7.1.3" + console-browserify@^1.1.0: version "1.2.0" resolved "https://registry.yarnpkg.com/console-browserify/-/console-browserify-1.2.0.tgz#67063cef57ceb6cf4993a2ab3a55840ae8c49336" @@ -4808,6 +4817,11 @@ growly@^1.3.0: resolved "https://registry.yarnpkg.com/growly/-/growly-1.3.0.tgz#f10748cbe76af964b7c96c93c6bcc28af120c081" integrity sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE= +gud@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/gud/-/gud-1.0.0.tgz#a489581b17e6a70beca9abe3ae57de7a499852c0" + integrity sha512-zGEOVKFM5sVPPrYs7J5/hYEw2Pof8KCyOwyhG8sAF26mCAeUFAcYPu1mwB7hhpIP29zOIBaDqwuHdLp0jvZXjw== + gzip-size@5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/gzip-size/-/gzip-size-5.1.1.tgz#cb9bee692f87c0612b232840a873904e4c135274" @@ -4936,6 +4950,18 @@ hex-color-regex@^1.1.0: resolved "https://registry.yarnpkg.com/hex-color-regex/-/hex-color-regex-1.1.0.tgz#4c06fccb4602fe2602b3c93df82d7e7dbf1a8a8e" integrity sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ== +history@^4.9.0: + version "4.10.1" + resolved "https://registry.yarnpkg.com/history/-/history-4.10.1.tgz#33371a65e3a83b267434e2b3f3b1b4c58aad4cf3" + integrity sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew== + dependencies: + "@babel/runtime" "^7.1.2" + loose-envify "^1.2.0" + resolve-pathname "^3.0.0" + tiny-invariant "^1.0.2" + tiny-warning "^1.0.0" + value-equal "^1.0.1" + hmac-drbg@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1" @@ -4945,7 +4971,7 @@ hmac-drbg@^1.0.0: minimalistic-assert "^1.0.0" minimalistic-crypto-utils "^1.0.1" -hoist-non-react-statics@^3.3.0: +hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.0: version "3.3.1" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz#101685d3aff3b23ea213163f6e8e12f4f111e19f" integrity sha512-wbg3bpgA/ZqWrZuMOeJi8+SKMhr7X9TesL/rXMjTzh0p0JUBo3II8DHboYbuIXWRlttrUFxwcu/5kygrCw8fJw== @@ -5159,6 +5185,11 @@ immer@1.10.0: resolved "https://registry.yarnpkg.com/immer/-/immer-1.10.0.tgz#bad67605ba9c810275d91e1c2a47d4582e98286d" integrity sha512-O3sR1/opvCDGLEVcvrGTMtLac8GJ5IwZC4puPrLuRj3l7ICKvkmA0vGuU9OW8mV9WIBRnaxp5GJh9IEAaNOoYg== +immutable@^3.8.1: + version "3.8.2" + resolved "https://registry.yarnpkg.com/immutable/-/immutable-3.8.2.tgz#c2439951455bb39913daf281376f1530e104adf3" + integrity sha1-wkOZUUVbs5kT2vKBN28VMOEErfM= + import-cwd@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/import-cwd/-/import-cwd-2.1.0.tgz#aa6cf36e722761285cb371ec6519f53e2435b0a9" @@ -5602,6 +5633,11 @@ is-wsl@^2.1.0: resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-2.1.1.tgz#4a1c152d429df3d441669498e2486d3596ebaf1d" integrity sha512-umZHcSrwlDHo2TGMXv0DZ8dIUGunZ2Iv68YZnrmCiBPkZ4aaOhtv7pXJKeki9k3qJ3RJr0cDyitcl5wEH3AYog== +isarray@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf" + integrity sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8= + isarray@1.0.0, isarray@^1.0.0, isarray@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" @@ -6437,7 +6473,7 @@ loglevel@^1.6.4: resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.6.6.tgz#0ee6300cc058db6b3551fa1c4bf73b83bb771312" integrity sha512-Sgr5lbboAUBo3eXCSPL4/KoVz3ROKquOjcctxmHIt+vol2DrqTQe3SwkKKuYhEiWB5kYa13YyopJ69deJ1irzQ== -loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0: +loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.1, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== @@ -6644,6 +6680,15 @@ min-indent@^1.0.0: resolved "https://registry.yarnpkg.com/min-indent/-/min-indent-1.0.0.tgz#cfc45c37e9ec0d8f0a0ec3dd4ef7f7c3abe39256" integrity sha1-z8RcN+nsDY8KDsPdTvf3w6vjklY= +mini-create-react-context@^0.3.0: + version "0.3.2" + resolved "https://registry.yarnpkg.com/mini-create-react-context/-/mini-create-react-context-0.3.2.tgz#79fc598f283dd623da8e088b05db8cddab250189" + integrity sha512-2v+OeetEyliMt5VHMXsBhABoJ0/M4RCe7fatd/fBy6SMiKazUSEt3gxxypfnk2SHMkdBYvorHRoQxuGoiwbzAw== + dependencies: + "@babel/runtime" "^7.4.0" + gud "^1.0.0" + tiny-warning "^1.0.2" + mini-css-extract-plugin@0.8.0: version "0.8.0" resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-0.8.0.tgz#81d41ec4fe58c713a96ad7c723cdb2d0bd4d70e1" @@ -7549,6 +7594,13 @@ path-to-regexp@0.1.7: resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" integrity sha1-32BBeABfUi8V60SQ5yR6G/qmf4w= +path-to-regexp@^1.7.0: + version "1.8.0" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-1.8.0.tgz#887b3ba9d84393e87a0a0b9f4cb756198b53548a" + integrity sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA== + dependencies: + isarray "0.0.1" + path-type@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/path-type/-/path-type-2.0.0.tgz#f012ccb8415b7096fc2daa1054c3d72389594c73" @@ -8642,7 +8694,7 @@ react-error-overlay@^6.0.4: resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.4.tgz#0d165d6d27488e660bc08e57bdabaad741366f7a" integrity sha512-ueZzLmHltszTshDMwyfELDq8zOA803wQ1ZuzCccXa1m57k1PxSHfflPD5W9YIiTXLs0JTLzoj6o1LuM5N6zzNA== -react-is@^16.7.0, react-is@^16.8.1, react-is@^16.8.4, react-is@^16.9.0: +react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.1, react-is@^16.8.4, react-is@^16.9.0: version "16.12.0" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.12.0.tgz#2cc0fe0fba742d97fd527c42a13bec4eeb06241c" integrity sha512-rPCkf/mWBtKc97aLL9/txD8DZdemK0vkA3JMLShjlJB3Pj3s+lpf1KaBzMfQrAmhMQB0n1cU/SUGgKKBCe837Q== @@ -8667,6 +8719,35 @@ react-redux@^7.1.3: prop-types "^15.7.2" react-is "^16.9.0" +react-router-dom@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-5.1.2.tgz#06701b834352f44d37fbb6311f870f84c76b9c18" + integrity sha512-7BPHAaIwWpZS074UKaw1FjVdZBSVWEk8IuDXdB+OkLb8vd/WRQIpA4ag9WQk61aEfQs47wHyjWUoUGGZxpQXew== + dependencies: + "@babel/runtime" "^7.1.2" + history "^4.9.0" + loose-envify "^1.3.1" + prop-types "^15.6.2" + react-router "5.1.2" + tiny-invariant "^1.0.2" + tiny-warning "^1.0.0" + +react-router@5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/react-router/-/react-router-5.1.2.tgz#6ea51d789cb36a6be1ba5f7c0d48dd9e817d3418" + integrity sha512-yjEuMFy1ONK246B+rsa0cUam5OeAQ8pyclRDgpxuSCrAlJ1qN9uZ5IgyKC7gQg0w8OM50NXHEegPh/ks9YuR2A== + dependencies: + "@babel/runtime" "^7.1.2" + history "^4.9.0" + hoist-non-react-statics "^3.1.0" + loose-envify "^1.3.1" + mini-create-react-context "^0.3.0" + path-to-regexp "^1.7.0" + prop-types "^15.6.2" + react-is "^16.6.0" + tiny-invariant "^1.0.2" + tiny-warning "^1.0.0" + react-scripts@3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/react-scripts/-/react-scripts-3.3.0.tgz#f26a21f208f20bd04770f43e50b5bbc151920c2a" @@ -8823,6 +8904,11 @@ redent@^3.0.0: indent-string "^4.0.0" strip-indent "^3.0.0" +redux-thunk@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.3.0.tgz#51c2c19a185ed5187aaa9a2d08b666d0d6467622" + integrity sha512-km6dclyFnmcvxhAcrQV2AkZmPQjzPDjgVlQtR0EQjxZPyJ0BnMf3in1ryuR8A2qU0HldVRfxYXbFSKlI3N7Slw== + redux@^4.0.5: version "4.0.5" resolved "https://registry.yarnpkg.com/redux/-/redux-4.0.5.tgz#4db5de5816e17891de8a80c424232d06f051d93f" @@ -9024,6 +9110,11 @@ resolve-from@^4.0.0: resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== +resolve-pathname@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/resolve-pathname/-/resolve-pathname-3.0.0.tgz#99d02224d3cf263689becbb393bc560313025dcd" + integrity sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng== + resolve-url-loader@3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/resolve-url-loader/-/resolve-url-loader-3.1.1.tgz#28931895fa1eab9be0647d3b2958c100ae3c0bf0" @@ -9251,6 +9342,11 @@ schema-utils@^2.0.0, schema-utils@^2.0.1, schema-utils@^2.1.0, schema-utils@^2.2 ajv "^6.10.2" ajv-keywords "^3.4.1" +seamless-immutable@^7.1.3: + version "7.1.4" + resolved "https://registry.yarnpkg.com/seamless-immutable/-/seamless-immutable-7.1.4.tgz#6e9536def083ddc4dea0207d722e0e80d0f372f8" + integrity sha512-XiUO1QP4ki4E2PHegiGAlu6r82o5A+6tRh7IkGGTVg/h+UoeX4nFBeCGPOhb4CYjvkqsfm/TUtvOMYC1xmV30A== + select-hose@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca" @@ -10034,6 +10130,16 @@ timsort@^0.3.0: resolved "https://registry.yarnpkg.com/timsort/-/timsort-0.3.0.tgz#405411a8e7e6339fe64db9a234de11dc31e02bd4" integrity sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q= +tiny-invariant@^1.0.2: + version "1.0.6" + resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.0.6.tgz#b3f9b38835e36a41c843a3b0907a5a7b3755de73" + integrity sha512-FOyLWWVjG+aC0UqG76V53yAWdXfH8bO6FNmyZOuUrzDzK8DI3/JRY25UD7+g49JWM1LXwymsKERB+DzI0dTEQA== + +tiny-warning@^1.0.0, tiny-warning@^1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754" + integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA== + tmp@^0.0.33: version "0.0.33" resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9" @@ -10386,6 +10492,11 @@ validate-npm-package-license@^3.0.1: spdx-correct "^3.0.0" spdx-expression-parse "^3.0.0" +value-equal@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/value-equal/-/value-equal-1.0.1.tgz#1e0b794c734c5c0cade179c437d356d931a34d6c" + integrity sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw== + vary@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" From 15afa4f0a3ba5e91c554695d10a07f8a437626b2 Mon Sep 17 00:00:00 2001 From: x1unix Date: Tue, 21 Jan 2020 04:14:50 +0200 Subject: [PATCH 06/17] ui: use message bar for errors --- web/src/Preview.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/web/src/Preview.tsx b/web/src/Preview.tsx index d87818c0..c2eb7b39 100644 --- a/web/src/Preview.tsx +++ b/web/src/Preview.tsx @@ -5,6 +5,8 @@ import { Connect } from './store'; import {EvalEvent} from './services/api'; import EvalEventView from './EvalEventView'; import { getTheme } from '@uifabric/styling'; +import { MessageBar, MessageBarType } from 'office-ui-fabric-react'; + interface PreviewProps { lastError?:string | null; @@ -24,7 +26,9 @@ export default class Preview extends React.Component { render() { let content; if (this.props.lastError) { - content = {this.props.lastError}; + content = + Build failed: {this.props.lastError} + } else if (this.props.events) { content = this.props.events.map((e, k) => Date: Tue, 21 Jan 2020 04:26:08 +0200 Subject: [PATCH 07/17] ui: change build error message --- web/src/Preview.css | 15 +++++++++++++++ web/src/Preview.tsx | 7 +++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/web/src/Preview.css b/web/src/Preview.css index 05392733..5a4c0521 100644 --- a/web/src/Preview.css +++ b/web/src/Preview.css @@ -16,4 +16,19 @@ .app-preview__epilogue { color: #999; margin-top: 15px; +} + +.app-preview__build-error { + margin: 0 0 5px; +} + +.app-preview__label { + display: block; + margin-bottom: 2px; +} + +.app-preview__errors { + margin: 0; + padding: 0; + font: inherit; } \ No newline at end of file diff --git a/web/src/Preview.tsx b/web/src/Preview.tsx index c2eb7b39..f534d947 100644 --- a/web/src/Preview.tsx +++ b/web/src/Preview.tsx @@ -26,8 +26,11 @@ export default class Preview extends React.Component { render() { let content; if (this.props.lastError) { - content = - Build failed: {this.props.lastError} + content = + Build failed +
+                    {this.props.lastError}
+                
} else if (this.props.events) { content = this.props.events.map((e, k) => Date: Tue, 21 Jan 2020 04:27:54 +0200 Subject: [PATCH 08/17] ui: change initial file name --- web/src/store/reducers.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/store/reducers.ts b/web/src/store/reducers.ts index abe87aa2..1134b96a 100644 --- a/web/src/store/reducers.ts +++ b/web/src/store/reducers.ts @@ -56,10 +56,10 @@ const reducers = { export const getInitialState = (): State => ({ status: { - loading: false, + loading: false }, editor: { - fileName: 'main.go', + fileName: 'prog.go', code: DEMO_CODE }, settings: { From 5e2e25474b4b1b8f95c868771b0b6a404db3acfc Mon Sep 17 00:00:00 2001 From: x1unix Date: Tue, 21 Jan 2020 05:04:09 +0200 Subject: [PATCH 09/17] ui: change progress bar --- web/src/Header.tsx | 20 +++++++++----------- web/src/Preview.css | 13 +++++++++++++ web/src/Preview.tsx | 17 ++++++++++++++++- 3 files changed, 38 insertions(+), 12 deletions(-) diff --git a/web/src/Header.tsx b/web/src/Header.tsx index d196c0f4..a71fb309 100644 --- a/web/src/Header.tsx +++ b/web/src/Header.tsx @@ -1,7 +1,6 @@ import React from 'react'; import './Header.css' import { CommandBar, ICommandBarItemProps } from 'office-ui-fabric-react/lib/CommandBar'; -import { Spinner, SpinnerSize } from 'office-ui-fabric-react/lib/Spinner'; import { getTheme } from '@uifabric/styling'; import { Connect, @@ -45,6 +44,7 @@ export class Header extends React.Component { key: 'openFile', text: 'Open', iconProps: {iconName: 'OpenFile'}, + disabled: this.props.loading, onClick: () => this.fileInput?.click(), }, { @@ -61,12 +61,14 @@ export class Header extends React.Component { key: 'share', text: 'Share', iconProps: {iconName: 'Share'}, + disabled: this.props.loading, onClick: () => alert('Work in progress 🐨') }, { key: 'download', text: 'Download', iconProps: {iconName: 'Download'}, + disabled: this.props.loading, onClick: () => { this.props.dispatch(saveFileDispatcher); }, @@ -115,16 +117,12 @@ export class Header extends React.Component { className='header__logo' alt='Golang Logo' /> - {this.props.loading ? ( - - ) : ( - - )} +
; } } diff --git a/web/src/Preview.css b/web/src/Preview.css index 5a4c0521..5e8427c1 100644 --- a/web/src/Preview.css +++ b/web/src/Preview.css @@ -4,6 +4,9 @@ max-height: 640px; height: 50%; box-sizing: border-box; +} + +.app-preview__content { padding: 15px; font-size: 10pt; } @@ -31,4 +34,14 @@ margin: 0; padding: 0; font: inherit; +} + +.app-preview__progress { + position: relative; + bottom: 8px; + margin-bottom: -18px; +} + +.app-preview__progress--hidden { + display: none; } \ No newline at end of file diff --git a/web/src/Preview.tsx b/web/src/Preview.tsx index f534d947..d6338153 100644 --- a/web/src/Preview.tsx +++ b/web/src/Preview.tsx @@ -6,11 +6,13 @@ import {EvalEvent} from './services/api'; import EvalEventView from './EvalEventView'; import { getTheme } from '@uifabric/styling'; import { MessageBar, MessageBarType } from 'office-ui-fabric-react'; +import {ProgressIndicator} from "office-ui-fabric-react/lib/ProgressIndicator"; interface PreviewProps { lastError?:string | null; events?: EvalEvent[] + loading?: boolean } @Connect(s => ({darkMode: s.settings.darkMode, ...s.status})) @@ -23,6 +25,16 @@ export default class Preview extends React.Component { fontFamily: EDITOR_FONTS } } + + get progressStyles() { + return this.props.loading ? 'app-preview__progress' : 'app-preview__progress--hidden'; + // return { + // display: this.props.loading ? 'block' : 'none', + // position: 'relative', + // bottom: '8px', + // } + } + render() { let content; if (this.props.lastError) { @@ -46,7 +58,10 @@ export default class Preview extends React.Component { } return
- {content} + +
+ {content} +
; } } \ No newline at end of file From d6b3b32698fe01df871c7c921d3bf89bc9186654 Mon Sep 17 00:00:00 2001 From: x1unix Date: Tue, 21 Jan 2020 05:57:27 +0200 Subject: [PATCH 10/17] server: return snippet meta --- pkg/goplay/methods.go | 10 +++++++--- pkg/goplay/types.go | 6 ++++++ pkg/langserver/request.go | 5 +++++ pkg/langserver/server.go | 5 ++++- 4 files changed, 22 insertions(+), 4 deletions(-) diff --git a/pkg/goplay/methods.go b/pkg/goplay/methods.go index 35252cb6..8802600e 100644 --- a/pkg/goplay/methods.go +++ b/pkg/goplay/methods.go @@ -23,8 +23,9 @@ func ValidateContentLength(r lener) error { return nil } -func GetSnippet(ctx context.Context, snippetID string) ([]byte, error) { - resp, err := getRequest(ctx, "p/"+snippetID+".go") +func GetSnippet(ctx context.Context, snippetID string) (*Snippet, error) { + fileName := snippetID + ".go" + resp, err := getRequest(ctx, "p/"+fileName) if err != nil { return nil, err } @@ -37,7 +38,10 @@ func GetSnippet(ctx context.Context, snippetID string) ([]byte, error) { return nil, err } - return snippet, nil + return &Snippet{ + FileName: fileName, + Contents: string(snippet), + }, nil case http.StatusNotFound: return nil, ErrSnippetNotFound default: diff --git a/pkg/goplay/types.go b/pkg/goplay/types.go index 1b9d9d20..b4007603 100644 --- a/pkg/goplay/types.go +++ b/pkg/goplay/types.go @@ -5,6 +5,12 @@ import ( "time" ) +// Snippet represents shared snippet +type Snippet struct { + FileName string + Contents string +} + // FmtResponse is the response returned from // upstream play.golang.org/fmt request type FmtResponse struct { diff --git a/pkg/langserver/request.go b/pkg/langserver/request.go index e6f2c23d..8078a000 100644 --- a/pkg/langserver/request.go +++ b/pkg/langserver/request.go @@ -13,6 +13,11 @@ import ( "github.com/x1unix/go-playground/pkg/analyzer" ) +type SnippetResponse struct { + FileName string `json:"fileName"` + Code string `json:"code"` +} + type ShareResponse struct { SnippetID string `json:"snippetID"` } diff --git a/pkg/langserver/server.go b/pkg/langserver/server.go index 30748f6b..9d587b52 100644 --- a/pkg/langserver/server.go +++ b/pkg/langserver/server.go @@ -165,7 +165,10 @@ func (s *Service) GetSnippet(w http.ResponseWriter, r *http.Request) { return } - w.Write(snippet) + WriteJSON(w, SnippetResponse{ + FileName: snippet.FileName, + Code: snippet.Contents, + }) } func (s *Service) Compile(w http.ResponseWriter, r *http.Request) { From c809fd803e468e93d5ad41da62166f7a91428d72 Mon Sep 17 00:00:00 2001 From: x1unix Date: Tue, 21 Jan 2020 05:57:36 +0200 Subject: [PATCH 11/17] ui: load snippet --- web/src/App.tsx | 5 ++++- web/src/Playground.tsx | 23 +++++++++++++++-------- web/src/Preview.tsx | 11 +++-------- web/src/services/api.ts | 10 ++++++++++ web/src/store/dispatch.ts | 19 +++++++++++++++++++ web/src/store/reducers.ts | 10 ++++++---- 6 files changed, 57 insertions(+), 21 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index 5e454a77..f764fca3 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -20,7 +20,10 @@ function App() { - + diff --git a/web/src/Playground.tsx b/web/src/Playground.tsx index 5f6e95b7..cf502a22 100644 --- a/web/src/Playground.tsx +++ b/web/src/Playground.tsx @@ -3,13 +3,20 @@ import { Header } from './Header'; import CodeEditor from './editor/CodeEditor'; import './Playground.css'; import Preview from './Preview'; +import { useParams } from "react-router-dom"; +import {newSnippetLoadDispatcher} from "./store"; +import { connect } from 'react-redux'; -export default class Playground extends React.Component{ - render() { - return
-
- - -
; +const Playground = connect()(function (props) { + const {snippetID} = useParams(); + if (snippetID) { + props.dispatch(newSnippetLoadDispatcher(snippetID)); } -} \ No newline at end of file + return
+
+ + +
; +}); + +export default Playground; \ No newline at end of file diff --git a/web/src/Preview.tsx b/web/src/Preview.tsx index d6338153..cc9c4bf4 100644 --- a/web/src/Preview.tsx +++ b/web/src/Preview.tsx @@ -26,20 +26,15 @@ export default class Preview extends React.Component { } } - get progressStyles() { + get progressClass() { return this.props.loading ? 'app-preview__progress' : 'app-preview__progress--hidden'; - // return { - // display: this.props.loading ? 'block' : 'none', - // position: 'relative', - // bottom: '8px', - // } } render() { let content; if (this.props.lastError) { content = - Build failed + Error
                     {this.props.lastError}
                 
@@ -58,7 +53,7 @@ export default class Preview extends React.Component { } return
- +
{content}
diff --git a/web/src/services/api.ts b/web/src/services/api.ts index 0219a360..1f6f9b6b 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -6,6 +6,11 @@ const apiAddress = process.env['REACT_APP_LANG_SERVER'] ?? window.location.origi let axiosClient = axios.default.create({baseURL: `${apiAddress}/api`}); +export interface Snippet { + fileName: string + code: string +} + export interface EvalEvent { Message: string Kind: string @@ -22,6 +27,7 @@ export interface IAPIClient { getSuggestions(query: {packageName?: string, value?:string}): Promise evaluateCode(code: string): Promise formatCode(code: string): Promise + getSnippet(id: string): Promise } class Client implements IAPIClient { @@ -44,6 +50,10 @@ class Client implements IAPIClient { return this.post('/format', code); } + async getSnippet(id: string): Promise { + return this.get(`/snippet/${id}`); + } + private async get(uri: string): Promise { try { const resp = await this.client.get(uri); diff --git a/web/src/store/dispatch.ts b/web/src/store/dispatch.ts index d599b851..9070ce8e 100644 --- a/web/src/store/dispatch.ts +++ b/web/src/store/dispatch.ts @@ -9,6 +9,7 @@ import { import {State} from "./state"; import client from '../services/api'; import config from '../services/config'; +import {DEMO_CODE} from '../editor/props'; type StateProvider = () => State type DispatchFn = (Action) => any @@ -35,6 +36,24 @@ export function newImportFileDispatcher(f: File): Dispatcher { }; } +export function newSnippetLoadDispatcher(snippetID: string): Dispatcher { + return async(dispatch: DispatchFn, getState: StateProvider) => { + console.log('load snippet %s', snippetID); + if (!snippetID) { + dispatch(newImportFileAction('prog.go', DEMO_CODE)); + return; + } + + try { + const resp = await client.getSnippet(snippetID); + const { fileName, code } = resp; + dispatch(newImportFileAction(fileName, code)); + } catch(err) { + dispatch(newBuildErrorAction(err.message)); + } + } +} + export const saveFileDispatcher: Dispatcher = (_: DispatchFn, getState: StateProvider) => { try { diff --git a/web/src/store/reducers.ts b/web/src/store/reducers.ts index 1134b96a..85f0e12b 100644 --- a/web/src/store/reducers.ts +++ b/web/src/store/reducers.ts @@ -2,7 +2,6 @@ import { connectRouter } from 'connected-react-router'; import { combineReducers } from 'redux'; import {Action, ActionType, FileImportArgs} from './actions'; -import {DEMO_CODE} from '../editor/props'; import {EditorState, SettingsState, State, StatusState} from './state'; import { CompilerResponse } from '../services/api'; import localConfig from '../services/config' @@ -29,7 +28,7 @@ const reducers = { return s; }, - }, {fileName: 'main.go', code: DEMO_CODE}), + }, {fileName: 'main.go', code: ''}), status: mapByAction({ [ActionType.COMPILE_RESULT]: (s: StatusState, a: Action) => { return { @@ -38,6 +37,9 @@ const reducers = { events: a.payload.events, } }, + [ActionType.IMPORT_FILE]: (s: StatusState, a: Action) => { + return {...s, loading: false} + }, [ActionType.COMPILE_FAIL]: (s: StatusState, a: Action) => { return {...s, loading: false, lastError: a.payload} }, @@ -56,11 +58,11 @@ const reducers = { export const getInitialState = (): State => ({ status: { - loading: false + loading: true }, editor: { fileName: 'prog.go', - code: DEMO_CODE + code: '' }, settings: { darkMode: localConfig.darkThemeEnabled From 6cf948aec84d59305bb34045df2c978156e79e34 Mon Sep 17 00:00:00 2001 From: x1unix Date: Tue, 21 Jan 2020 06:17:37 +0200 Subject: [PATCH 12/17] ui: file share support --- web/src/App.tsx | 16 +++++++--------- web/src/Header.tsx | 6 ++++-- web/src/Playground.tsx | 5 ++--- web/src/editor/CodeEditor.tsx | 3 ++- web/src/services/api.ts | 9 +++++++++ web/src/store/actions.ts | 6 +++--- web/src/store/dispatch.ts | 25 +++++++++++++++++++------ web/src/store/reducers.ts | 4 ++-- 8 files changed, 48 insertions(+), 26 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index f764fca3..12046d96 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { Provider } from 'react-redux'; import {Fabric} from 'office-ui-fabric-react/lib/Fabric'; import { ConnectedRouter } from 'connected-react-router'; -import {BrowserRouter as Router, Switch, Route} from "react-router-dom"; +import {Switch, Route} from "react-router-dom"; import { configureStore } from './store'; import { history } from './store/configure'; @@ -18,14 +18,12 @@ function App() { - - - - - + + + diff --git a/web/src/Header.tsx b/web/src/Header.tsx index a71fb309..f8afcd2a 100644 --- a/web/src/Header.tsx +++ b/web/src/Header.tsx @@ -8,7 +8,7 @@ import { formatFileDispatcher, runFileDispatcher, saveFileDispatcher, - dispatchToggleTheme + dispatchToggleTheme, shareSnippetDispatcher } from './store'; @Connect(s => ({darkMode: s.settings.darkMode, loading: s.status?.loading})) @@ -62,7 +62,9 @@ export class Header extends React.Component { text: 'Share', iconProps: {iconName: 'Share'}, disabled: this.props.loading, - onClick: () => alert('Work in progress 🐨') + onClick: () => { + this.props.dispatch(shareSnippetDispatcher); + } }, { key: 'download', diff --git a/web/src/Playground.tsx b/web/src/Playground.tsx index cf502a22..73b45320 100644 --- a/web/src/Playground.tsx +++ b/web/src/Playground.tsx @@ -9,9 +9,8 @@ import { connect } from 'react-redux'; const Playground = connect()(function (props) { const {snippetID} = useParams(); - if (snippetID) { - props.dispatch(newSnippetLoadDispatcher(snippetID)); - } + props.dispatch(newSnippetLoadDispatcher(snippetID)); + return
diff --git a/web/src/editor/CodeEditor.tsx b/web/src/editor/CodeEditor.tsx index d9f60774..88b6b351 100644 --- a/web/src/editor/CodeEditor.tsx +++ b/web/src/editor/CodeEditor.tsx @@ -8,9 +8,10 @@ import { DEFAULT_EDITOR_OPTIONS, LANGUAGE_GOLANG } from './props'; interface CodeEditorState { code?: string + loading?:boolean } -@Connect(s => ({code: s.editor.code, darkMode: s.settings.darkMode})) +@Connect(s => ({code: s.editor.code, darkMode: s.settings.darkMode, loading: s.status?.loading})) export default class CodeEditor extends React.Component { editorDidMount(editor: editor.IStandaloneCodeEditor, monaco: any) { editor.focus(); diff --git a/web/src/services/api.ts b/web/src/services/api.ts index 1f6f9b6b..359d57b9 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -6,6 +6,10 @@ const apiAddress = process.env['REACT_APP_LANG_SERVER'] ?? window.location.origi let axiosClient = axios.default.create({baseURL: `${apiAddress}/api`}); +export interface ShareResponse { + snippetID: string +} + export interface Snippet { fileName: string code: string @@ -28,6 +32,7 @@ export interface IAPIClient { evaluateCode(code: string): Promise formatCode(code: string): Promise getSnippet(id: string): Promise + shareSnippet(code: string): Promise } class Client implements IAPIClient { @@ -54,6 +59,10 @@ class Client implements IAPIClient { return this.get(`/snippet/${id}`); } + async shareSnippet(code: string): Promise { + return this.post('/share', code); + } + private async get(uri: string): Promise { try { const resp = await this.client.get(uri); diff --git a/web/src/store/actions.ts b/web/src/store/actions.ts index 1441e287..7ddcc05d 100644 --- a/web/src/store/actions.ts +++ b/web/src/store/actions.ts @@ -4,8 +4,8 @@ export enum ActionType { IMPORT_FILE = 'IMPORT_FILE', FILE_CHANGE = 'FILE_CHANGE', LOADING = 'LOADING', + ERROR = 'ERROR', COMPILE_RESULT = 'COMPILE_RESULT', - COMPILE_FAIL = 'COMPILE_FAIL', TOGGLE_THEME = 'TOGGLE_THEME' } @@ -37,9 +37,9 @@ export const newBuildResultAction = (resp: CompilerResponse) => payload: resp, }); -export const newBuildErrorAction = (err: string) => +export const newErrorAction = (err: string) => ({ - type: ActionType.COMPILE_FAIL, + type: ActionType.ERROR, payload: err, }); diff --git a/web/src/store/dispatch.ts b/web/src/store/dispatch.ts index 9070ce8e..f65a8f65 100644 --- a/web/src/store/dispatch.ts +++ b/web/src/store/dispatch.ts @@ -1,6 +1,7 @@ import { saveAs } from 'file-saver'; +import { push } from 'connected-react-router'; import { - newBuildErrorAction, + newErrorAction, newBuildResultAction, newImportFileAction, newLoadingAction, @@ -37,23 +38,35 @@ export function newImportFileDispatcher(f: File): Dispatcher { } export function newSnippetLoadDispatcher(snippetID: string): Dispatcher { - return async(dispatch: DispatchFn, getState: StateProvider) => { - console.log('load snippet %s', snippetID); + return async(dispatch: DispatchFn, _: StateProvider) => { if (!snippetID) { dispatch(newImportFileAction('prog.go', DEMO_CODE)); return; } try { + console.log('loading snippet %s', snippetID); const resp = await client.getSnippet(snippetID); const { fileName, code } = resp; dispatch(newImportFileAction(fileName, code)); } catch(err) { - dispatch(newBuildErrorAction(err.message)); + dispatch(newErrorAction(err.message)); } } } +export const shareSnippetDispatcher: Dispatcher = + async (dispatch: DispatchFn, getState: StateProvider) => { + dispatch(newLoadingAction()); + try { + const {code} = getState().editor; + const res = await client.shareSnippet(code); + dispatch(push(`/snippet/${res.snippetID}`)); + } catch (err) { + dispatch(newErrorAction(err.message)); + } + }; + export const saveFileDispatcher: Dispatcher = (_: DispatchFn, getState: StateProvider) => { try { @@ -74,7 +87,7 @@ export const runFileDispatcher: Dispatcher = const res = await client.evaluateCode(code); dispatch(newBuildResultAction(res)); } catch (err) { - dispatch(newBuildErrorAction(err.message)); + dispatch(newErrorAction(err.message)); } }; @@ -89,7 +102,7 @@ export const formatFileDispatcher: Dispatcher = dispatch(newBuildResultAction(res)); } } catch (err) { - dispatch(newBuildErrorAction(err.message)); + dispatch(newErrorAction(err.message)); } }; diff --git a/web/src/store/reducers.ts b/web/src/store/reducers.ts index 85f0e12b..dde76ce4 100644 --- a/web/src/store/reducers.ts +++ b/web/src/store/reducers.ts @@ -38,9 +38,9 @@ const reducers = { } }, [ActionType.IMPORT_FILE]: (s: StatusState, a: Action) => { - return {...s, loading: false} + return {...s, loading: false, lastError: null} }, - [ActionType.COMPILE_FAIL]: (s: StatusState, a: Action) => { + [ActionType.ERROR]: (s: StatusState, a: Action) => { return {...s, loading: false, lastError: a.payload} }, [ActionType.LOADING]: (s: StatusState, a: Action) => { From a49d9218b8d3aa0ea67815d8cc5a1848434bca38 Mon Sep 17 00:00:00 2001 From: x1unix Date: Tue, 21 Jan 2020 06:29:55 +0200 Subject: [PATCH 13/17] ui: add meta tags --- web/public/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/public/index.html b/web/public/index.html index 4f18b394..f919bf47 100644 --- a/web/public/index.html +++ b/web/public/index.html @@ -7,7 +7,7 @@