diff --git a/src/RosBtPy.tsx b/src/RosBtPy.tsx index 702775e..82b82c1 100644 --- a/src/RosBtPy.tsx +++ b/src/RosBtPy.tsx @@ -80,6 +80,7 @@ import { } from "./types/services/SetExecutionMode"; import { SaveFileBrowser } from "./components/SaveFileBrowser"; import { LoadFileBrowser } from "./components/LoadFileBrowser"; +import { GenerateSubtreeBrowser } from "./components/GenerateSubtreeFileBrowser"; interface AppState { bt_namespace: string; @@ -122,6 +123,7 @@ interface AppState { messages_available: boolean; node_search: string; selected_node_name: string | null; + gen_subtree_msg: TreeMsg | null; } interface AppProps {} @@ -218,6 +220,7 @@ export class RosBtPyApp extends Component { messages_available: false, node_search: "", selected_node_name: null, + gen_subtree_msg: null, }; this.nodes_fuse = null; @@ -318,6 +321,7 @@ export class RosBtPyApp extends Component { this.handleNodeSearchClear = this.handleNodeSearchClear.bind(this); this.onNewRunningCommand = this.onNewRunningCommand.bind(this); this.onRunningCommandCompleted = this.onRunningCommandCompleted.bind(this); + this.onGeneratedSubtreeChange = this.onGeneratedSubtreeChange.bind(this); } onTreeUpdate(msg: TreeMsg) { @@ -1005,6 +1009,10 @@ export class RosBtPyApp extends Component { } } + onGeneratedSubtreeChange(tree_msg: TreeMsg | null) { + this.setState({ gen_subtree_msg: tree_msg }); + } + render() { let selectedNodeComponent = null; @@ -1025,6 +1033,8 @@ export class RosBtPyApp extends Component { onSelectionChange={this.onEditorSelectionChange} onMultipleSelectionChange={this.onMultipleSelectionChange} onSelectedEdgeChange={this.onSelectedEdgeChange} + onGenSubtreeChange={this.onGeneratedSubtreeChange} + onChangeFileModal={this.onChangeFileModal} /> ); } else if (this.state.selected_node === null) { @@ -1228,6 +1238,18 @@ export class RosBtPyApp extends Component { onSelectedStorageFolderChange={this.onSelectedStorageFolderChange} /> ); + } else if (this.state.show_file_modal === "generate_subtree") { + modal_content = ( + + ); } return ( diff --git a/src/components/GenerateSubtreeFileBrowser.tsx b/src/components/GenerateSubtreeFileBrowser.tsx new file mode 100644 index 0000000..f9e022b --- /dev/null +++ b/src/components/GenerateSubtreeFileBrowser.tsx @@ -0,0 +1,704 @@ +/* + * Copyright 2024 FZI Forschungszentrum Informatik + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * * Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * * Neither the name of the {copyright_holder} nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ +import { ChangeEvent, Component } from "react"; +import { PackageStructure, TreeMsg } from "../types/types"; +import { + GetStorageFoldersRequest, + GetStorageFoldersResponse, +} from "../types/services/GetStorageFolders"; +import { + GetFolderStructureRequest, + GetFolderStructureResponse, +} from "../types/services/GetFolderStructure"; +import { SaveTreeRequest, SaveTreeResponse } from "../types/services/SaveTree"; +import ROSLIB from "roslib"; +import Fuse from "fuse.js"; +import { + ChangeTreeNameRequest, + ChangeTreeNameResponse, +} from "../types/services/ChangeTreeName"; + +interface GenerateSubtreeBrowserProps { + ros: ROSLIB.Ros; + bt_namespace: string; + onError: (error_message: string) => void; + tree_message: TreeMsg | null; + last_selected_folder: string; + onChangeFileModal: (mode: string | null) => void; + onSelectedStorageFolderChange: (new_selected_folder_name: string) => void; +} + +interface GenerateSubtreeBrowserState { + storage_folder: string; + storage_folder_results: string[]; + selected_storage_folder: string | null; + show_hidden: false; + storage_folder_structure: PackageStructure | null; + selected_directory?: number; + selected_file: string; + file_path: string | null; + file_type_filter: string; + highlighted: number | null; + highlighted_package: number | null; + write_mode: string; + error_message: string | null; + last_selected_folder: string; + storage_folders_fuse: Fuse; + storage_folders_loaded: boolean; +} + +export class GenerateSubtreeBrowser extends Component< + GenerateSubtreeBrowserProps, + GenerateSubtreeBrowserState +> { + get_storage_folders_service?: ROSLIB.Service< + GetStorageFoldersRequest, + GetStorageFoldersResponse + >; + get_folder_structure_service?: ROSLIB.Service< + GetFolderStructureRequest, + GetFolderStructureResponse + >; + + change_tree_name_service?: ROSLIB.Service< + ChangeTreeNameRequest, + ChangeTreeNameResponse + >; + + save_service?: ROSLIB.Service; + + node?: HTMLDivElement | null; + + constructor(props: GenerateSubtreeBrowserProps) { + super(props); + + this.state = { + storage_folder: "", + storage_folder_results: [], + selected_storage_folder: null, + show_hidden: false, + storage_folder_structure: null, + selected_directory: undefined, + selected_file: "", + file_path: null, + file_type_filter: ".yaml", + highlighted: null, + highlighted_package: null, + write_mode: "ask", + error_message: null, + last_selected_folder: "", + storage_folders_loaded: false, + storage_folders_fuse: new Fuse([]), + }; + this.selectFolderSearchResult = this.selectFolderSearchResult.bind(this); + } + + componentDidMount() { + this.get_folder_structure_service = new ROSLIB.Service({ + ros: this.props.ros, + name: this.props.bt_namespace + "get_folder_structure", + serviceType: "ros_bt_py_interfaces/srv/GetFolderStructure", + }); + + this.get_storage_folders_service = new ROSLIB.Service({ + ros: this.props.ros, + name: this.props.bt_namespace + "get_storage_folders", + serviceType: "ros_bt_py_interfaces/srv/GetStorageFolders", + }); + + this.save_service = new ROSLIB.Service({ + ros: this.props.ros, + name: this.props.bt_namespace + "save_tree", + serviceType: "ros_bt_py_interfaces/srv/SaveTree", + }); + + this.change_tree_name_service = new ROSLIB.Service({ + ros: this.props.ros, + name: this.props.bt_namespace + "change_tree_name", + serviceType: "ros_bt_py_interfaces/srv/ChangeTreeName", + }); + + this.get_storage_folders_service.callService( + {} as GetStorageFoldersRequest, + (response: GetStorageFoldersResponse) => { + this.setState({ + storage_folders_fuse: new Fuse(response.storage_folders), + storage_folder_results: response.storage_folders, + storage_folders_loaded: true, + }); + }, + (error: string) => { + this.props.onError("Failed to retrive storage folders: " + error); + this.setState({ + storage_folders_loaded: false, + }); + } + ); + + if (this.props.last_selected_folder !== "") { + this.selectFolderSearchResult(this.props.last_selected_folder); + } + } + + selectFolderSearchResult(result: string) { + this.setState({ + storage_folder: result, + storage_folder_results: [], + selected_storage_folder: result, + highlighted_package: null, + }); + + // get package structure + this.get_folder_structure_service!.callService( + { + storage_folder: result, + show_hidden: this.state.show_hidden, + } as GetFolderStructureRequest, + (response: GetFolderStructureResponse) => { + if (response.success) { + this.setState({ + storage_folder_structure: JSON.parse( + response.storage_folder_structure + ), + selected_directory: 0, + }); + this.props.onSelectedStorageFolderChange(result); + } else { + console.error( + "error getting storage_folder structure: ", + response.error_message + ); + } + } + ); + } + + searchStorageFolderName(event: ChangeEvent) { + if (this.state.storage_folders_fuse) { + const results = this.state.storage_folders_fuse + .search(event.target.value) + .map((x) => x.item); + this.setState({ storage_folder_results: results.slice(0, 5) }); + } + this.setState({ + storage_folder: event.target.value, + selected_storage_folder: null, + highlighted_package: null, + }); + } + + keyPressHandler(event: React.KeyboardEvent) { + if ( + this.state.storage_folder_results && + this.state.storage_folder_results.length !== 0 + ) { + if (event.key == "ArrowDown" || event.key == "ArrowUp") { + // up or down arrow + let package_to_highlight = 0; + if (this.state.highlighted_package !== null) { + let direction = 1; + if (event.keyCode == 38) { + direction = -1; + } + package_to_highlight = this.state.highlighted_package + direction; + if (package_to_highlight < 0) { + package_to_highlight = this.state.storage_folder_results.length - 1; + } + + package_to_highlight %= this.state.storage_folder_results.length; + } + this.setState({ highlighted_package: package_to_highlight }); + } else if (event.key === "Enter") { + if (this.state.highlighted_package !== null) { + this.selectFolderSearchResult( + this.state.storage_folder_results[this.state.highlighted_package] + ); + } + } + } + } + + renderFolderSearchResults(results: string[]) { + if (results.length > 0) { + const result_rows = results.map((x, i) => { + return ( +
  • this.selectFolderSearchResult(x)} + > +
    + + + {x} + + +
    +
  • + ); + }); + + return ( +
    (this.node = node)}> +
      {result_rows}
    +
    + ); + } else { + return null; + } + } + + search(item_id: number, parent: PackageStructure) { + const stack = [parent]; + while (stack.length > 0) { + const node = stack.pop()!; + if (node.item_id === item_id) { + return node; + } + if (node.type === "directory") { + stack.push(...node.children!); + } + } + return stack.pop() || null; + } + + render() { + const compareStorageFolderContent = function ( + a: PackageStructure, + b: PackageStructure + ) { + const t1 = a.type.toLowerCase(); + const t2 = b.type.toLowerCase(); + + const n1 = a.name.toLowerCase(); + const n2 = b.name.toLowerCase(); + + if (t1 < t2) { + return -1; + } + if (t1 > t2) { + return 1; + } + if (n1 < n2) { + return -1; + } + if (n1 > n2) { + return 1; + } + return 0; + }; + + let storage_folders_results: string[] = []; + if (this.state.storage_folders_fuse) { + if (this.state.storage_folder_results.length === 0) { + if (this.state.storage_folder === "") { + storage_folders_results = this.state.storage_folders_fuse + .search("") + .map((x) => x.item); + } + } else { + storage_folders_results = this.state.storage_folder_results; + } + } + + let storage_folder_structure: JSX.Element | null = null; + if (this.state.storage_folder_structure) { + // this.state.selected_directory contains the level, is set to 0 (aka no element) by default + let selected_directory = 0; + // TODO: this is a bit of a hack + if (this.state.selected_directory === 0) { + // none selected, discard whole package structure + this.setState({ + selected_directory: this.state.storage_folder_structure.item_id, + }); + selected_directory = this.state.storage_folder_structure.item_id; + } else { + selected_directory = this.state.selected_directory!; + } + + const tree = this.search( + selected_directory, + this.state.storage_folder_structure + ); + if (tree === null) { + console.error("Package structure tree is null!"); + return; + } + + let par = tree.parent; + const path: string[] = []; + const extended_path = []; + if (par !== 0) { + path.push(tree.name); + extended_path.push({ + name: tree.name, + item_id: tree.item_id, + }); + } + while (par && par !== 0) { + const node = this.search(par, this.state.storage_folder_structure); + if (node === null) { + console.error("Node is null!"); + continue; + } + + par = node.parent; + if (par !== 0) { + path.unshift(node.name); + extended_path.unshift({ + name: node.name, + item_id: node.item_id, + }); + } + } + + const write_mode_select = ( + + ); + + const open_save_button = ( + + ); + + storage_folder_structure = ( +
    +
    + + {open_save_button} + + {write_mode_select} +
    +
    +
    + + input && input.focus()} + onChange={(event) => { + const file_path = path.concat(event.target.value); + const relative_path = file_path.join("/"); + this.setState({ + file_path: relative_path, + selected_file: event.target.value, + }); + }} + value={this.state.selected_file} + /> +
    +
    +

    + { + this.setState({ + selected_directory: 1, // directory 1 is top level + file_path: null, + selected_file: "", + highlighted: null, + }); + }} + > + {this.state.storage_folder_structure.name} + + {extended_path.map((element) => { + return ( + { + this.setState({ + selected_directory: element.item_id, + file_path: null, + selected_file: "", + highlighted: null, + }); + }} + > + {element.name} + + ); + })} +

    +
      + {tree!.children!.sort(compareStorageFolderContent).map((child) => { + let icon = ; + if (child.type === "directory") { + icon = ; + } + if ( + child.type === "file" && + this.state.file_type_filter !== "all" + ) { + if (!child.name.endsWith(this.state.file_type_filter)) { + return null; + } + } + return ( +
    • { + if (child.type === "file") { + const file_path = path.concat(child.name); + const relative_path = file_path.join("/"); + this.setState({ + file_path: relative_path, + selected_file: child.name, + highlighted: child.item_id, + }); + } else { + if (child.type === "directory") { + this.setState({ + selected_directory: child.item_id, + file_path: null, + selected_file: "", + }); + } + } + }} + > + {icon} {child.name} +
    • + ); + })} +
    +
    + ); + } + + let title = "Generate Subtree"; + if (!this.state.storage_folders_loaded) { + title += ". Please wait, storage folder list is loading..."; + } + + let package_name_element = null; + if (this.state.storage_folder_structure) { + package_name_element = ( + + ); + } else { + package_name_element = ( + input && input.focus()} + value={this.state.storage_folder} + disabled={false} + onChange={this.searchStorageFolderName} + onKeyDown={this.keyPressHandler} + /> + ); + } + + return ( +
    +
    + + {this.state.error_message} +
    +

    {title}

    +
    +
    + + {package_name_element} +
    + {this.renderFolderSearchResults(storage_folders_results)} +
    + {storage_folder_structure} +
    + ); + } +} diff --git a/src/components/MultipleSelection.tsx b/src/components/MultipleSelection.tsx index bc34c6b..2303b7c 100644 --- a/src/components/MultipleSelection.tsx +++ b/src/components/MultipleSelection.tsx @@ -27,29 +27,14 @@ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE * POSSIBILITY OF SUCH DAMAGE. */ -import { ChangeEvent, Component, KeyboardEvent } from "react"; +import { Component } from "react"; import { NodeDataWiring, Package, TreeMsg } from "../types/types"; import Fuse from "fuse.js"; import ROSLIB from "roslib"; -import { - WireNodeDataRequest, - WireNodeDataResponse, -} from "../types/services/WireNodeData"; -import { - RemoveNodeRequest, - RemoveNodeResponse, -} from "../types/services/RemoveNode"; -import { AddNodeRequest, AddNodeResponse } from "../types/services/AddNode"; import { GenerateSubtreeRequest, GenerateSubtreeResponse, } from "../types/services/GenerateSubtree"; -import { - AddNodeAtIndexRequest, - AddNodeAtIndexResponse, -} from "../types/services/AddNodeAtIndex"; -import { MoveNodeRequest, MoveNodeResponse } from "../types/services/MoveNode"; - interface MultipleSelectionProps { ros: ROSLIB.Ros; bt_namespace: string; @@ -62,17 +47,11 @@ interface MultipleSelectionProps { onSelectionChange: (new_selected_node_name: string | null) => void; onMultipleSelectionChange: (new_selected_node_names: string[] | null) => void; onSelectedEdgeChange: (new_selected_edge: NodeDataWiring | null) => void; + onChangeFileModal: (mode: string | null) => void; + onGenSubtreeChange: (tree_msg: TreeMsg | null) => void; } -interface MultipleSelectionState { - name: string; - isValid: boolean; - target: string; - description: string; - filename: string; - package: string; - package_results: Package[]; -} +interface MultipleSelectionState {} export class MultipleSelection extends Component< MultipleSelectionProps, @@ -82,30 +61,11 @@ export class MultipleSelection extends Component< GenerateSubtreeRequest, GenerateSubtreeResponse >; - add_node_service: ROSLIB.Service; - add_node_at_index_service: ROSLIB.Service< - AddNodeAtIndexRequest, - AddNodeAtIndexResponse - >; - move_node_service: ROSLIB.Service; - remove_node_service: ROSLIB.Service; - wire_data_service: ROSLIB.Service; - unwire_data_service: ROSLIB.Service< - WireNodeDataRequest, - WireNodeDataResponse - >; node: HTMLDivElement | null; constructor(props: MultipleSelectionProps) { super(props); - this.setFilename = this.setFilename.bind(this); - this.setDescription = this.setDescription.bind(this); - this.setTarget = this.setTarget.bind(this); - this.searchPackageName = this.searchPackageName.bind(this); - this.selectPackageSearchResult = this.selectPackageSearchResult.bind(this); this.onClickCreateSubtree = this.onClickCreateSubtree.bind(this); - this.updateValidity = this.updateValidity.bind(this); - this.handlePackageSearchClear = this.handlePackageSearchClear.bind(this); let name = this.props.selectedNodeNames.join("_"); if (name.length === 0) { @@ -114,82 +74,15 @@ export class MultipleSelection extends Component< this.node = null; - this.state = { - name: name, - isValid: false, - target: "", - description: "", - filename: "subtree.yaml", - package: this.props.last_selected_package, - package_results: [], - }; + this.state = {}; this.generate_subtree_service = new ROSLIB.Service({ ros: props.ros, name: props.bt_namespace + "generate_subtree", serviceType: "ros_bt_py_interfaces/srv/GenerateSubtree", }); - - this.add_node_service = new ROSLIB.Service({ - ros: props.ros, - name: props.bt_namespace + "add_node", - serviceType: "ros_bt_py_interfaces/srv/AddNode", - }); - - this.add_node_at_index_service = new ROSLIB.Service({ - ros: props.ros, - name: props.bt_namespace + "add_node_at_index", - serviceType: "ros_bt_py_interfaces/srv/AddNodeAtIndex", - }); - - this.move_node_service = new ROSLIB.Service({ - ros: props.ros, - name: props.bt_namespace + "move_node", - serviceType: "ros_bt_py_interfaces/srv/MoveNode", - }); - - this.remove_node_service = new ROSLIB.Service({ - ros: props.ros, - name: props.bt_namespace + "remove_node", - serviceType: "ros_bt_py_interfaces/srv/RemoveNode", - }); - - this.wire_data_service = new ROSLIB.Service({ - ros: props.ros, - name: props.bt_namespace + "wire_data", - serviceType: "ros_bt_py_interfaces/srv/WireNodeData", - }); - - this.unwire_data_service = new ROSLIB.Service({ - ros: props.ros, - name: props.bt_namespace + "unwire_data", - serviceType: "ros_bt_py_interfaces/srv/WireNodeData", - }); } - componentDidMount() { - document.addEventListener("click", this.handleClick); - } - - componentDidUpdate( - _prevProps: MultipleSelectionProps, - prevState: MultipleSelectionState - ) { - if (prevState.package != this.state.package) { - if (this.state.package !== "") { - this.setState({ isValid: true }); - } else { - this.setState({ isValid: false }); - } - } - } - - handleClick = (event: MouseEvent) => { - if (this.node && !this.node.contains(event.target as Element)) { - this.setState({ package_results: [] }); - } - }; - onClickCreateSubtree() { this.generate_subtree_service.callService( { @@ -198,6 +91,9 @@ export class MultipleSelection extends Component< (response: GenerateSubtreeResponse) => { if (response.success) { console.log("Generated subtree"); + console.log(response.tree); + this.props.onGenSubtreeChange(response.tree); + this.props.onChangeFileModal("generate_subtree"); } else { this.props.onError( "Failed to create subtree " + response.error_message @@ -207,71 +103,6 @@ export class MultipleSelection extends Component< ); } - searchPackageName(event: ChangeEvent) { - if (this.props.packagesFuse) { - const results = this.props.packagesFuse - .search(event.target.value) - .map((x) => x.item); - this.setState({ package_results: results.slice(0, 5) }); - } - this.setState({ package: event.target.value }); - } - - handlePackageSearchClear(e: KeyboardEvent) { - if (e.keyCode == 27) { - // ESC - this.setState({ package_results: [] }); - } - } - - selectPackageSearchResult(result: string) { - this.setState({ package: result }); - this.setState({ package_results: [] }); - } - - renderPackageSearchResults(results: Package[]) { - if (results.length > 0) { - const result_rows = results.map((x) => { - return ( -
    this.selectPackageSearchResult(x.package)} - > -
    - {x.package} - -
    -
    - ); - }); - - return ( -
    (this.node = node)}> -
    {result_rows}
    -
    - ); - } else { - return null; - } - } - - updateValidity() { - // do nothing. - } - - // FIXME this is temporary...!!! - setFilename(event: ChangeEvent) { - this.setState({ filename: event.target.value }); - } - - setDescription(event: ChangeEvent) { - this.setState({ description: event.target.value }); - } - - setTarget(event: ChangeEvent) { - this.setState({ target: event.target.value }); - } - render() { let create_subtree_text = "Create subtree from selected "; if (this.props.selectedNodeNames.length > 1) { @@ -286,42 +117,11 @@ export class MultipleSelection extends Component< -
    -
    - Filename{" "} - -
    - -
    - Package{" "} - -
    - - {this.renderPackageSearchResults(this.state.package_results)} -
    ); }