Skip to content

Commit 9014e53

Browse files
authored
Pr autosave (wpilibsuite#365)
* Adds autosave behavior * Make "save" menu item work * Fixed autosave bug * Changed to use named constant * Change to get project selected out of i18n * Changed to be same size as other text per review comment
1 parent ed4f630 commit 9014e53

File tree

6 files changed

+254
-16
lines changed

6 files changed

+254
-16
lines changed

src/App.tsx

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import SiderCollapseTrigger from './reactComponents/SiderCollapseTrigger';
2929
import ToolboxSettingsModal from './reactComponents/ToolboxSettings';
3030
import * as Tabs from './reactComponents/Tabs';
3131
import { TabType } from './types/TabType';
32+
import { AutosaveProvider } from './reactComponents/AutosaveManager';
3233

3334
import { extendedPythonGenerator } from './editor/extended_python_generator';
3435

@@ -471,19 +472,35 @@ const AppContent: React.FC<AppContentProps> = ({ project, setProject }): React.J
471472
tabsRef.current?.closeTab(tabKey);
472473
};
473474

475+
/** Saves the currently active tab. */
476+
const saveCurrentTab = async (): Promise<void> => {
477+
if (tabsRef.current) {
478+
await tabsRef.current.saveCurrentTab();
479+
}
480+
};
481+
482+
/** Gets the active tab key for autosave tracking. */
483+
const getActiveTabKey = (): string => {
484+
return tabsRef.current?.getActiveTabKey() || '';
485+
};
486+
474487
const { Sider } = Antd.Layout;
475488

476489
return (
477490
<Antd.ConfigProvider
478491
theme={antdThemeFromString(theme)}
479492
>
480493
{contextHolder}
481-
<Antd.Layout style={{ height: FULL_VIEWPORT_HEIGHT }}>
482-
<Header
483-
alertErrorMessage={alertErrorMessage}
484-
setAlertErrorMessage={setAlertErrorMessage}
485-
project={project}
486-
/>
494+
<AutosaveProvider
495+
saveCurrentTab={saveCurrentTab}
496+
activeTabKey={getActiveTabKey()}
497+
>
498+
<Antd.Layout style={{ height: FULL_VIEWPORT_HEIGHT }}>
499+
<Header
500+
alertErrorMessage={alertErrorMessage}
501+
setAlertErrorMessage={setAlertErrorMessage}
502+
project={project}
503+
/>
487504
<Antd.Layout
488505
style={{
489506
background: LAYOUT_BACKGROUND_COLOR,
@@ -508,6 +525,7 @@ const AppContent: React.FC<AppContentProps> = ({ project, setProject }): React.J
508525
openWPIToolboxSettings={() => setToolboxSettingsModalIsOpen(true)}
509526
theme={theme}
510527
setTheme={setTheme}
528+
saveCurrentTab={saveCurrentTab}
511529
/>
512530
<SiderCollapseTrigger
513531
collapsed={leftCollapsed}
@@ -538,6 +556,7 @@ const AppContent: React.FC<AppContentProps> = ({ project, setProject }): React.J
538556
onOk={handleToolboxSettingsConfirm}
539557
onCancel={handleToolboxSettingsCancel}
540558
/>
559+
</AutosaveProvider>
541560
</Antd.ConfigProvider>
542561
);
543562
};
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Porpoiseful LLC
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* https://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
/**
19+
* @fileoverview Autosave manager that tracks unsaved changes and triggers saves.
20+
* @author alan@porpoiseful.com (Alan Smith)
21+
*/
22+
import * as React from 'react';
23+
24+
/** Delay in milliseconds before auto-saving after a change. */
25+
const AUTOSAVE_DELAY_MS = 5000;
26+
27+
/** Context for autosave state. */
28+
interface AutosaveContextType {
29+
/** Whether the current tab has unsaved changes. */
30+
hasUnsavedChanges: boolean;
31+
/** Mark the current tab as having unsaved changes. */
32+
markAsModified: () => void;
33+
/** Mark the current tab as saved. */
34+
markAsSaved: () => void;
35+
/** Trigger an immediate save of the current tab. */
36+
triggerSave: () => Promise<void>;
37+
}
38+
39+
const AutosaveContext = React.createContext<AutosaveContextType | null>(null);
40+
41+
/** Props for AutosaveProvider. */
42+
interface AutosaveProviderProps {
43+
children: React.ReactNode;
44+
/** Function to save the current active tab. */
45+
saveCurrentTab: () => Promise<void>;
46+
/** Current active tab key. */
47+
activeTabKey: string;
48+
}
49+
50+
/**
51+
* Provider component that manages autosave state and triggers.
52+
* Handles debounced auto-save after changes.
53+
*/
54+
export function AutosaveProvider({ children, saveCurrentTab, activeTabKey }: AutosaveProviderProps): React.JSX.Element {
55+
const [hasUnsavedChanges, setHasUnsavedChanges] = React.useState(false);
56+
const [lastActiveTabKey, setLastActiveTabKey] = React.useState(activeTabKey);
57+
const saveTimerRef = React.useRef<NodeJS.Timeout | null>(null);
58+
59+
/** Clears any pending autosave timer. */
60+
const clearSaveTimer = React.useCallback(() => {
61+
if (saveTimerRef.current) {
62+
clearTimeout(saveTimerRef.current);
63+
saveTimerRef.current = null;
64+
}
65+
}, []);
66+
67+
/** Marks the current tab as having unsaved changes and schedules an autosave. */
68+
const markAsModified = React.useCallback(() => {
69+
setHasUnsavedChanges(true);
70+
71+
// Clear any existing timer
72+
clearSaveTimer();
73+
74+
// Schedule a new autosave
75+
saveTimerRef.current = setTimeout(async () => {
76+
try {
77+
await saveCurrentTab();
78+
setHasUnsavedChanges(false);
79+
} catch (error) {
80+
console.error('Autosave failed:', error);
81+
// Keep hasUnsavedChanges true on error
82+
}
83+
}, AUTOSAVE_DELAY_MS);
84+
}, [saveCurrentTab, clearSaveTimer]);
85+
86+
/** Marks the current tab as saved. */
87+
const markAsSaved = React.useCallback(() => {
88+
setHasUnsavedChanges(false);
89+
clearSaveTimer();
90+
}, [clearSaveTimer]);
91+
92+
/** Triggers an immediate save. */
93+
const triggerSave = React.useCallback(async () => {
94+
clearSaveTimer();
95+
try {
96+
await saveCurrentTab();
97+
setHasUnsavedChanges(false);
98+
} catch (error) {
99+
console.error('Manual save failed:', error);
100+
throw error;
101+
}
102+
}, [saveCurrentTab, clearSaveTimer]);
103+
104+
// When tab changes, clear unsaved state for the new tab
105+
React.useEffect(() => {
106+
if (activeTabKey !== lastActiveTabKey) {
107+
setHasUnsavedChanges(false);
108+
setLastActiveTabKey(activeTabKey);
109+
}
110+
}, [activeTabKey, lastActiveTabKey]);
111+
112+
// Cleanup on unmount
113+
React.useEffect(() => {
114+
return () => {
115+
clearSaveTimer();
116+
};
117+
}, [clearSaveTimer]);
118+
119+
const contextValue: AutosaveContextType = {
120+
hasUnsavedChanges,
121+
markAsModified,
122+
markAsSaved,
123+
triggerSave,
124+
};
125+
126+
return (
127+
<AutosaveContext.Provider value={contextValue}>
128+
{children}
129+
</AutosaveContext.Provider>
130+
);
131+
}
132+
133+
/**
134+
* Hook to access autosave context.
135+
* @returns Autosave context value.
136+
* @throws Error if used outside of AutosaveProvider.
137+
*/
138+
export function useAutosave(): AutosaveContextType {
139+
const context = React.useContext(AutosaveContext);
140+
if (!context) {
141+
throw new Error('useAutosave must be used within AutosaveProvider');
142+
}
143+
return context;
144+
}

src/reactComponents/Header.tsx

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import * as Antd from 'antd';
2222
import * as storageProject from '../storage/project';
2323
import * as React from 'react';
2424
import { useTranslation } from 'react-i18next';
25+
import { useAutosave } from './AutosaveManager';
2526

2627
/** Function type for setting string values. */
2728
type StringFunction = (input: string) => void;
@@ -52,6 +53,7 @@ const TEXT_PADDING_LEFT = 20;
5253
export default function Header(props: HeaderProps): React.JSX.Element {
5354
const { token } = Antd.theme.useToken();
5455
const { t } = useTranslation();
56+
const autosave = useAutosave();
5557

5658
const isDarkTheme = token.colorBgLayout === '#000000';
5759

@@ -77,7 +79,27 @@ export default function Header(props: HeaderProps): React.JSX.Element {
7779

7880
/** Gets the project name or fallback text. */
7981
const getProjectName = (): string => {
80-
return props.project?.projectName || 'No Project Selected';
82+
return props.project?.projectName || t('NO_PPROJECT_SELECTED');
83+
};
84+
85+
/** Renders the unsaved changes indicator. */
86+
const renderUnsavedIndicator = (): React.JSX.Element | null => {
87+
if (!autosave.hasUnsavedChanges) {
88+
return null;
89+
}
90+
91+
return (
92+
<Antd.Typography.Text
93+
style={{
94+
marginLeft: 8,
95+
fontSize: TEXT_FONT_SIZE,
96+
color: token.colorWarning,
97+
fontStyle: 'italic',
98+
}}
99+
>
100+
*
101+
</Antd.Typography.Text>
102+
);
81103
};
82104

83105
return (
@@ -106,6 +128,7 @@ export default function Header(props: HeaderProps): React.JSX.Element {
106128
}}
107129
>
108130
{t("PROJECT")}: {getProjectName()}
131+
{renderUnsavedIndicator()}
109132
</Antd.Typography>
110133
{renderErrorAlert()}
111134
</Antd.Flex>

src/reactComponents/Menu.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export interface MenuProps {
6565
openWPIToolboxSettings: () => void;
6666
theme: string;
6767
setTheme: (theme: string) => void;
68+
saveCurrentTab: () => Promise<void>;
6869
}
6970

7071
/** Default selected menu keys. */
@@ -273,6 +274,8 @@ export function Component(props: MenuProps): React.JSX.Element {
273274
setThemeModalOpen(true);
274275
} else if (key == 'deploy') {
275276
handleDeploy();
277+
} else if (key == 'save') {
278+
handleSave();
276279
} else if (key.startsWith('setlang:')) {
277280
const lang = key.split(':')[1];
278281
i18n.changeLanguage(lang);
@@ -283,6 +286,16 @@ export function Component(props: MenuProps): React.JSX.Element {
283286
}
284287
};
285288

289+
/** Handles the save action to save the current tab. */
290+
const handleSave = async (): Promise<void> => {
291+
try {
292+
await props.saveCurrentTab();
293+
} catch (error) {
294+
console.error('Failed to save current tab:', error);
295+
props.setAlertErrorMessage(t('FAILED_TO_SAVE_MODULE') || 'Failed to save module');
296+
}
297+
};
298+
286299
/** Handles the deploy action to generate and download Python files. */
287300
const handleDeploy = async (): Promise<void> => {
288301
if (!props.currentProject) {
@@ -294,6 +307,9 @@ export function Component(props: MenuProps): React.JSX.Element {
294307
}
295308

296309
try {
310+
// Save current tab before deploying
311+
await props.saveCurrentTab();
312+
297313
const blobUrl = await createPythonFiles.producePythonProjectBlob(props.currentProject, props.storage);
298314

299315
// Check if the backend server is available

src/reactComponents/TabContent.tsx

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import * as commonStorage from '../storage/common_storage';
3333
import * as classMethodDef from '../blocks/mrc_class_method_def'
3434
import * as eventHandler from '../blocks/mrc_event_handler'
3535
import { Content } from 'antd/es/layout/layout';
36+
import { useAutosave } from './AutosaveManager';
3637

3738
/** Default size for code panel. */
3839
const CODE_PANEL_DEFAULT_SIZE = '25%';
@@ -82,6 +83,8 @@ export const TabContent = React.forwardRef<TabContentRef, TabContentProps>(({
8283
const [codePanelCollapsed, setCodePanelCollapsed] = React.useState(false);
8384
const [codePanelExpandedSize, setCodePanelExpandedSize] = React.useState<string | number>(CODE_PANEL_DEFAULT_SIZE);
8485
const [codePanelAnimating, setCodePanelAnimating] = React.useState(false);
86+
const autosave = useAutosave();
87+
const isInitialActivation = React.useRef(true);
8588

8689
/** Expose saveModule method via ref. */
8790
React.useImperativeHandle(ref, () => ({
@@ -92,9 +95,11 @@ export const TabContent = React.forwardRef<TabContentRef, TabContentProps>(({
9295
// modulePathToContentText is passed to Editor.makeCurrent so the active editor will know
9396
// about changes to other modules.
9497
modulePathToContentText[modulePath] = moduleContentText;
98+
// Mark as saved after successful save
99+
autosave.markAsSaved();
95100
}
96101
},
97-
}), [editorInstance]);
102+
}), [editorInstance, autosave]);
98103

99104
/** Handles Blockly workspace changes and triggers code regeneration. */
100105
const handleBlocksChanged = React.useCallback((event: Blockly.Events.Abstract): void => {
@@ -119,9 +124,12 @@ export const TabContent = React.forwardRef<TabContentRef, TabContentProps>(({
119124
if (blocklyComponent.current &&
120125
event.workspaceId === blocklyComponent.current.getBlocklyWorkspace().id) {
121126
setTriggerPythonRegeneration(Date.now());
122-
// Also notify parent
127+
// Mark as modified when blocks change, but not during initial activation
128+
if (!isInitialActivation.current) {
129+
autosave.markAsModified();
130+
}
123131
}
124-
}, [blocklyComponent]);
132+
}, [blocklyComponent, autosave]);
125133

126134
/** Called when BlocklyComponent is created. */
127135
const setupBlocklyComponent = React.useCallback((_modulePath: string, newBlocklyComponent: BlocklyComponentType) => {
@@ -161,7 +169,13 @@ export const TabContent = React.forwardRef<TabContentRef, TabContentProps>(({
161169
blocklyComponent.current.setActive(isActive);
162170
}
163171
if (editorInstance && isActive) {
172+
// Set flag to ignore changes during activation
173+
isInitialActivation.current = true;
164174
editorInstance.makeCurrent(project, modulePathToContentText);
175+
// Clear the flag after a brief delay to allow workspace to settle
176+
setTimeout(() => {
177+
isInitialActivation.current = false;
178+
}, 100);
165179
}
166180
}, [isActive, blocklyComponent, editorInstance, project, modulePathToContentText]);
167181

0 commit comments

Comments
 (0)