From 00b279c9665048e81f5e4950112ce63b629043aa Mon Sep 17 00:00:00 2001 From: Alan Smith Date: Fri, 26 Sep 2025 18:57:31 -0400 Subject: [PATCH 1/3] Changes for saving open tabs as user settings --- src/App.tsx | 191 +++++++++++++++---- src/reactComponents/UserSettingsProvider.tsx | 56 ++++++ 2 files changed, 209 insertions(+), 38 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index a449a07d..b1f7785f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -153,7 +153,7 @@ interface AppContentProps { const AppContent: React.FC = ({ project, setProject }): React.JSX.Element => { const { t, i18n } = useTranslation(); - const { settings, updateLanguage, updateTheme, storage, isLoading } = useUserSettings(); + const { settings, updateLanguage, updateTheme, updateOpenTabs, getOpenTabs, storage, isLoading } = useUserSettings(); const [alertErrorMessage, setAlertErrorMessage] = React.useState(''); const [currentModule, setCurrentModule] = React.useState(null); @@ -163,6 +163,7 @@ const AppContent: React.FC = ({ project, setProject }): React.J const [modulePathToContentText, setModulePathToContentText] = React.useState<{[modulePath: string]: string}>({}); const [tabItems, setTabItems] = React.useState([]); const [activeTab, setActiveTab] = React.useState(''); + const [isLoadingTabs, setIsLoadingTabs] = React.useState(false); const [shownPythonToolboxCategories, setShownPythonToolboxCategories] = React.useState>(new Set()); const [triggerPythonRegeneration, setTriggerPythonRegeneration] = React.useState(0); const [leftCollapsed, setLeftCollapsed] = React.useState(false); @@ -384,35 +385,6 @@ const AppContent: React.FC = ({ project, setProject }): React.J handleToolboxSettingsOk(updatedShownCategories); }; - /** Creates tab items from project data. */ - const createTabItemsFromProject = (projectData: storageProject.Project): Tabs.TabItem[] => { - const tabs: Tabs.TabItem[] = [ - { - key: projectData.robot.modulePath, - title: t('ROBOT'), - type: TabType.ROBOT, - }, - ]; - - projectData.mechanisms.forEach((mechanism) => { - tabs.push({ - key: mechanism.modulePath, - title: mechanism.className, - type: TabType.MECHANISM, - }); - }); - - projectData.opModes.forEach((opmode) => { - tabs.push({ - key: opmode.modulePath, - title: opmode.className, - type: TabType.OPMODE, - }); - }); - - return tabs; - }; - /** Handles toolbox update requests from blocks */ const handleToolboxUpdateRequest = React.useCallback((e: Event) => { const workspaceId = (e as CustomEvent).detail.workspaceId; @@ -581,20 +553,163 @@ const AppContent: React.FC = ({ project, setProject }): React.J } }, [project]); - // Update tab items when ever the modules in the project change. + // Load saved tabs when project changes React.useEffect(() => { - if (project) { - const tabs = createTabItemsFromProject(project); - setTabItems(tabs); + const loadSavedTabs = async () => { + if (project && !isLoading) { + setIsLoadingTabs(true); + + // Add a small delay to ensure UserSettingsProvider context is updated + await new Promise(resolve => setTimeout(resolve, 0)); + + let tabsToSet: Tabs.TabItem[] = []; + let usedSavedTabs = false; + + // Try to load saved tabs first + try { + const savedTabPaths = await getOpenTabs(project.projectName); + + if (savedTabPaths.length > 0) { + // Filter saved tabs to only include those that still exist in the project + const validSavedTabs = savedTabPaths.filter((tabPath: string) => { + const module = storageProject.findModuleByModulePath(project!, tabPath); + return module !== null; + }); + + if (validSavedTabs.length > 0) { + usedSavedTabs = true; + // Convert paths back to TabItem objects + tabsToSet = validSavedTabs.map((path: string) => { + const module = storageProject.findModuleByModulePath(project!, path); + if (!module) return null; + + let type: TabType; + let title: string; + + switch (module.moduleType) { + case storageModule.ModuleType.ROBOT: + type = TabType.ROBOT; + title = t('ROBOT'); + break; + case storageModule.ModuleType.MECHANISM: + type = TabType.MECHANISM; + title = module.className; + break; + case storageModule.ModuleType.OPMODE: + type = TabType.OPMODE; + title = module.className; + break; + default: + return null; + } + + return { + key: path, + title, + type, + }; + }).filter((item): item is Tabs.TabItem => item !== null); + } + } + } catch (error) { + console.error('Failed to load saved tabs:', error); + } + + // If no saved tabs or loading failed, create default tabs (all project files) + if (tabsToSet.length === 0) { + tabsToSet = [ + { + key: project.robot.modulePath, + title: t('ROBOT'), + type: TabType.ROBOT, + } + ]; + + // Add all mechanisms + project.mechanisms.forEach((mechanism) => { + tabsToSet.push({ + key: mechanism.modulePath, + title: mechanism.className, + type: TabType.MECHANISM, + }); + }); + + // Add all opmodes + project.opModes.forEach((opmode) => { + tabsToSet.push({ + key: opmode.modulePath, + title: opmode.className, + type: TabType.OPMODE, + }); + }); + } + + // Set the tabs + setTabItems(tabsToSet); + + // Only set active tab to robot if no active tab is set or if the current active tab no longer exists + const currentActiveTabExists = tabsToSet.some(tab => tab.key === activeTab); + if (!activeTab || !currentActiveTabExists) { + setActiveTab(project.robot.modulePath); + } + + // Only auto-save if we didn't use saved tabs (i.e., this is a new project or the first time) + if (!usedSavedTabs) { + try { + const tabPaths = tabsToSet.map(tab => tab.key); + await updateOpenTabs(project.projectName, tabPaths); + } catch (error) { + console.error('Failed to auto-save default tabs:', error); + } + } + + setIsLoadingTabs(false); + } + }; + + loadSavedTabs(); + }, [project?.projectName, isLoading, getOpenTabs]); + + // Update tab items when modules in project change (for title updates, etc) + React.useEffect(() => { + if (project && tabItems.length > 0) { + // Update existing tab titles in case they changed + const updatedTabs = tabItems.map(tab => { + const module = storageProject.findModuleByModulePath(project, tab.key); + if (module && module.moduleType !== storageModule.ModuleType.ROBOT) { + return { ...tab, title: module.className }; + } + return tab; + }); - // Only set active tab to robot if no active tab is set or if the current active tab no longer exists - const currentActiveTabExists = tabs.some(tab => tab.key === activeTab); - if (!activeTab || !currentActiveTabExists) { - setActiveTab(project.robot.modulePath); + // Only update if something actually changed + const titlesChanged = updatedTabs.some((tab, index) => tab.title !== tabItems[index]?.title); + if (titlesChanged) { + setTabItems(updatedTabs); } } }, [modulePathToContentText]); + // Save tabs when tab list changes (but not during initial loading) + React.useEffect(() => { + const saveTabs = async () => { + // Don't save tabs while we're in the process of loading them + if (project?.projectName && tabItems.length > 0 && !isLoadingTabs) { + try { + const tabPaths = tabItems.map(tab => tab.key); + await updateOpenTabs(project.projectName, tabPaths); + } catch (error) { + console.error('Failed to save open tabs:', error); + // Don't show alert for save failures as they're not critical to user workflow + } + } + }; + + // Use a small delay to debounce rapid tab changes + const timeoutId = setTimeout(saveTabs, 100); + return () => clearTimeout(timeoutId); + }, [tabItems, project?.projectName, isLoadingTabs]); + const { Sider, Content } = Antd.Layout; return ( diff --git a/src/reactComponents/UserSettingsProvider.tsx b/src/reactComponents/UserSettingsProvider.tsx index 1dd0ea75..2e562ee3 100644 --- a/src/reactComponents/UserSettingsProvider.tsx +++ b/src/reactComponents/UserSettingsProvider.tsx @@ -31,6 +31,9 @@ const USER_THEME_KEY = 'userTheme'; const DEFAULT_LANGUAGE = 'en'; const DEFAULT_THEME = 'dark'; +/** Helper function to generate project-specific storage key for open tabs. */ +const getUserOptionsKey = (projectName: string): string => `user_options_${projectName}`; + /** User settings interface. */ export interface UserSettings { language: string; @@ -42,6 +45,8 @@ export interface UserSettingsContextType { settings: UserSettings; updateLanguage: (language: string) => Promise; updateTheme: (theme: string) => Promise; + updateOpenTabs: (projectName: string, tabPaths: string[]) => Promise; + getOpenTabs: (projectName: string) => Promise; isLoading: boolean; error: string | null; storage: Storage | null; @@ -134,10 +139,52 @@ export const UserSettingsProvider: React.FC = ({ } }; + /** Update open tabs for a specific project. */ + const updateOpenTabs = async (projectName: string, tabPaths: string[]): Promise => { + try { + setError(null); + + if (storage) { + const storageKey = getUserOptionsKey(projectName); + await storage.saveEntry(storageKey, JSON.stringify(tabPaths)); + } else { + console.warn('No storage available, cannot save open tabs'); + } + } catch (err) { + setError(`Failed to save open tabs: ${err}`); + console.error('Error saving open tabs:', err); + throw err; + } + }; + + /** Get open tabs for a specific project. */ + const getOpenTabs = async (projectName: string): Promise => { + try { + if (!storage) { + return []; + } + + const storageKey = getUserOptionsKey(projectName); + const tabsJson = await storage.fetchEntry(storageKey, JSON.stringify([])); + + try { + return JSON.parse(tabsJson); + } catch (error) { + console.warn(`Failed to parse open tabs for project ${projectName}, using default:`, error); + return []; + } + } catch (err) { + console.error(`Error loading open tabs for project ${projectName}:`, err); + return []; + } + }; + const contextValue: UserSettingsContextType = { settings, updateLanguage, updateTheme, + updateOpenTabs, + getOpenTabs, isLoading, error, storage: storage || null, @@ -149,3 +196,12 @@ export const UserSettingsProvider: React.FC = ({ ); }; + +/** Custom hook to use user settings context. */ +export const useUserSettings = (): UserSettingsContextType => { + const context = React.useContext(UserSettingsContext); + if (!context) { + throw new Error('useUserSettings must be used within a UserSettingsProvider'); + } + return context; +}; From 71f27ca745406a47be6e716050a5c48e617b05ed Mon Sep 17 00:00:00 2001 From: Alan Smith Date: Fri, 26 Sep 2025 20:09:43 -0400 Subject: [PATCH 2/3] Make it so you can make methods that return a value --- src/blocks/mrc_class_method_def.ts | 35 ++++++++++++++++++++++++++++++ src/toolbox/methods_category.ts | 3 ++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/blocks/mrc_class_method_def.ts b/src/blocks/mrc_class_method_def.ts index 9543106a..2faf965e 100644 --- a/src/blocks/mrc_class_method_def.ts +++ b/src/blocks/mrc_class_method_def.ts @@ -38,6 +38,7 @@ import { MUTATOR_BLOCK_NAME, PARAM_CONTAINER_BLOCK_NAME, MethodMutatorArgBlock } export const BLOCK_NAME = 'mrc_class_method_def'; export const FIELD_METHOD_NAME = 'NAME'; +export const RETURN_VALUE = 'RETURN'; type Parameter = { name: string, @@ -54,6 +55,7 @@ interface ClassMethodDefMixin extends ClassMethodDefMixinType { mrcParameters: Parameter[], mrcPythonMethodName: string, mrcFuncName: string | null, + mrcUpdateReturnInput(): void, } type ClassMethodDefMixinType = typeof CLASS_METHOD_DEF; @@ -179,6 +181,7 @@ const CLASS_METHOD_DEF = { (this as Blockly.BlockSvg).setMutator(null); } this.mrcUpdateParams(); + this.mrcUpdateReturnInput(); }, compose: function (this: ClassMethodDefBlock, containerBlock: any) { // Parameter list. @@ -250,6 +253,21 @@ const CLASS_METHOD_DEF = { } } }, + mrcUpdateReturnInput: function (this: ClassMethodDefBlock) { + // Remove existing return input if it exists + if (this.getInput(RETURN_VALUE)) { + this.removeInput(RETURN_VALUE); + } + + // Add return input if return type is not 'None' + if (this.mrcReturnType && this.mrcReturnType !== 'None') { + this.appendValueInput(RETURN_VALUE) + .setAlign(Blockly.inputs.Align.RIGHT) + .appendField(Blockly.Msg.PROCEDURES_DEFRETURN_RETURN); + // Move the return input to be before the statement input + this.moveInputBefore(RETURN_VALUE, 'STACK'); + } + }, removeParameterFields: function (input: Blockly.Input) { const fieldsToRemove = input.fieldRow .filter(field => field.name?.startsWith('PARAM_')) @@ -502,6 +520,23 @@ export function createCustomMethodBlock(): toolboxItems.Block { return new toolboxItems.Block(BLOCK_NAME, extraState, fields, null); } +export function createCustomMethodBlockWithReturn(): toolboxItems.Block { + const extraState: ClassMethodDefExtraState = { + canChangeSignature: true, + canBeCalledWithinClass: true, + canBeCalledOutsideClass: true, + returnType: 'Any', + params: [], + }; + const fields: {[key: string]: any} = {}; + fields[FIELD_METHOD_NAME] = 'my_method_with_return'; + const inputs: {[key: string]: any} = {}; + inputs[RETURN_VALUE] = { + 'type': 'input_value', + }; + return new toolboxItems.Block(BLOCK_NAME, extraState, fields, inputs); +} + export function getBaseClassBlocks( baseClassName: string): toolboxItems.Block[] { const blocks: toolboxItems.Block[] = []; diff --git a/src/toolbox/methods_category.ts b/src/toolbox/methods_category.ts index c6505850..66d5ab18 100644 --- a/src/toolbox/methods_category.ts +++ b/src/toolbox/methods_category.ts @@ -26,7 +26,7 @@ import * as storageModule from '../storage/module'; import { MRC_CATEGORY_STYLE_METHODS } from '../themes/styles'; import { CLASS_NAME_ROBOT_BASE, CLASS_NAME_OPMODE, CLASS_NAME_MECHANISM } from '../blocks/utils/python'; import { addInstanceWithinBlocks } from '../blocks/mrc_call_python_function'; -import { createCustomMethodBlock, getBaseClassBlocks, FIELD_METHOD_NAME } from '../blocks/mrc_class_method_def'; +import { createCustomMethodBlock, getBaseClassBlocks, FIELD_METHOD_NAME, createCustomMethodBlockWithReturn } from '../blocks/mrc_class_method_def'; import { Editor } from '../editor/editor'; @@ -113,6 +113,7 @@ class MethodsCategory { text: 'Custom Methods', }, createCustomMethodBlock(), + createCustomMethodBlockWithReturn() ); // Get blocks for calling methods defined in the current workspace. From 6ca6b65929b333d19634be356fab1fc968702098 Mon Sep 17 00:00:00 2001 From: Alan Smith Date: Fri, 26 Sep 2025 20:13:48 -0400 Subject: [PATCH 3/3] Revert "Make it so you can make methods that return a value" This reverts commit 71f27ca745406a47be6e716050a5c48e617b05ed. --- src/blocks/mrc_class_method_def.ts | 35 ------------------------------ src/toolbox/methods_category.ts | 3 +-- 2 files changed, 1 insertion(+), 37 deletions(-) diff --git a/src/blocks/mrc_class_method_def.ts b/src/blocks/mrc_class_method_def.ts index 2faf965e..9543106a 100644 --- a/src/blocks/mrc_class_method_def.ts +++ b/src/blocks/mrc_class_method_def.ts @@ -38,7 +38,6 @@ import { MUTATOR_BLOCK_NAME, PARAM_CONTAINER_BLOCK_NAME, MethodMutatorArgBlock } export const BLOCK_NAME = 'mrc_class_method_def'; export const FIELD_METHOD_NAME = 'NAME'; -export const RETURN_VALUE = 'RETURN'; type Parameter = { name: string, @@ -55,7 +54,6 @@ interface ClassMethodDefMixin extends ClassMethodDefMixinType { mrcParameters: Parameter[], mrcPythonMethodName: string, mrcFuncName: string | null, - mrcUpdateReturnInput(): void, } type ClassMethodDefMixinType = typeof CLASS_METHOD_DEF; @@ -181,7 +179,6 @@ const CLASS_METHOD_DEF = { (this as Blockly.BlockSvg).setMutator(null); } this.mrcUpdateParams(); - this.mrcUpdateReturnInput(); }, compose: function (this: ClassMethodDefBlock, containerBlock: any) { // Parameter list. @@ -253,21 +250,6 @@ const CLASS_METHOD_DEF = { } } }, - mrcUpdateReturnInput: function (this: ClassMethodDefBlock) { - // Remove existing return input if it exists - if (this.getInput(RETURN_VALUE)) { - this.removeInput(RETURN_VALUE); - } - - // Add return input if return type is not 'None' - if (this.mrcReturnType && this.mrcReturnType !== 'None') { - this.appendValueInput(RETURN_VALUE) - .setAlign(Blockly.inputs.Align.RIGHT) - .appendField(Blockly.Msg.PROCEDURES_DEFRETURN_RETURN); - // Move the return input to be before the statement input - this.moveInputBefore(RETURN_VALUE, 'STACK'); - } - }, removeParameterFields: function (input: Blockly.Input) { const fieldsToRemove = input.fieldRow .filter(field => field.name?.startsWith('PARAM_')) @@ -520,23 +502,6 @@ export function createCustomMethodBlock(): toolboxItems.Block { return new toolboxItems.Block(BLOCK_NAME, extraState, fields, null); } -export function createCustomMethodBlockWithReturn(): toolboxItems.Block { - const extraState: ClassMethodDefExtraState = { - canChangeSignature: true, - canBeCalledWithinClass: true, - canBeCalledOutsideClass: true, - returnType: 'Any', - params: [], - }; - const fields: {[key: string]: any} = {}; - fields[FIELD_METHOD_NAME] = 'my_method_with_return'; - const inputs: {[key: string]: any} = {}; - inputs[RETURN_VALUE] = { - 'type': 'input_value', - }; - return new toolboxItems.Block(BLOCK_NAME, extraState, fields, inputs); -} - export function getBaseClassBlocks( baseClassName: string): toolboxItems.Block[] { const blocks: toolboxItems.Block[] = []; diff --git a/src/toolbox/methods_category.ts b/src/toolbox/methods_category.ts index 66d5ab18..c6505850 100644 --- a/src/toolbox/methods_category.ts +++ b/src/toolbox/methods_category.ts @@ -26,7 +26,7 @@ import * as storageModule from '../storage/module'; import { MRC_CATEGORY_STYLE_METHODS } from '../themes/styles'; import { CLASS_NAME_ROBOT_BASE, CLASS_NAME_OPMODE, CLASS_NAME_MECHANISM } from '../blocks/utils/python'; import { addInstanceWithinBlocks } from '../blocks/mrc_call_python_function'; -import { createCustomMethodBlock, getBaseClassBlocks, FIELD_METHOD_NAME, createCustomMethodBlockWithReturn } from '../blocks/mrc_class_method_def'; +import { createCustomMethodBlock, getBaseClassBlocks, FIELD_METHOD_NAME } from '../blocks/mrc_class_method_def'; import { Editor } from '../editor/editor'; @@ -113,7 +113,6 @@ class MethodsCategory { text: 'Custom Methods', }, createCustomMethodBlock(), - createCustomMethodBlockWithReturn() ); // Get blocks for calling methods defined in the current workspace.