From e5f11992f5d4f32aae815def57095c1ac6551bfa Mon Sep 17 00:00:00 2001 From: Sam Carlberg Date: Sat, 28 Sep 2024 14:56:41 -0400 Subject: [PATCH] Allow project settings to be configurable (#31) * Add basic project settings Uses team number setting in generated preferences file * Include project json file in project export * Add a UI for configuring settings * Respect epilogue support setting when generating code * Show settings when creating a new project * Default new project names to empty string * Move project name to settings object for consistency * Validate settings to prevent saving with bad input (eg no project name or team number) * Trim leading and trailing whitespace from text on save * Make settings flexible * Add definitions for grouping settings together * Allow custom settings to be defined (eg by plugins) --- src/App.scss | 7 + src/bindings/Project.ts | 90 ++++++++-- src/bundled_files/README.md.ts | 2 +- src/bundled_files/wpilib_preferences.json.ts | 19 ++- src/codegen/java/RobotGenerator.ts | 52 +++--- src/codegen/java/StateGenerator.ts | 5 +- src/codegen/java/SubsystemGenerator.ts | 12 +- src/codegen/java/util.ts | 5 +- src/settings/Settings.ts | 56 +++++++ src/ui/ProjectView.tsx | 78 +++++---- src/ui/project/SettingsDialog.tsx | 164 +++++++++++++++++++ src/ui/robot/Robot.tsx | 20 ++- src/ui/subsystem/Subsystem.tsx | 7 +- 13 files changed, 434 insertions(+), 83 deletions(-) create mode 100644 src/settings/Settings.ts create mode 100644 src/ui/project/SettingsDialog.tsx diff --git a/src/App.scss b/src/App.scss index e1f35f3..90e6af7 100644 --- a/src/App.scss +++ b/src/App.scss @@ -685,3 +685,10 @@ div.gutter { width: 0.125em; background: #ccc; } + +.project-settings-dialog { + td.MuiTableCell-root.MuiTableCell-body { + // Reduce the default 16px padding + padding: 8px; + } +} diff --git a/src/bindings/Project.ts b/src/bindings/Project.ts index 36853c2..699e9b2 100644 --- a/src/bindings/Project.ts +++ b/src/bindings/Project.ts @@ -4,12 +4,14 @@ import { v4 as uuidV4 } from "uuid" import * as IR from "../bindings/ir" import { BundledMain } from "../bundled_files/Main.java" import { BundledGitignore } from "../bundled_files/.gitignore" -import { BundledPreferences } from "../bundled_files/wpilib_preferences.json" +import { generateBundledPreferences } from "../bundled_files/wpilib_preferences.json" import { generateRobotClass } from "../codegen/java/RobotGenerator" import { BundledGradleBuild } from "../bundled_files/build.gradle" import { generateReadme } from "../bundled_files/README.md" import { BundledLaunchJson, BundledSettingsJson } from "../bundled_files/vscode" import { BundledWpilibCommandsV2 } from "../bundled_files/vendordeps" +import { className } from "../codegen/java/util" +import { generateSubsystem } from "../codegen/java/SubsystemGenerator" export type GeneratedFile = { name: string @@ -19,13 +21,57 @@ export type GeneratedFile = { } export type Project = { - name: string controllers: Controller[] subsystems: Subsystem[] commands: IR.Group[] generatedFiles: GeneratedFile[] + settings: Settings }; +export type SettingsKey = string +export type SettingsType = string | number | boolean | null +export type SettingsTypeName = "string" | "number" | "boolean" + +export type Settings = Record + +export type SettingsCategory = { + key: string + name: string + settings: SettingConfig[] +} + +export type SettingConfig = { + /** + * A unique key to identify this setting. For example, a UUID or a unique identifier like "wpilib.epilogue.enabled". + */ + key: SettingsKey + + /** + * The name of the setting to display to users. + */ + name: string + + /** + * A description of this setting and what it does or how it's used. + */ + description: string + + /** + * Whether or not the setting is required. + */ + required: boolean + + /** + * The data type of the setting object. This will be used to determine the UI element that edits this setting. + */ + type: SettingsTypeName + + /** + * The default value of the setting. + */ + defaultValue: SettingsType +} + const makeDefaultGeneratedFiles = (): GeneratedFile[] => { return [ { @@ -41,7 +87,7 @@ const makeDefaultGeneratedFiles = (): GeneratedFile[] => { { name: ".wpilib/wpilib_preferences.json", description: "", - contents: BundledPreferences, + contents: "", }, { name: ".gitignore", @@ -83,20 +129,25 @@ const makeDefaultGeneratedFiles = (): GeneratedFile[] => { export const makeNewProject = (): Project => { const project: Project = { - name: "New Project", controllers: [ { name: "New Controller", uuid: uuidV4(), type: "ps5", className: "CommandPS5Controller", fqn: "", port: 1 , buttons: [] }, ], subsystems: [], commands: [], generatedFiles: makeDefaultGeneratedFiles(), - } - - // Update the robot class contents - project.generatedFiles.find(f => f.name === "src/main/java/frc/robot/Robot.java").contents = generateRobotClass(project) - - // Update the readme - project.generatedFiles.find(f => f.name === "README.md").contents = generateReadme(project) + settings: { + "robotbuilder.general.project_name": "", + "robotbuilder.general.team_number": null, + // "robotbuilder.general.cache_sensor_values": false, + "wpilib.epilogue.enabled": true, + }, + } + + // Update pregenerated files to give them a valid initial state + // These files may need to be updated over time while the project is edited + updateFile(project, ".wpilib/wpilib_preferences.json", generateBundledPreferences(project)) + updateFile(project, "src/main/java/frc/robot/Robot.java", generateRobotClass(project)) + updateFile(project, "README.md", generateReadme(project)) return project } @@ -105,6 +156,23 @@ export function updateFile(project: Project, path: string, contents: string): vo project.generatedFiles.find(f => f.name === path).contents = contents } +/** + * Regenerates all dynamic project files. + * + * @param project the project to regenerate + */ +export function regenerateFiles(project: Project): void { + // Regenerate subsystems + project.subsystems.forEach(subsytem => { + updateFile(project, `src/main/java/frc/robot/subsystems/${ className(subsytem.name) }.java`, generateSubsystem(subsytem, project)) + }) + + updateFile(project, "src/main/java/frc/robot/Robot.java", generateRobotClass(project)) + + updateFile(project, `README.md`, generateReadme(project)) + updateFile(project, ".wpilib/wpilib_preferences.json", generateBundledPreferences(project)) +} + export function findCommand(project: Project, commandOrId: AtomicCommand | IR.Group | string): AtomicCommand | IR.Group | null { if (commandOrId instanceof AtomicCommand || commandOrId instanceof IR.Group) { // Passed in a command object, return it diff --git a/src/bundled_files/README.md.ts b/src/bundled_files/README.md.ts index 8d1bcfd..5ea1905 100644 --- a/src/bundled_files/README.md.ts +++ b/src/bundled_files/README.md.ts @@ -3,7 +3,7 @@ import { unindent } from "../codegen/java/util" export const generateReadme = (project: Project): string => { return unindent(` - # ${ project.name } + # ${ project.settings["robotbuilder.general.project_name"] } This is your robot program! `).trim() diff --git a/src/bundled_files/wpilib_preferences.json.ts b/src/bundled_files/wpilib_preferences.json.ts index 99c4d80..9cdd7e9 100644 --- a/src/bundled_files/wpilib_preferences.json.ts +++ b/src/bundled_files/wpilib_preferences.json.ts @@ -1,10 +1,13 @@ +import { Project } from "../bindings/Project" import { unindent } from "../codegen/java/util" -export const BundledPreferences = unindent(` - { - "enableCppIntellisense": false, - "currentLanguage": "java", - "projectYear": "2025", - "teamNumber": 9999 - } -`).trim() +export const generateBundledPreferences = (project: Project): string => { + return unindent(` + { + "enableCppIntellisense": false, + "currentLanguage": "java", + "projectYear": "2025", + "teamNumber": ${ project.settings["robotbuilder.general.team_number"] } + } + `).trim() +} diff --git a/src/codegen/java/RobotGenerator.ts b/src/codegen/java/RobotGenerator.ts index 383a022..3eef99f 100644 --- a/src/codegen/java/RobotGenerator.ts +++ b/src/codegen/java/RobotGenerator.ts @@ -10,24 +10,24 @@ export function generateRobotClass(project: Project): string { ` package frc.robot; - import edu.wpi.first.epilogue.Epilogue; - import edu.wpi.first.epilogue.Logged; - import edu.wpi.first.epilogue.NotLogged; + ${ project.settings["wpilib.epilogue.enabled"] ? "import edu.wpi.first.epilogue.Epilogue;" : "" } + ${ project.settings["wpilib.epilogue.enabled"] ? "import edu.wpi.first.epilogue.Logged;" : "" } + ${ project.settings["wpilib.epilogue.enabled"] ? "import edu.wpi.first.epilogue.NotLogged;" : "" } import edu.wpi.first.wpilibj.RuntimeType; import edu.wpi.first.wpilibj.TimedRobot; import edu.wpi.first.wpilibj2.command.Command; import edu.wpi.first.wpilibj2.command.CommandScheduler; import frc.robot.subsystems.*; - @Logged + ${ project.settings["wpilib.epilogue.enabled"] ? "@Logged" : "" } public class Robot extends TimedRobot { ${ - project.subsystems.map(s => generateSubsystemDeclaration(s)).join("\n") + project.subsystems.map(s => generateSubsystemDeclaration(project, s)).join("\n") } ${ project.controllers.map(c => ` - @NotLogged // Controllers are not loggable + ${ project.settings["wpilib.epilogue.enabled"] ? `@NotLogged // Controllers are not loggable` : "" } private final ${ c.className } ${ methodName(c.name) } = new ${ c.className }(${ c.port }); `) } @@ -36,18 +36,21 @@ ${ configureButtonBindings(); configureAutomaticBindings(); - Epilogue.configure(config -> { - // TODO: Add a UI for customizing epilogue - - if (getRuntimeType() == RuntimeType.kRoboRIO) { - // Only log to networktables on a roboRIO 1 because of limited disk space. - // If the disk fills up, there's a real risk of getting locked out of the rio! - config.dataLogger = new NTDataLogger(NetworkTablesInstance.getDefault()); - } else { - // On a roboRIO 2 there's enough disk space to be able to safely log to disk - config.dataLogger = new FileSystemLogger(DataLogManager.getDataLog()); - } - }); + ${ project.settings["wpilib.epilogue.enabled"] ? ` + Epilogue.configure(config -> { + // TODO: Add a UI for customizing epilogue + + if (getRuntimeType() == RuntimeType.kRoboRIO) { + // Only log to networktables on a roboRIO 1 because of limited disk space. + // If the disk fills up, there's a real risk of getting locked out of the rio! + config.dataLogger = new NTDataLogger(NetworkTablesInstance.getDefault()); + } else { + // On a roboRIO 2 there's enough disk space to be able to safely log to disk + config.dataLogger = new FileSystemLogger(DataLogManager.getDataLog()); + } + }); + ` : "" +} } @Override @@ -55,8 +58,13 @@ ${ // Run our commands CommandScheduler.getInstance().run(); - // Update our data logs - Epilogue.update(this); + ${ + project.settings["wpilib.epilogue.enabled"] ? + ` + // Update our data logs + Epilogue.update(this); + ` : "" +} } @Override @@ -97,10 +105,10 @@ ${ ) } -function generateSubsystemDeclaration(subsystem: Subsystem): string { +function generateSubsystemDeclaration(project: Project, subsystem: Subsystem): string { return indent( ` - @Logged(name = "${ subsystem.name }") + ${ project.settings["wpilib.epilogue.enabled"] ? `@Logged(name = "${ subsystem.name }")` : "" } private final ${ className(subsystem.name) } ${ methodName(subsystem.name) } = new ${ className(subsystem.name) }(); `.trim(), 2, diff --git a/src/codegen/java/StateGenerator.ts b/src/codegen/java/StateGenerator.ts index d7fdfcb..87fc2bd 100644 --- a/src/codegen/java/StateGenerator.ts +++ b/src/codegen/java/StateGenerator.ts @@ -1,12 +1,13 @@ import { Subsystem, SubsystemState } from "../../bindings/Command" import { indent, methodName, unindent } from "./util" import { generateStepInvocations, generateStepParams } from "./ActionGenerator" +import { Project } from "../../bindings/Project" -export function generateState(state: SubsystemState, subsystem: Subsystem): string { +export function generateState(project: Project, state: SubsystemState, subsystem: Subsystem): string { console.log("[STATE-GENERATOR] Generating code for state", state) return unindent( ` - @Logged(name = "${ state.name }?") + ${ project.settings["wpilib.epilogue.enabled"] ? `@Logged(name = "${ state.name }?")` : "" } public boolean ${ methodName(state.name) }(${ generateStepParams([state.step].filter(s => !!s), subsystem) }) { ${ generateStepInvocations([state.step].filter(s => !!s), subsystem).map(i => indent(`return ${ i }`, 6)).join("\n") } } diff --git a/src/codegen/java/SubsystemGenerator.ts b/src/codegen/java/SubsystemGenerator.ts index 939cc4e..f0240a3 100644 --- a/src/codegen/java/SubsystemGenerator.ts +++ b/src/codegen/java/SubsystemGenerator.ts @@ -114,7 +114,9 @@ export function generateSubsystem(subsystem: Subsystem, project: Project) { import static edu.wpi.first.units.Units.*; ${ [...new Set(subsystem.components.map(c => c.definition.fqn))].sort().map(fqn => indent(`import ${ fqn };`, 4)).join("\n") } - import edu.wpi.first.epilogue.Logged; + + ${ project.settings["wpilib.epilogue.enabled"] ? "import edu.wpi.first.epilogue.Logged;" : "" } + ${ project.settings["wpilib.epilogue.enabled"] ? "import edu.wpi.first.epilogue.NotLogged;" : "" } import edu.wpi.first.units.*; import edu.wpi.first.wpilibj.shuffleboard.Shuffleboard; import edu.wpi.first.wpilibj.shuffleboard.ShuffleboardTab; @@ -125,10 +127,10 @@ ${ [...new Set(subsystem.components.map(c => c.definition.fqn))].sort().map(fqn /** * The ${ subsystem.name } subsystem. */ - @Logged + ${ project.settings["wpilib.epilogue.enabled"] ? "@Logged" : "" } public class ${ clazz } extends SubsystemBase { -${ subsystem.components.map(c => indent(`${ fieldDeclaration(c.definition.className, c.name) };`, 6)).join("\n") } +${ subsystem.components.map(c => indent(`${ fieldDeclaration(project, c.definition.className, c.name) };`, 6)).join("\n") } ${ (() => { @@ -150,7 +152,7 @@ ${ ${ subsystem.states.map(state => { return indent(unindent( ` - @NotLogged + ${ project.settings["wpilib.epilogue.enabled"] ? "@NotLogged" : "" } public final Trigger ${ methodName(state.name) } = new Trigger(this::${ methodName(state.name) }); `, ).trim(), 6) @@ -183,7 +185,7 @@ ${ // STATES ${ - subsystem.states.map(state => unindent(indent(generateState(state, subsystem), 4)).trim()) + subsystem.states.map(state => unindent(indent(generateState(project, state, subsystem), 4)).trim()) .map(f => indent(f, 6)).join("\n\n") } diff --git a/src/codegen/java/util.ts b/src/codegen/java/util.ts index 8f735fe..41354f0 100644 --- a/src/codegen/java/util.ts +++ b/src/codegen/java/util.ts @@ -1,6 +1,7 @@ import parsers from "prettier-plugin-java" import prettier from "prettier" import { Subsystem } from "../../bindings/Command" +import { Project } from "../../bindings/Project" export const MAIN_CLASS_PATH = "src/main/java/frc/robot/Main.java" export const ROBOT_CLASS_PATH = "src/main/java/frc/robot/Robot.java" @@ -92,9 +93,9 @@ export function objectName(name: string): string { } } -export function fieldDeclaration(type: string, name: string): string { +export function fieldDeclaration(project: Project, type: string, name: string): string { return unindent(` - @Logged(name = "${ name }") + ${ project.settings["wpilib.epilogue.enabled"] ? `@Logged(name = "${ name }")` : "" } private final ${ type } ${ objectName(name) } `).trim() } diff --git a/src/settings/Settings.ts b/src/settings/Settings.ts new file mode 100644 index 0000000..a3b565f --- /dev/null +++ b/src/settings/Settings.ts @@ -0,0 +1,56 @@ +import { SettingsCategory } from "../bindings/Project" + +export const ALL_SETTINGS = {} + +export function registerCategory(setting: SettingsCategory) { + ALL_SETTINGS[setting.key] = setting +} + +// Seed default values + +registerCategory({ + key: "robotbuilder.general", + name: "General", + settings: [ + { + key: "robotbuilder.general.project_name", + name: "Project Name", + description: "The name of your robot project", + required: true, + type: "string", + defaultValue: null, + }, + { + key: "robotbuilder.general.team_number", + name: "Team Number", + description: "Your FRC team number. You ought to know this!", + required: true, + type: "number", + defaultValue: null, + }, + // TODO: Implement sensor caching. It's out of scope for the initial project settings work. + // { + // key: "robotbuilder.general.cache_sensor_values", + // name: "Cache Sensor Values", + // description: "Changes code generation to read sensor values once per loop, instead of on demand. This setting may improve performance", + // required: false, + // type: "boolean", + // defaultValue: false + // } + ], +}) + +registerCategory({ + key: "wpilib.epilogue", + name: "Epilogue", + settings: [ + { + key: "wpilib.epilogue.enabled", + name: "Enable Epilogue Support", + description: "Enables support for automatic data logging in your project via the Epilogue library", + required: false, + type: "boolean", + defaultValue: true, + }, + ], +}) diff --git a/src/ui/ProjectView.tsx b/src/ui/ProjectView.tsx index 796fb0d..6585784 100644 --- a/src/ui/ProjectView.tsx +++ b/src/ui/ProjectView.tsx @@ -1,9 +1,9 @@ -import { Box, Button, Tab, Tabs, TextField } from "@mui/material" +import { Box, Button, Tab, Tabs } from "@mui/material" import { Controllers } from "./controller/Controller" import { Subsystems } from "./subsystem/Subsystem" import { Commands } from "./command/Commands" import React, { useState } from "react" -import { makeNewProject, Project } from "../bindings/Project" +import { makeNewProject, Project, regenerateFiles } from "../bindings/Project" import $ from "jquery" import { AtomicCommand, @@ -14,8 +14,8 @@ import { } from "../bindings/Command" import { CommandInvocation, Group, ParGroup, SeqGroup } from "../bindings/ir" import { Robot } from "./robot/Robot" -import { generateReadme } from "../bundled_files/README.md" import { BlobWriter, TextReader, ZipWriter } from "@zip.js/zip.js" +import SettingsDialog from "./project/SettingsDialog" type ProjectProps = { initialProject: Project; @@ -26,7 +26,7 @@ const saveProject = (project: Project) => { console.log(savedProject) const link = document.getElementById("download-link") - const fileName = `${ project.name }.json` + const fileName = `${ project.settings["robotbuilder.general.project_name"] }.json` const file = new Blob([savedProject], { type: "text/plain" }) link.setAttribute("href", window.URL.createObjectURL(file)) link.setAttribute("download", fileName) @@ -105,9 +105,17 @@ const exportProject = async (project: Project) => { const zipFileWriter = new BlobWriter() const zipWriter = new ZipWriter(zipFileWriter) + // Work on a copy so we can exclude some things, like generated file contents, from the JSON + // No need to include copies of files that are already in the export + const projectCopy: Project = { ...project, generatedFiles: [] } + const savedProjectContents = JSON.stringify(projectCopy, null, 2) + await Promise.all(project.generatedFiles.map(file => { return zipWriter.add(file.name, new TextReader(file.contents)) - })) + }).concat([ + zipWriter.add(`${ project.settings["robotbuilder.general.project_name"] }.json`, new TextReader(savedProjectContents)), + ])) + await zipWriter.close() const zipFile = await zipFileWriter.getData() @@ -115,16 +123,19 @@ const exportProject = async (project: Project) => { const link = document.createElement("a") const objurl = URL.createObjectURL(zipFile) - link.download = `${ project.name }.zip` + link.download = `${ project.settings["robotbuilder.general.project_name"] }.zip` link.href = objurl link.click() } export function ProjectView({ initialProject }: ProjectProps) { type Tab = "robot" | "controllers" | "subsystems" | "commands"; - const defaultTab: Tab = "subsystems" - const [project, setProject] = React.useState(initialProject) - const [selectedTab, setSelectedTab] = React.useState(defaultTab) + const defaultTab: Tab = "robot" + const [project, setProject] = useState(initialProject) + const [selectedTab, setSelectedTab] = useState(defaultTab) + const [settingsDialogProps, setSettingsDialogProps] = useState({ visible: true, allowCancel: false }) + + const hideSettings = () => setSettingsDialogProps({ ...settingsDialogProps, visible: false }) const handleChange = (event, newValue) => { setSelectedTab(newValue) @@ -143,28 +154,33 @@ export function ProjectView({ initialProject }: ProjectProps) { } } - const [, sn] = useState(project.name) - - const setProjectName = (name: string) => { - console.debug("Setting project name to", name) - sn(name) - project.name = name - } - - const updateProjectName = (newName) => { - if (!newName || newName.length < 1) return // blank name, ignore the change - - setProjectName(newName) - project.generatedFiles.find(f => f.name === "README.md").contents = generateReadme(project) - } - return ( + hideSettings() } + onSave={ (settings) => { + // Remove leading and trailing whitespace on save + // settings.name = settings.name.trim() + const newProject = { ...project, settings } + regenerateFiles(newProject) + hideSettings() + setProject(newProject) + } } /> - updateProjectName(e.target.value) }/> + + { + (() => { + if (/^[ ]*$/.test(project.settings["robotbuilder.general.project_name"] as string) || !(project.settings["robotbuilder.general.team_number"] as number > 0)) { + return null + } else { + return (<>Team { project.settings["robotbuilder.general.team_number"] } - { project.settings["robotbuilder.general.project_name"] }) + } + })() + } + @@ -182,6 +198,12 @@ export function ProjectView({ initialProject }: ProjectProps) { {/* Hidden link for use by downloads */ } + + @@ -191,7 +213,6 @@ export function ProjectView({ initialProject }: ProjectProps) { id="load-project" onChange={ (e) => loadProject(e.target.files[0]).then(p => { setProject(p) - setProjectName(p.name) }) }/> @@ -202,6 +223,7 @@ export function ProjectView({ initialProject }: ProjectProps) { // Select the default tab handleChange(null, defaultTab) + setSettingsDialogProps({ visible: true, allowCancel: false }) } }> New diff --git a/src/ui/project/SettingsDialog.tsx b/src/ui/project/SettingsDialog.tsx new file mode 100644 index 0000000..74c3ad8 --- /dev/null +++ b/src/ui/project/SettingsDialog.tsx @@ -0,0 +1,164 @@ +import { DialogTitle, DialogContent, Table, TableBody, TableRow, TableCell, Input, Switch, DialogActions, Button } from "@mui/material" +import Dialog from "@mui/material/Dialog" +import React, { useEffect, useState } from "react" +import { Project, Settings, SettingsCategory } from "../../bindings/Project" +import { HelpableLabel } from "../HelpableLabel" +import { ALL_SETTINGS } from "../../settings/Settings" + +type SettingsDialogProps = { + project: Project + visible: boolean + allowCancel: boolean + onSave: (projectSettings: Settings) => void + onCancel: (projectSettings: Settings) => void +} + +export default function SettingsDialog({ project, visible, allowCancel, onSave, onCancel }: SettingsDialogProps) { + const blankStringRegex = /^[ ]*$/ + + const [settings, setSettings] = useState({ ...project.settings }) + const [isValid, setValid] = useState(false) + + useEffect(() => { + setSettings({ ...project.settings }) + }, [project, visible]) + + useEffect(() => { + let isValid = true + + for (const key in settings) { + const value = settings[key] + + const category: SettingsCategory = ALL_SETTINGS[key.substring(0, key.lastIndexOf("."))] + const setting = category.settings.find(s => s.key === key) + if (setting.required) { + switch (setting.type) { + case "string": + isValid &&= !blankStringRegex.test(value as string) + break + case "number": + isValid &&= value as number > 0 + break + case "boolean": + break + } + } + + if (!isValid) { + // Early exit if any required value is missing + break + } + } + + setValid(isValid) + }, [settings]) + + return ( + + + Project Settings + + + + + { + (() => { + const elements = [] + for (const categoryKey in ALL_SETTINGS) { + const category: SettingsCategory = ALL_SETTINGS[categoryKey] + elements.push(( + + +

+ { category.name } +

+
+ +
+ )) + category.settings.forEach(setting => { + elements.push(( + + + + { setting.name } + + + + { + (() => { + switch (setting.type) { + case "string": + return ( + setSettings({ ...settings, [setting.key]: event.target.value }) } /> + ) + case "number": + return ( + 0) } + onChange={ (event) => { + // Prevent non-numeric values from being entered + const input = event.target.value + + if (!input.match(/^([1-9]+[0-9]*)?$/g)) { + event.target.value = input.replaceAll(/[^0-9]+/g, "") + + // Prevent leading zeroes + if (event.target.value.charAt(0) === "0") { + event.target.value = event.target.value.substring(1) + } + return + } + + setSettings({ ...settings, [setting.key]: parseInt(event.target.value) }) + } }/> + ) + case "boolean": + return ( + setSettings({ ...settings, [setting.key]: event.target.checked }) } /> + ) + default: + // No editor, just show the value as text + return ( + + { settings[setting.key] } + + ) + } + })() + } + + + )) + }) + } + return elements + })() + } +
+
+
+ + { + allowCancel ? ( + + ) + : <> + } + + +
+ ) +} diff --git a/src/ui/robot/Robot.tsx b/src/ui/robot/Robot.tsx index c2d4564..e756073 100644 --- a/src/ui/robot/Robot.tsx +++ b/src/ui/robot/Robot.tsx @@ -1,4 +1,4 @@ -import React, { CSSProperties, useState } from "react" +import React, { CSSProperties, useEffect, useState } from "react" import { GeneratedFile, Project } from "../../bindings/Project" import { Box } from "@mui/material" import SyntaxHighlighter from "react-syntax-highlighter" @@ -64,6 +64,20 @@ const sortTree = (roots: FileTreeEntry[], sorter: (a: FileTreeEntry, b: FileTree export function Robot({ project }: { project: Project }) { const [selectedFile, setSelectedFile] = useState(project.generatedFiles.find(f => f.name === ROBOT_CLASS_PATH)) + // Reload the current file when the project changes + useEffect(() => { + const currentFile = selectedFile + const correspondingFile = project.generatedFiles.find(f => f.name === currentFile.name) + + if (correspondingFile !== undefined) { + // The project changed but still has a file in the same path. Keep it rendered. + setSelectedFile(correspondingFile) + } else { + // Fall back to render the README. This should still exist, right? + setSelectedFile(project.generatedFiles.find(f => f.name === "README.md")) + } + }, [project]) + return ( @@ -85,7 +99,9 @@ export function Robot({ project }: { project: Project }) { - /{ selectedFile.name } + + /{ selectedFile.name } +
-