diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a28835c0..86ab4dd6 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -31,3 +31,20 @@ jobs: run: cd gui; yarn - name: Test run: cd gui; yarn test + + frontend-build: + name: yarn build + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'yarn' + cache-dependency-path: gui/yarn.lock + - name: Install dependencies + run: cd gui; yarn + - name: Build + run: cd gui; yarn build diff --git a/gui/src/app/FileEditor/StanFileEditor.tsx b/gui/src/app/FileEditor/StanFileEditor.tsx index 952825ed..8cfe5a86 100644 --- a/gui/src/app/FileEditor/StanFileEditor.tsx +++ b/gui/src/app/FileEditor/StanFileEditor.tsx @@ -3,8 +3,9 @@ import { AutoFixHigh, Cancel, Settings, } from "@mui/icons-material"; import { FunctionComponent, useCallback, useEffect, useMemo, useState } from "react"; import StanCompileResultWindow from "./StanCompileResultWindow"; import useStanc from "../Stanc/useStanc"; -import TextEditor, { ToolbarItem } from "./TextEditor"; +import TextEditor, { CodeMarker, ToolbarItem } from "./TextEditor"; import compileStanProgram from '../compileStanProgram/compileStanProgram'; +import { StancErrors } from '../Stanc/Types'; type Props = { fileName: string @@ -157,7 +158,7 @@ const StanFileEditor: FunctionComponent = ({ fileName, fileContent, onSav } return ret - }, [editedFileContent, fileContent, handleCompile, requestFormat, showLabelsOnButtons, validSyntax, compileStatus, compileMessage, readOnly]) + }, [editedFileContent, fileContent, handleCompile, requestFormat, showLabelsOnButtons, validSyntax, compileStatus, compileMessage, readOnly, hasWarnings]) const isCompiling = compileStatus === 'compiling' @@ -183,6 +184,7 @@ const StanFileEditor: FunctionComponent = ({ fileName, fileContent, onSav onSetEditedText={setEditedFileContent} readOnly={!isCompiling ? readOnly : true} toolbarItems={toolbarItems} + codeMarkers={stancErrorsToCodeMarkers(stancErrors)} /> { editedFileContent ? { return hash; } +const stancErrorsToCodeMarkers = (stancErrors: StancErrors) => { + const codeMarkers: CodeMarker[] = [] + + for (const x of stancErrors.errors || []) { + const marker = stancErrorStringToMarker(x, 'error') + if (marker) codeMarkers.push(marker) + } + for (const x of stancErrors.warnings || []) { + const marker = stancErrorStringToMarker(x, 'warning') + if (marker) codeMarkers.push(marker) + } + + return codeMarkers +} + +const stancErrorStringToMarker = (x: string, severity: 'error' | 'warning'): CodeMarker | undefined => { + if (!x) return undefined + + // Example: Syntax error in 'main.stan', line 1, column 0 to column 1, parsing error: + + let lineNumber: number | undefined = undefined + let startColumn: number | undefined = undefined + let endColumn: number | undefined = undefined + + const sections = x.split(',').map(x => x.trim()) + for (const section of sections) { + if ((section.startsWith('line ')) && (lineNumber === undefined)) { + lineNumber = parseInt(section.slice('line '.length)) + } + else if ((section.startsWith('column ')) && (startColumn === undefined)) { + const cols = section.slice('column '.length).split(' to ') + startColumn = parseInt(cols[0]) + endColumn = cols.length > 1 ? parseInt(cols[1].slice('column '.length)) : startColumn + 1 + } + } + + if ((lineNumber !== undefined) && (startColumn !== undefined) && (endColumn !== undefined)) { + return { + startLineNumber: lineNumber, + startColumn: startColumn + 1, + endLineNumber: lineNumber, + endColumn: endColumn + 1, + message: severity === 'warning' ? getWarningMessage(x) : getErrorMessage(x), + severity + } + } + else { + return undefined + } +} + +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Adapted from https://github.com/WardBrian/vscode-stan-extension +function getWarningMessage(message: string) { + let warning = message.replace(/Warning.*column \d+: /s, ""); + warning = warning.replace(/\s+/gs, " "); + warning = warning.trim(); + warning = message.includes("included from") + ? "Warning in included file:\n" + warning + : warning; + return warning; +} + +function getErrorMessage(message: string) { + let error = message; + // cut off code snippet for display + if (message.includes("------\n")) { + error = error.split("------\n")[2]; + } + error = error.trim(); + error = message.includes("included from") + ? "Error in included file:\n" + error + : error; + + // only relevant to vscode-stan-extension: + // error = error.includes("given information about") + // ? error + + // "\nConsider updating the includePaths setting of vscode-stan-extension" + // : error; + + return error; +} +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// export default StanFileEditor diff --git a/gui/src/app/FileEditor/TextEditor.tsx b/gui/src/app/FileEditor/TextEditor.tsx index a47f0b3c..973249df 100644 --- a/gui/src/app/FileEditor/TextEditor.tsx +++ b/gui/src/app/FileEditor/TextEditor.tsx @@ -9,6 +9,16 @@ import { Hyperlink, SmallIconButton } from "@fi-sci/misc"; type Monaco = typeof monaco +// An interface for passing markers (squiggles) to the editor without depending on monaco types +export type CodeMarker = { + startLineNumber: number + startColumn: number + endLineNumber: number + endColumn: number + message: string + severity: 'error' | 'warning' | 'hint' | 'info' +} + type Props = { defaultText?: string text: string | undefined @@ -23,6 +33,7 @@ type Props = { label: string width: number height: number + codeMarkers?: CodeMarker[] } export type ToolbarItem = { @@ -38,7 +49,7 @@ export type ToolbarItem = { color?: string } -const TextEditor: FunctionComponent = ({defaultText, text, onSaveText, editedText, onSetEditedText, readOnly, wordWrap, onReload, toolbarItems, language, label, width, height}) => { +const TextEditor: FunctionComponent = ({defaultText, text, onSaveText, editedText, onSetEditedText, readOnly, wordWrap, onReload, toolbarItems, language, label, width, height, codeMarkers}) => { const handleChange = useCallback((value: string | undefined) => { onSetEditedText(value || '') }, [onSetEditedText]) @@ -60,10 +71,27 @@ const TextEditor: FunctionComponent = ({defaultText, text, onSaveText, ed if (editor.getValue() === editedText) return editor.setValue(editedText || defaultText || '') }, [editedText, editor, defaultText]) + const [monacoInstance, setMonacoInstance] = useState(undefined) + useEffect(() => { + if (!monacoInstance) return + if (codeMarkers === undefined) return + if (editor === undefined) return + const model = editor.getModel() + if (model === null) return + const modelMarkers = codeMarkers.map(marker => ({ + startLineNumber: marker.startLineNumber, + startColumn: marker.startColumn, + endLineNumber: marker.endLineNumber, + endColumn: marker.endColumn, + message: marker.message, + severity: toMonacoMarkerSeverity(marker.severity) + })) + monacoInstance.editor.setModelMarkers(model, 'stan-playground', modelMarkers) + }, [codeMarkers, monacoInstance, editor]) const handleEditorDidMount = useCallback((editor: editor.IStandaloneCodeEditor, monaco: Monaco) => { + setMonacoInstance(monaco); (async () => { if (language === 'stan') { - monaco.editor.defineTheme('vs-stan', { base: 'vs-dark', inherit: true, @@ -140,8 +168,13 @@ const TextEditor: FunctionComponent = ({defaultText, text, onSaveText, ed return (
-
- {label} +
+ + {label} +     {!readOnly && ( } title="Save file" disabled={text === editedText} label="save" /> @@ -181,6 +214,15 @@ const TextEditor: FunctionComponent = ({defaultText, text, onSaveText, ed ) } +const toMonacoMarkerSeverity = (s: 'error' | 'warning' | 'hint' | 'info'): monaco.MarkerSeverity => { + switch (s) { + case 'error': return monaco.MarkerSeverity.Error + case 'warning': return monaco.MarkerSeverity.Warning + case 'hint': return monaco.MarkerSeverity.Hint + case 'info': return monaco.MarkerSeverity.Info + } +} + const ToolbarItemComponent: FunctionComponent<{item: ToolbarItem}> = ({item}) => { if (item.type === 'button') { const {onClick, color, label, tooltip, icon} = item diff --git a/gui/src/app/SPAnalysis/SPAnalysisContextProvider.tsx b/gui/src/app/SPAnalysis/SPAnalysisContextProvider.tsx index ea14665f..31dd1a56 100644 --- a/gui/src/app/SPAnalysis/SPAnalysisContextProvider.tsx +++ b/gui/src/app/SPAnalysis/SPAnalysisContextProvider.tsx @@ -2,7 +2,8 @@ import { initialDataModel, SPAnalysisDataModel } from "./SPAnalysisDataModel" import { createContext, FunctionComponent, PropsWithChildren, useEffect, useReducer } from "react" import { SPAnalysisReducer, SPAnalysisReducerAction, SPAnalysisReducerType } from "./SPAnalysisReducer" import { deserializeAnalysisFromLocalStorage, serializeAnalysisToLocalStorage } from "./SPAnalysisSerialization" -import { fetchRemoteAnalysis, queryStringHasParameters, useQueryParams } from "./SPAnalysisQueryLoading" +import { fetchRemoteAnalysis, queryStringHasParameters, fromQueryParams } from "./SPAnalysisQueryLoading" +import { useSearchParams } from "react-router-dom" type SPAnalysisContextType = { data: SPAnalysisDataModel @@ -20,10 +21,9 @@ export const SPAnalysisContext = createContext({ const SPAnalysisContextProvider: FunctionComponent> = ({ children }) => { + const [data, update] = useReducer(SPAnalysisReducer, initialDataModel) - const { queries, clearSearchParams } = useQueryParams(); - - const [data, update] = useReducer(SPAnalysisReducer(clearSearchParams), initialDataModel) + const [searchParams, setSearchParams] = useSearchParams(); useEffect(() => { // as user reloads the page or closes the tab, save state to local storage @@ -39,11 +39,15 @@ const SPAnalysisContextProvider: FunctionComponent { - if (data != initialDataModel) return; - + const queries = fromQueryParams(searchParams) if (queryStringHasParameters(queries)) { fetchRemoteAnalysis(queries).then((data) => { - update({ type: 'loadInitialData', state: data }) + update({ type: 'loadInitialData', state: data }); + + // set title so that history is better preserved in the browser + document.title = "Stan Playground - " + data.meta.title; + // clear search parameters now that load is complete + setSearchParams(new URLSearchParams()); }) } else { // load the saved state on first load @@ -53,8 +57,10 @@ const SPAnalysisContextProvider: FunctionComponent diff --git a/gui/src/app/SPAnalysis/SPAnalysisQueryLoading.ts b/gui/src/app/SPAnalysis/SPAnalysisQueryLoading.ts index f8183ef1..d5b4a21b 100644 --- a/gui/src/app/SPAnalysis/SPAnalysisQueryLoading.ts +++ b/gui/src/app/SPAnalysis/SPAnalysisQueryLoading.ts @@ -1,6 +1,4 @@ -import { useSearchParams } from "react-router-dom"; import { SPAnalysisDataModel, initialDataModel, persistStateToEphemera } from "./SPAnalysisDataModel"; -import { useCallback } from "react"; import { isSamplingOpts } from "../StanSampler/StanSampler"; @@ -20,16 +18,7 @@ type QueryParams = { [key in QueryParamKeys]: string | null } -export const useQueryParams = () => { - const [searchParams, setSearchParams] = useSearchParams(); - - const clearSearchParams = useCallback(() => { - // whenever the data state is 'dirty', we want to - // clear the URL bar as to indiciate that the viewed content is - // no longer what the link would point to - if (searchParams.size !== 0) - setSearchParams(new URLSearchParams()) - }, [searchParams, setSearchParams]); +export const fromQueryParams = (searchParams: URLSearchParams) => { for (const key of searchParams.keys()) { // warn on unknown keys @@ -50,7 +39,7 @@ export const useQueryParams = () => { seed: searchParams.get(QueryParamKeys.SOSeed), } - return { queries, clearSearchParams } + return queries; } export const queryStringHasParameters = (query: QueryParams) => { diff --git a/gui/src/app/SPAnalysis/SPAnalysisReducer.ts b/gui/src/app/SPAnalysis/SPAnalysisReducer.ts index 5d7401a0..28e441da 100644 --- a/gui/src/app/SPAnalysis/SPAnalysisReducer.ts +++ b/gui/src/app/SPAnalysis/SPAnalysisReducer.ts @@ -34,14 +34,7 @@ export type SPAnalysisReducerAction = { type: 'clear' } -export const SPAnalysisReducer = (onDirty: () => void) => (s: SPAnalysisDataModel, a: SPAnalysisReducerAction) => { - if (a.type !== "loadInitialData") { - // TextEditor seems to trigger occasional spurious edits where nothing changes - if (a.type !== "editFile" || s[a.filename] != a.content) { - onDirty(); - } - } - +export const SPAnalysisReducer = (s: SPAnalysisDataModel, a: SPAnalysisReducerAction) => { switch (a.type) { case "loadStanie": { const dataFileContent = JSON.stringify(a.stanie.data, null, 2); @@ -134,4 +127,4 @@ const loadFromProjectFiles = (data: SPAnalysisDataModel, files: Partial loadFileFromString(currData, currField, files[currField] ?? ''), newData) newData = persistStateToEphemera(newData) return newData -} \ No newline at end of file +} diff --git a/gui/src/app/pages/HomePage/HomePage.tsx b/gui/src/app/pages/HomePage/HomePage.tsx index 4ba0e1c2..cbb57fb5 100644 --- a/gui/src/app/pages/HomePage/HomePage.tsx +++ b/gui/src/app/pages/HomePage/HomePage.tsx @@ -1,5 +1,5 @@ import { Splitter } from "@fi-sci/splitter"; -import { FunctionComponent, useCallback, useContext, useEffect, useMemo, useState } from "react"; +import { FunctionComponent, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import DataFileEditor from "../../FileEditor/DataFileEditor"; import StanFileEditor from "../../FileEditor/StanFileEditor"; import RunPanel from "../../RunPanel/RunPanel"; @@ -30,8 +30,6 @@ const HomePage: FunctionComponent = ({ width, height }) => { } const HomePageChild: FunctionComponent = ({ width, height }) => { - - const { data, update } = useContext(SPAnalysisContext) const setSamplingOpts = useCallback((opts: SamplingOpts) => { update({ type: 'setSamplingOpts', opts }) @@ -39,14 +37,29 @@ const HomePageChild: FunctionComponent = ({ width, height }) => { const [compiledMainJsUrl, setCompiledMainJsUrl] = useState('') - const leftPanelWidth = Math.max(250, Math.min(340, width * 0.2)) + const [leftPanelCollapsed, setLeftPanelCollapsed] = useState(determineShouldBeInitiallyCollapsed(width)) + const expandedLeftPanelWidth = determineLeftPanelWidth(width) // what the width would be if expanded + const leftPanelWidth = leftPanelCollapsed ? 20 : expandedLeftPanelWidth // the actual width + + // We automatically collapse the panel if user has resized the window to be + // too small but we only want to do this right when we cross the threshold, + // not every time we resize by a pixel. Similar for expanding the panel when + // we cross the threshold in the other direction. + const lastShouldBeCollapsed = useRef(determineShouldBeInitiallyCollapsed(width)) + useEffect(() => { + const shouldBeCollapsed = determineShouldBeInitiallyCollapsed(width) + if (shouldBeCollapsed !== lastShouldBeCollapsed.current) { + lastShouldBeCollapsed.current = shouldBeCollapsed + setLeftPanelCollapsed(shouldBeCollapsed) + } + }, [width]) + const topBarHeight = 25 useEffect(() => { document.title = "Stan Playground - " + data.meta.title; }, [data.meta.title]) - return (
@@ -58,6 +71,8 @@ const HomePageChild: FunctionComponent = ({ width, height }) => {
= ({ width, height }) => { ) } +// the width of the left panel when it is expanded based on the overall width +const determineLeftPanelWidth = (width: number) => { + const minWidth = 250 + const maxWidth = 400 + return Math.min(maxWidth, Math.max(minWidth, width / 4)) +} + +// whether the left panel should be collapsed initially based on the overall +// width +const determineShouldBeInitiallyCollapsed = (width: number) => { + return width < 800 +} + type RightViewProps = { width: number height: number diff --git a/gui/src/app/pages/HomePage/LeftPanel.tsx b/gui/src/app/pages/HomePage/LeftPanel.tsx index 554758ce..30c7a362 100644 --- a/gui/src/app/pages/HomePage/LeftPanel.tsx +++ b/gui/src/app/pages/HomePage/LeftPanel.tsx @@ -1,18 +1,21 @@ -import { Hyperlink } from "@fi-sci/misc" +import { Hyperlink, SmallIconButton } from "@fi-sci/misc" import ModalWindow, { useModalWindow } from "@fi-sci/modal-window" import { FunctionComponent, useCallback, useContext } from "react" import examplesStanies, { Stanie } from "../../exampleStanies/exampleStanies" import { SPAnalysisContext } from "../../SPAnalysis/SPAnalysisContextProvider" import ExportWindow from "./ExportWindow" import ImportWindow from "./ImportWindow" +import { ChevronLeft, ChevronRight } from "@mui/icons-material" type LeftPanelProps = { width: number height: number hasUnsavedChanges: boolean + collapsed: boolean + onSetCollapsed: (collapsed: boolean) => void } -const LeftPanel: FunctionComponent = ({ width, height, hasUnsavedChanges }) => { +const LeftPanel: FunctionComponent = ({ width, height, hasUnsavedChanges, collapsed, onSetCollapsed }) => { // note: this is close enough to pass in directly if we wish const { update } = useContext(SPAnalysisContext) @@ -22,9 +25,21 @@ const LeftPanel: FunctionComponent = ({ width, height, hasUnsave const { visible: exportVisible, handleOpen: exportOpen, handleClose: exportClose } = useModalWindow() const { visible: importVisible, handleOpen: importOpen, handleClose: importClose } = useModalWindow() + + if (collapsed) { + return ( +
+
+ onSetCollapsed(false)} /> +
+
+ ) + } + return (
+ onSetCollapsed(true)} />

Examples

{ examplesStanies.map((stanie, i) => ( @@ -85,4 +100,24 @@ const LeftPanel: FunctionComponent = ({ width, height, hasUnsave ) } +const ExpandButton: FunctionComponent<{ onClick: () => void }> = ({ onClick }) => { + return ( + } + onClick={onClick} + title="Expand" + /> + ) +} + +const CollapseButton: FunctionComponent<{ onClick: () => void }> = ({ onClick }) => { + return ( + } + onClick={onClick} + title="Collapse" + /> + ) +} + export default LeftPanel \ No newline at end of file