diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 8de526864d..b8538d24b1 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -299,6 +299,7 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" name = "choreo" version = "2024.0.4" dependencies = [ + "open", "serde", "serde_json", "tauri", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index bcecec2b8d..8a2500bf40 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -15,10 +15,11 @@ license = "BSD-3-Clause" tauri-build = { version = "1.4", features = [] } [dependencies] -tauri = { version = "1.4", features = [ "dialog-confirm", "dialog-save", "dialog-open", "dialog-save", "fs-all", "shell-open", "devtools" ] } +tauri = { version = "1.4", features = [ "window-close", "window-set-title", "path-all", "dialog", "dialog-confirm", "dialog-save", "dialog-open", "dialog-ask", "fs-all", "shell-open", "devtools"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" trajoptlib = { git = "https://github.com/SleipnirGroup/TrajoptLib.git", rev = "c9f8140e92dff07b2edb9c7034fea4d18dc46c9a" } +open = "3" [features] # this feature is used for production builds or when `devPath` points to the filesystem diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 7fc7833743..1b50d148b9 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -2,6 +2,8 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] use trajoptlib::{SwervePathBuilder, HolonomicTrajectory, SwerveDrivetrain, SwerveModule, InitialGuessPoint}; +use tauri::{api::{dialog::blocking::FileDialogBuilder, file}, Manager}; +use std::{fs, path::Path}; // A way to make properties that exist on all enum variants accessible from the generic variant // I have no idea how it works but it came from // https://users.rust-lang.org/t/generic-referencing-enum-inner-data/66342/9 @@ -18,6 +20,79 @@ use trajoptlib::{SwervePathBuilder, HolonomicTrajectory, SwerveDrivetrain, Swerv // }; // } + +#[derive(Clone, serde::Serialize, Debug)] +struct OpenFileEventPayload<'a> { + dir: Option<&'a str>, + name: Option<&'a str>, + contents: Option<&'a str>, + adjacent_gradle: bool +} + +#[tauri::command] +async fn contains_build_gradle(dir: Option<&Path>) -> Result { + dir.map_or_else(|| Err("Directory does not exist"), + |dir_path| { + let mut found_build_gradle = false; + for entry in dir_path.read_dir().expect("read_dir call failed") { + if let Ok(other_file) = entry { + found_build_gradle |= other_file.file_name().eq("build.gradle") + } + } + Ok(found_build_gradle)} + ) +} +#[tauri::command] +async fn open_file_dialog(app_handle: tauri::AppHandle){ + let file_path = FileDialogBuilder::new() + .set_title("Open a .chor file") + .add_filter("Choreo Save File", &["chor"]).pick_file(); + match file_path{ + Some(path)=>{ + let dir = path.parent(); + let name = path.file_name(); + let adjacent_gradle = contains_build_gradle(dir).await; + if dir.is_some() && name.is_some() && adjacent_gradle.is_ok() { + let _ = app_handle.emit_all("open-file", + OpenFileEventPayload { + dir: dir.unwrap().as_os_str().to_str(), + name: name.unwrap().to_str(), + contents: file::read_string(path.clone()).ok().as_deref(), + adjacent_gradle: adjacent_gradle.unwrap_or(false)}); + + } + + }, + None=>{} + } +} + +#[tauri::command] +async fn delete_file(dir: String, name: String) { + let dir_path = Path::new(&dir); + let name_path = Path::join(dir_path, name); + let _ = fs::remove_file(name_path); +} + +#[tauri::command] +async fn save_file(dir: String, name: String, contents: String) -> Result<(), &'static str> { + let dir_path = Path::new(&dir); + let name_path = Path::join(dir_path, name); + if name_path.is_relative() { + return Err("Dir needs to be absolute"); + } + let _ = fs::create_dir_all(dir_path); + if fs::write(name_path, contents).is_err() { + return Err("Failed file writing"); + } + Ok(()) +} + +#[tauri::command] +async fn open_file_app(dir: String) { + let _ = open::that(dir); +} + #[allow(non_snake_case)] #[derive(serde::Serialize, serde::Deserialize)] struct ChoreoWaypoint { @@ -280,7 +355,8 @@ async fn generate_trajectory( fn main() { tauri::Builder::default() - .invoke_handler(tauri::generate_handler![generate_trajectory, cancel]) + .invoke_handler(tauri::generate_handler![ + generate_trajectory, cancel, open_file_dialog, save_file, contains_build_gradle, delete_file, open_file_app]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/src-tauri/tauri.conf.in.json b/src-tauri/tauri.conf.in.json index 2ef4f15433..fa05552f80 100644 --- a/src-tauri/tauri.conf.in.json +++ b/src-tauri/tauri.conf.in.json @@ -17,19 +17,21 @@ "all": false, "open": true }, + "path": { + "all": true + }, "fs": { "all": true }, "dialog": { "confirm": true, "open": true, - "save": true + "save": true, + "ask": true }, "window": { - "setTitle": true - }, - "path": { - "all": true + "setTitle": true, + "close": true } }, "bundle": { diff --git a/src/AppMenu.tsx b/src/AppMenu.tsx index a812dec9b3..d1968aa7bf 100644 --- a/src/AppMenu.tsx +++ b/src/AppMenu.tsx @@ -4,8 +4,10 @@ import { observer } from "mobx-react"; import { Dialog, DialogTitle, + Divider, Drawer, List, + ListItem, ListItemButton, ListItemIcon, ListItemText, @@ -16,10 +18,18 @@ import MenuIcon from "@mui/icons-material/Menu"; import UploadIcon from "@mui/icons-material/UploadFile"; import IconButton from "@mui/material/IconButton"; import FileDownload from "@mui/icons-material/FileDownload"; +import FileCopyIcon from "@mui/icons-material/FileCopy"; import Tooltip from "@mui/material/Tooltip"; -import { NoteAddOutlined, Settings } from "@mui/icons-material"; +import { + CopyAll, + NoteAddOutlined, + OpenInNew, + Settings, +} from "@mui/icons-material"; import { ToastContainer, toast } from "react-toastify"; -import { dialog } from "@tauri-apps/api"; +import { dialog, invoke, path } from "@tauri-apps/api"; + +import * as nodePath from "path"; type Props = {}; @@ -33,6 +43,44 @@ class AppMenu extends Component { settingsOpen: false, }; + private convertToRelative(filePath: string): string { + return filePath.replace( + RegExp( + `^(?:C:)?\\${path.sep}(Users|home)\\${path.sep}[a-zA-Z]+\\${path.sep}` + ), + "~" + path.sep + ); + } + + CopyToClipboardButton({ data, tooltip }: { data: any; tooltip: string }) { + let handleAction = async function () { + await navigator.clipboard.writeText(data); + toast.success("Copied to clipboard"); + }; + + return ( + + + + + + ); + } + + OpenInFilesApp({ dir }: { dir: string }) { + let handleAction = async function () { + invoke("open_file_app", { dir }); + }; + + return ( + + + + + + ); + } + render() { let { mainMenuOpen, toggleMainMenu } = this.context.model.uiState; return ( @@ -76,9 +124,20 @@ class AppMenu extends Component { Choreo - + { - this.context.saveFile(); + onClick={async () => { + this.context.saveFileDialog(); }} > - + { @@ -114,7 +179,16 @@ class AppMenu extends Component { { - this.context.exportActiveTrajectory(); + toast.promise(this.context.exportActiveTrajectory(), { + pending: "Exporting trajectory...", + success: "Trajectory exported", + error: { + render(toastProps) { + console.error(toastProps.data); + return `Error exporting trajectory: ${toastProps.data}`; + }, + }, + }); }} > @@ -122,38 +196,126 @@ class AppMenu extends Component { + + { + if (!this.context.model.uiState.hasSaveLocation) { + if ( + await dialog.ask( + "Saving trajectories to the deploy directory requires saving the project. Save it now?", + { + title: "Choreo", + type: "warning", + } + ) + ) { + if (!(await this.context.saveFileDialog())) { + return; + } + } else { + return; + } + } + + toast.promise(this.context.exportAllTrajectories(), { + success: `Saved all trajectories to ${this.context.model.uiState.chorRelativeTrajDir}.`, + error: { + render(toastProps) { + console.error(toastProps.data); + return `Couldn't export trajectories: ${ + toastProps.data as string[] + }`; + }, + }, + }); + }} + > + + + + + + + +
+ {this.context.model.uiState.hasSaveLocation ? ( + <> + Project saved at

+
+ {this.projectLocation(true)} + + +
+

+ {this.context.model.uiState.isGradleProject + ? "Gradle (Java/C++) project detected." + : "Python project or no robot project detected."} +

+

+
+ {this.context.model.uiState.hasSaveLocation ? ( + <> + Trajectories saved in

+
+ {this.trajectoriesLocation(true)} + + +
+ + ) : ( + <> +
+
+ + )} + + ) : ( + <> + Project not saved. +
+ Click "Save File" above to save. + + )} +
+
- - { - if ( - e.target != null && - e.target.files != null && - e.target.files.length >= 1 - ) { - let fileList = e.target.files; - this.context.onFileUpload(fileList[0]); - e.target.value = ""; - } - }} - > ); } + + private projectLocation(relativeFormat: boolean): string { + return ( + (relativeFormat + ? this.convertToRelative( + this.context.model.uiState.saveFileDir as string + ) + : this.context.model.uiState.saveFileDir) + path.sep + ); + } + + private trajectoriesLocation(relativeFormat: boolean): string { + return ( + this.projectLocation(relativeFormat) + + this.context.model.uiState.chorRelativeTrajDir + + path.sep + ); + } } export default observer(AppMenu); diff --git a/src/Body.tsx b/src/Body.tsx index 6b4f3e512e..8f09405125 100644 --- a/src/Body.tsx +++ b/src/Body.tsx @@ -7,7 +7,6 @@ import Sidebar from "./components/sidebar/Sidebar"; import PathAnimationSlider from "./components/field/PathAnimationSlider"; import AppMenu from "./AppMenu"; import PathAnimationPanel from "./components/field/PathAnimationPanel"; -import { ToastContainer } from "react-toastify"; type Props = {}; diff --git a/src/components/field/Field.tsx b/src/components/field/Field.tsx index ae17437f0c..6543e2cb76 100644 --- a/src/components/field/Field.tsx +++ b/src/components/field/Field.tsx @@ -36,17 +36,6 @@ export class Field extends Component { let activePathUUID = this.context.model.document.pathlist.activePathUUID; return (
- {selectedSidebar !== undefined && "heading" in selectedSidebar && @@ -153,9 +142,9 @@ export class Field extends Component { marginInline: 0, visibility: activePath.canGenerate() ? "visible" : "hidden", }} - onClick={() => { - this.context.model.generatePathWithToasts(activePathUUID); - }} + onClick={() => + this.context.generateWithToastsAndExport(activePathUUID) + } disabled={!activePath.canGenerate()} > diff --git a/src/components/sidebar/PathSelector.tsx b/src/components/sidebar/PathSelector.tsx index 1ec74997b1..d7f14e5ca7 100644 --- a/src/components/sidebar/PathSelector.tsx +++ b/src/components/sidebar/PathSelector.tsx @@ -22,6 +22,7 @@ import { KeyboardArrowDown, Route, Settings } from "@mui/icons-material"; import Input from "../input/Input"; import InputList from "../input/InputList"; import { dialog } from "@tauri-apps/api"; +import { toast } from "react-toastify"; type Props = {}; @@ -59,7 +60,10 @@ class PathSelectorOption extends Component { } completeRename() { if (!this.checkName()) { - this.getPath().setName(this.nameInputRef.current!.value); + this.context.renamePath( + this.props.uuid, + this.nameInputRef.current!.value + ); } this.escapeRename(); } @@ -72,8 +76,11 @@ class PathSelectorOption extends Component { } checkName(): boolean { let inputName = this.nameInputRef.current!.value; - let error = this.searchForName(this.nameInputRef.current!.value); - error = error || inputName.length == 0; + let error = + inputName.length == 0 || + inputName.includes("/") || + inputName.includes("\\") || + this.searchForName(this.nameInputRef.current!.value); this.setState({ renameError: error, name: inputName }); return error; } @@ -101,11 +108,13 @@ class PathSelectorOption extends Component { + onClick={() => { + toast.dismiss(); // remove toasts that showed from last path, which is irrelevant for the new path + this.context.model.document.pathlist.setActivePathUUID( this.props.uuid - ) - } + ); + }} > {this.getPath().generating ? ( { .confirm(`Delete "${this.getPath().name}"?`) .then((result) => { if (result) { - this.context.model.document.pathlist.deletePath( - this.props.uuid - ); + this.context.deletePath(this.props.uuid); } }); }} diff --git a/src/document/DocumentManager.ts b/src/document/DocumentManager.ts index 1cf6939d47..b450f20a79 100644 --- a/src/document/DocumentManager.ts +++ b/src/document/DocumentManager.ts @@ -1,14 +1,28 @@ import { createContext } from "react"; import StateStore, { IStateStore } from "./DocumentModel"; -import { dialog, fs } from "@tauri-apps/api"; +import { + dialog, + fs, + invoke, + path, + window as tauriWindow, +} from "@tauri-apps/api"; +import { listen, TauriEvent, Event } from "@tauri-apps/api/event"; import { v4 as uuidv4 } from "uuid"; import { VERSIONS, validate, SAVE_FILE_VERSION } from "./DocumentSpecTypes"; import { applySnapshot, getRoot, onPatch } from "mobx-state-tree"; -import { toJS } from "mobx"; +import { autorun, reaction, toJS } from "mobx"; import { toast } from "react-toastify"; import "react-toastify/dist/ReactToastify.min.css"; import hotkeys from "hotkeys-js"; +import { resourceLimits } from "worker_threads"; +type OpenFileEventPayload = { + adjacent_gradle: boolean; + name: string | undefined; + dir: string | undefined; + contents: string; +}; export class DocumentManager { simple: any; undo() { @@ -34,15 +48,88 @@ export class DocumentManager { }); this.model.document.pathlist.addPath("NewPath"); this.model.document.history.clear(); + this.setupEventListeners(); + this.newFile(); + this.model.uiState.updateWindowTitle(); + } + + async handleOpenFileEvent(event: Event) { + let payload = event.payload as OpenFileEventPayload; + if (payload.dir === undefined || payload.name === undefined) { + throw "Non-UTF-8 characters in file path"; + } else if (payload.contents === undefined) { + throw "Unable to read file"; + } else { + this.model.uiState.setSaveFileName(payload.name); + this.model.uiState.setSaveFileDir(payload.dir); + this.model.uiState.setIsGradleProject(payload.adjacent_gradle); + + await this.openFromContents(payload.contents); + } + } + + async setupEventListeners() { + const openFileUnlisten = await listen("open-file", async (event) => + this.handleOpenFileEvent(event).catch((err) => + toast.error("Opening file error: " + err) + ) + ); + window.addEventListener("contextmenu", (e) => e.preventDefault()); - window.addEventListener("unload", () => hotkeys.unbind()); + + // Save files on closing + tauriWindow + .getCurrent() + .listen(TauriEvent.WINDOW_CLOSE_REQUESTED, async () => { + if (!this.model.uiState.hasSaveLocation) { + if ( + await dialog.ask("Save project?", { + title: "Choreo", + type: "warning", + }) + ) { + if (!(await this.saveFileDialog())) { + return; + } + } + } + await tauriWindow.getCurrent().close(); + }) + .then((unlisten) => { + window.addEventListener("unload", () => { + unlisten(); + }); + }); + const autoSaveUnlisten = reaction( + () => this.model.document.history.undoIdx, + () => { + if (this.model.uiState.hasSaveLocation) { + this.saveFile(); + } + } + ); + const updateTitleUnlisten = reaction( + () => this.model.uiState.saveFileName, + () => { + this.model.uiState.updateWindowTitle(); + } + ); + window.addEventListener("unload", () => { + hotkeys.unbind(); + openFileUnlisten(); + autoSaveUnlisten(); + updateTitleUnlisten(); + }); + hotkeys.unbind(); hotkeys("f5,ctrl+shift+r,ctrl+r", function (event, handler) { event.preventDefault(); }); hotkeys("command+g,ctrl+g,g", () => { - this.model.generatePathWithToasts( - this.model.document.pathlist.activePathUUID - ); + if (!this.model.document.pathlist.activePath.generating) { + this.generateWithToastsAndExport( + this.model.document.pathlist.activePathUUID + ); + } }); hotkeys("command+z,ctrl+z", () => { this.undo(); @@ -179,6 +266,30 @@ export class DocumentManager { }); } + async generateAndExport(uuid: string) { + await this.model!.generatePath(uuid); + await this.exportTrajectory(uuid); + } + + async generateWithToastsAndExport(uuid: string) { + this.model!.generatePathWithToasts(uuid).then(() => + toast.promise( + this.writeTrajectory(() => this.getTrajFilePath(uuid), uuid), + { + success: `Saved all trajectories to ${this.model.uiState.chorRelativeTrajDir}.`, + error: { + render(toastProps) { + console.error(toastProps.data); + return `Couldn't export trajectories: ${ + toastProps.data as string[] + }`; + }, + }, + } + ) + ); + } + private getSelectedWaypoint() { const waypoints = this.model.document.pathlist.activePath.waypoints; return waypoints.find((w) => { @@ -191,6 +302,7 @@ export class DocumentManager { return c.selected; }); } + private getSelectedObstacle() { const obstacles = this.model.document.pathlist.activePath.obstacles; return obstacles.find((o) => { @@ -201,7 +313,7 @@ export class DocumentManager { applySnapshot(this.model, { uiState: { selectedSidebarItem: undefined, - layers: [true, false, true, true], + layers: [true, false, true, true, true], }, document: { robotConfig: { identifier: uuidv4() }, @@ -212,130 +324,216 @@ export class DocumentManager { this.model.document.pathlist.addPath("NewPath"); this.model.document.history.clear(); } - async parseFile(file: File | null): Promise { - if (file == null) { - return Promise.reject("Tried to upload a null file"); + + async openFromContents(chorContents: string) { + const parsed = JSON.parse(chorContents); + if (validate(parsed)) { + this.model.fromSavedDocument(parsed); + } else { + console.error("Invalid Document JSON"); + toast.error( + "Could not parse selected document (Is it a choreo document?)" + ); } - this.model.uiState.setSaveFileName(file.name); - return new Promise((resolve, reject) => { - const fileReader = new FileReader(); - fileReader.onload = (event) => { - let output = event.target!.result; - if (typeof output === "string") { - resolve(output); - } - reject("File did not read as string"); - }; - fileReader.onerror = (error) => reject(error); - fileReader.readAsText(file); - }); } - async onFileUpload(file: File | null) { - await this.parseFile(file) - .then((content) => { - const parsed = JSON.parse(content); - if (validate(parsed)) { - this.model.fromSavedDocument(parsed); - } else { - console.error("Invalid Document JSON"); - toast.error( - "Could not parse selected document (Is it a choreo document?)", - { - containerId: "MENU", - } - ); - } - }) - .catch((err) => { - console.log(err); - toast.error("File load error: " + err, { - containerId: "MENU", - }); - }); + + async renamePath(uuid: string, newName: string) { + let oldPath = await this.getTrajFilePath(uuid); + this.model.document.pathlist.paths.get(uuid)?.setName(newName); + let newPath = await this.getTrajFilePath(uuid); + if (oldPath !== null) { + invoke("delete_file", { dir: oldPath[0], name: oldPath[1] }) + .then(() => this.writeTrajectory(() => newPath, uuid)) + .catch((e) => {}); + } } - async exportTrajectory(uuid: string) { + async deletePath(uuid: string) { + let newPath = await this.getTrajFilePath(uuid).catch(() => null); + this.model.document.pathlist.deletePath(uuid); + if (newPath !== null && this.model.uiState.hasSaveLocation) { + invoke("delete_file", { dir: newPath[0], name: newPath[1] }); + this.writeTrajectory(() => newPath, uuid); + } + } + + /** + * Save the specified trajectory to the file path supplied by the given async function + * @param filePath An (optionally async) function returning a 2-string array of [dir, name], or null + * @param uuid the UUID of the path with the trajectory to export + */ + async writeTrajectory( + filePath: () => Promise<[string, string] | null> | [string, string] | null, + uuid: string + ) { const path = this.model.document.pathlist.paths.get(uuid); if (path === undefined) { - console.error("Tried to export trajectory with unknown uuid: ", uuid); - toast.error("Tried to export trajectory with unknown uuid", { - autoClose: 5000, - hideProgressBar: false, - containerId: "MENU", - }); - return; + throw `Tried to export trajectory with unknown uuid ${uuid}`; } const trajectory = path.generated; if (trajectory.length < 2) { - console.error("Tried to export ungenerated trajectory: ", uuid); - toast.error("Cannot export ungenerated trajectory", { - autoClose: 5000, - hideProgressBar: false, - containerId: "MENU", - }); - return; + throw `Path is not generated`; } const content = JSON.stringify({ samples: trajectory }, undefined, 4); - const filePath = await dialog.save({ - title: "Export Trajectory", - defaultPath: `${path.name}.traj`, - filters: [ - { - name: "Trajopt Trajectory", - extensions: ["traj"], - }, - ], - }); - if (filePath) { - await fs.writeTextFile(filePath, content); + var file = await filePath(); + if (file) { + await invoke("save_file", { + dir: file[0], + name: file[1], + contents: content, + }); + } + } + + async getTrajFilePath(uuid: string): Promise<[string, string]> { + const choreoPath = this.model.document.pathlist.paths.get(uuid); + if (choreoPath === undefined) { + throw `Trajectory has unknown uuid ${uuid}`; } + const { hasSaveLocation, chorRelativeTrajDir } = this.model.uiState; + if (!hasSaveLocation || chorRelativeTrajDir === undefined) { + throw `Project has not been saved yet`; + } + const dir = + this.model.uiState.saveFileDir + + path.sep + + this.model.uiState.chorRelativeTrajDir; + return [dir, `${choreoPath.name}.traj`]; + } + + async exportTrajectory(uuid: string) { + return await this.writeTrajectory(() => { + return this.getTrajFilePath(uuid).then(async (filepath) => { + var file = await dialog.save({ + title: "Export Trajectory", + defaultPath: filepath.join(path.sep), + filters: [ + { + name: "Trajopt Trajectory", + extensions: ["traj"], + }, + ], + }); + if (file == null) { + throw "No file selected or user cancelled"; + } + return [await path.dirname(file), await path.basename(file)]; + }); + }, uuid); } + async exportActiveTrajectory() { return await this.exportTrajectory( this.model.document.pathlist.activePathUUID ); } - async loadFile(jsonFilename: string) { - await fetch(jsonFilename, { cache: "no-store" }) - .then((res) => { - return res.json(); - }) - .then((data) => { - this.model.fromSavedDocument(data); - }) - .catch((err) => console.log(err)); - } - async saveFile() { - const content = JSON.stringify(this.model.asSavedDocument(), undefined, 4); - if (!VERSIONS[SAVE_FILE_VERSION].validate(this.model.asSavedDocument())) { - console.warn("Invalid Doc JSON:\n" + "\n" + content); - return; + let dir = this.model.uiState.saveFileDir; + let name = this.model.uiState.saveFileName; + // we could use hasSaveLocation but TS wouldn't know + // that dir and name aren't undefined below + if (dir === undefined || name === undefined) { + return await this.saveFileDialog(); + } else { + this.handleChangeIsGradleProject(await this.saveFileAs(dir, name)); } + return true; + } + + async saveFileDialog() { const filePath = await dialog.save({ title: "Save Document", filters: [ { - name: "Trajopt Document", + name: "Choreo Document", extensions: ["chor"], }, ], }); - if (filePath) { - await fs.writeTextFile(filePath, content); + if (filePath === null) { + return false; + } + let dir = await path.dirname(filePath); + let name = await path.basename(filePath); + let newIsGradleProject = await this.saveFileAs(dir, name); + this.model.uiState.setSaveFileDir(dir); + this.model.uiState.setSaveFileName(name); + + toast.promise(this.handleChangeIsGradleProject(newIsGradleProject), { + success: `Saved all trajectories to ${this.model.uiState.chorRelativeTrajDir}.`, + error: { + render(toastProps) { + console.error(toastProps.data); + return `Couldn't export trajectories: ${toastProps.data as string[]}`; + }, + }, + }); + + toast.success(`Saved ${name}. Future changes will now be auto-saved.`); + return true; + } + + private async handleChangeIsGradleProject( + newIsGradleProject: boolean | undefined + ) { + let prevIsGradleProject = this.model.uiState.isGradleProject; + if (newIsGradleProject !== undefined) { + if (newIsGradleProject !== prevIsGradleProject) { + this.model.uiState.setIsGradleProject(newIsGradleProject); + } + + await this.exportAllTrajectories(); + } + } + + /** + * Save the document as `dir/name`. + * @param dir The absolute path to the directory that will contain the saved file + * @param name The saved file, name only, including the extension ".chor" + * @returns Whether the dir contains a file named "build.gradle", or undefined + * if something failed + */ + async saveFileAs(dir: string, name: string): Promise { + const contents = JSON.stringify(this.model.asSavedDocument(), undefined, 4); + try { + invoke("save_file", { dir, name, contents }); + // if we get past the above line, the dir and name were valid for saving. + let adjacent_build_gradle = invoke("contains_build_gradle", { + dir, + name, + }); + return adjacent_build_gradle; + } catch (err) { + console.error(err); + return undefined; } } - async downloadJSONString(content: string, name: string) { - const element = document.createElement("a"); - const file = new Blob([content], { type: "application/json" }); - let link = URL.createObjectURL(file); - //window.open(link, '_blank'); - //Uncomment to "save as..." the file - element.href = link; - element.download = name; - element.click(); + /** + * Export all trajectories to the deploy directory, as determined by uiState.isGradleProject + */ + async exportAllTrajectories() { + if (this.model.uiState.hasSaveLocation) { + var promises = this.model.document.pathlist.pathUUIDs.map((uuid) => + this.writeTrajectory(() => this.getTrajFilePath(uuid), uuid) + ); + var pathNames = this.model.document.pathlist.pathNames; + Promise.allSettled(promises).then((results) => { + var errors: string[] = []; + + results.map((result, i) => { + if (result.status === "rejected") { + console.error(pathNames[i], ":", result.reason); + errors.push(`Couldn't save "${pathNames[i]}": ${result.reason}`); + } + }); + + if (errors.length != 0) { + throw errors; + } + }); + } } } let DocumentManagerContext = createContext(new DocumentManager()); diff --git a/src/document/DocumentModel.tsx b/src/document/DocumentModel.tsx index 18c9b2d13b..8f3f800193 100644 --- a/src/document/DocumentModel.tsx +++ b/src/document/DocumentModel.tsx @@ -63,10 +63,9 @@ const StateStore = types async generatePath(uuid: string) { const pathStore = self.document.pathlist.paths.get(uuid); if (pathStore === undefined) { - return new Promise((resolve, reject) => - reject("Path store is undefined") - ); + throw "Path store is undefined"; } + return new Promise((resolve, reject) => { pathStore.fixWaypointHeadings(); const controlIntervalOptResult = @@ -76,7 +75,6 @@ const StateStore = types reject(controlIntervalOptResult) ); } - pathStore.setTrajectory([]); if (pathStore.waypoints.length < 2) { return; } @@ -154,35 +152,34 @@ const StateStore = types .actions((self) => { return { generatePathWithToasts(activePathUUID: string) { + var path = self.document.pathlist.paths.get(activePathUUID)!; + if (path.generating) { + return Promise.resolve(); + } toast.dismiss(); - var pathName = self.document.pathlist.paths.get(activePathUUID)!.name; + + var pathName = path.name; if (pathName === undefined) { toast.error("Tried to generate unknown path."); } - toast.promise( - self.generatePath(activePathUUID), - { - success: { - render({ data, toastProps }) { - return `Generated \"${pathName}\"`; - }, + return toast.promise(self.generatePath(activePathUUID), { + success: { + render({ data, toastProps }) { + return `Generated \"${pathName}\"`; }, + }, - error: { - render({ data, toastProps }) { - console.log(data); - if ((data as string).includes("User_Requested_Stop")) { - toastProps.style = { visibility: "hidden" }; - return `Cancelled \"${pathName}\"`; - } - return `Can't generate \"${pathName}\": ` + (data as string); - }, + error: { + render({ data, toastProps }) { + console.error(data); + if ((data as string).includes("User_Requested_Stop")) { + toastProps.style = { visibility: "hidden" }; + return `Cancelled \"${pathName}\"`; + } + return `Can't generate \"${pathName}\": ` + (data as string); }, }, - { - containerId: "FIELD", - } - ); + }); }, }; }); diff --git a/src/document/HolonomicPathStore.ts b/src/document/HolonomicPathStore.ts index 2294259fcf..5ce8f5dd83 100644 --- a/src/document/HolonomicPathStore.ts +++ b/src/document/HolonomicPathStore.ts @@ -477,6 +477,7 @@ export const HolonomicPathStore = types ) { self.generated = savedPath.trajectory; } + self.usesControlIntervalGuessing = savedPath.usesControlIntervalGuessing; self.defaultControlIntervalCount = diff --git a/src/document/HolonomicWaypointStore.ts b/src/document/HolonomicWaypointStore.ts index 5b72a7a827..1c58e6c085 100644 --- a/src/document/HolonomicWaypointStore.ts +++ b/src/document/HolonomicWaypointStore.ts @@ -78,6 +78,7 @@ export const HolonomicWaypointStore = types self.isInitialGuess = point.isInitialGuess; self.translationConstrained = point.translationConstrained; self.headingConstrained = point.headingConstrained; + self.controlIntervalCount = point.controlIntervalCount; }, setX(x: number) { diff --git a/src/document/PathListStore.ts b/src/document/PathListStore.ts index 5acca115c6..30dc7bcb07 100644 --- a/src/document/PathListStore.ts +++ b/src/document/PathListStore.ts @@ -93,6 +93,9 @@ export const PathListStore = types path!.fromSavedPath(list[name]); }); } + if (self.paths.size == 0) { + self.addPath("New Path", true); + } }, }; }); diff --git a/src/document/UIStateStore.tsx b/src/document/UIStateStore.tsx index 8c2fc7124a..81ce509050 100644 --- a/src/document/UIStateStore.tsx +++ b/src/document/UIStateStore.tsx @@ -8,7 +8,15 @@ import { Route, SquareOutlined, } from "@mui/icons-material"; -import { getRoot, Instance, types } from "mobx-state-tree"; +import { path, tauri, window as tauriWindow } from "@tauri-apps/api"; +import { getVersion } from "@tauri-apps/api/app"; +import { + cast, + castToReferenceSnapshot, + getRoot, + Instance, + types, +} from "mobx-state-tree"; import { ReactElement } from "react"; import InitialGuessPoint from "../assets/InitialGuessPoint"; import Waypoint from "../assets/Waypoint"; @@ -212,17 +220,32 @@ export type ViewLayerType = typeof ViewLayers; export const UIStateStore = types .model("UIStateStore", { fieldScalingFactor: 0.02, - saveFileName: "save", + saveFileName: types.maybe(types.string), + saveFileDir: types.maybe(types.string), + isGradleProject: types.maybe(types.boolean), waypointPanelOpen: false, visibilityPanelOpen: false, mainMenuOpen: false, pathAnimationTimestamp: 0, - layers: types.array(types.boolean), + layers: types.refinement( + types.array(types.boolean), + (arr) => arr?.length == ViewItemData.length + ), selectedSidebarItem: types.maybe(types.safeReference(SelectableItem)), selectedNavbarItem: NavbarLabels.FullWaypoint, }) .views((self: any) => { return { + get chorRelativeTrajDir() { + return ( + self.isGradleProject ? "src/main/deploy/choreo" : "deploy/choreo" + ).replaceAll("/", path.sep); + }, + get hasSaveLocation() { + return ( + self.saveFileName !== undefined && self.saveFileDir !== undefined + ); + }, getSelectedConstraint() { return navbarIndexToConstraint[self.selectedNavbarItem] ?? undefined; }, @@ -257,6 +280,14 @@ export const UIStateStore = types return []; }); }, + async updateWindowTitle() { + await tauriWindow + .getCurrent() + .setTitle( + `Choreo ${await getVersion()} - ${self.saveFileName ?? "Untitled"}` + ) + .catch(console.error); + }, }; }) .actions((self: any) => ({ @@ -271,6 +302,13 @@ export const UIStateStore = types }, setSaveFileName(name: string) { self.saveFileName = name; + self.updateWindowTitle(); + }, + setSaveFileDir(dir: string) { + self.saveFileDir = dir; + }, + setIsGradleProject(isGradleProject: boolean) { + self.isGradleProject = isGradleProject; }, setWaypointPanelOpen(open: boolean) { self.waypointPanelOpen = open; @@ -289,6 +327,7 @@ export const UIStateStore = types self.layers[layer] = visible; }, setVisibleLayers(visibleLayers: number[]) { + console.log(self.layers, visibleLayers); self.layers.fill(false); visibleLayers.forEach((layer) => { self.layers.length = Math.max(layer + 1, self.layers.length); diff --git a/src/main.tsx b/src/main.tsx index 4f1b9102f0..28095fa9ff 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,7 +2,23 @@ import React from "react"; import ReactDOM from "react-dom/client"; import App from "./App"; import "./styles.css"; +import { ToastContainer } from "react-toastify"; +import "react-toastify/dist/ReactToastify.min.css"; ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - + <> + + + );