Skip to content

Commit 1f33fe4

Browse files
authored
Merge branch 'dev' into dependabot/npm_and_yarn/npm_and_yarn-79fa654eaf
2 parents fc6ed01 + 8002c3b commit 1f33fe4

10 files changed

Lines changed: 700 additions & 39 deletions

File tree

DEV-GUIDE.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,41 @@ The file open size limit is configured in [src/toolbarActions/toolbarFunctions.j
88
- `MAX_FILE_SIZE` is derived from it as bytes.
99

1010
To change the limit, update `MAX_FILE_SIZE_MB` only.
11+
12+
## Workspace persistence (hybrid storage)
13+
14+
Workspace persistence now uses a hybrid model:
15+
16+
- Files/binary artifacts (imports/exports) stay on the browser filesystem path.
17+
- Graph/session metadata is stored in IndexedDB (`concore-editor-storage`) when available.
18+
- Local storage is used as a fallback if IndexedDB is unavailable.
19+
20+
### IndexedDB schema (v1)
21+
22+
- `graphs` store (key: `id`): graph snapshot payloads used by autosave/recovery.
23+
- `meta` store (key: `key`):
24+
- `graph_order`
25+
- `session`
26+
- `author_name`
27+
- `migrated_local_storage_v1`
28+
29+
### Migration behavior
30+
31+
On first successful IndexedDB init, legacy localStorage data is migrated once:
32+
33+
- all graph snapshots from `ALL_GRAPHS`
34+
- session metadata from `SESSION_STATE` (if present)
35+
- author metadata from `AUTHOR_NAME`
36+
37+
### Fallback behavior
38+
39+
If IndexedDB cannot be initialized, the app stays functional with localStorage fallback.
40+
The same public storage manager API is used in both cases.
41+
42+
### Manual verification checklist
43+
44+
1. Open multiple graphs, switch active tab, reload page.
45+
2. Confirm open tabs and active tab are restored.
46+
3. Edit graphs and verify autosave keeps action history.
47+
4. Corrupt one graph record manually and confirm app still loads remaining workspace.
48+
5. Simulate IndexedDB unavailability and verify fallback load/save still works.

package-lock.json

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/GraphWorkspace.jsx

Lines changed: 58 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,65 @@ const GraphComp = (props) => {
1313
const { dispatcher, superState } = props;
1414

1515
React.useEffect(() => {
16-
const allIDs = localStorageManager.getAllGraphs();
17-
const validIDs = allIDs.filter((id) => typeof id === 'string' && id && localStorageManager.get(id) !== null);
18-
if (validIDs.length !== allIDs.length) {
19-
allIDs.filter((id) => !validIDs.includes(id)).forEach((id) => localStorageManager.remove(id));
20-
}
21-
if (validIDs.length === 0) return;
22-
dispatcher({ type: T.ADD_GRAPH_BULK, payload: validIDs.map((graphID) => ({ graphID })) });
16+
let mounted = true;
17+
const restoreWorkspace = async () => {
18+
await localStorageManager.initialize();
19+
if (!mounted) return;
20+
21+
const session = localStorageManager.getSession();
22+
const allIDs = Array.isArray(session?.openGraphIDs) && session.openGraphIDs.length
23+
? session.openGraphIDs
24+
: localStorageManager.getAllGraphs();
25+
const validIDs = allIDs.filter(
26+
(id) => typeof id === 'string' && id && localStorageManager.get(id) !== null,
27+
);
28+
if (validIDs.length !== allIDs.length) {
29+
allIDs.filter((id) => !validIDs.includes(id)).forEach((id) => localStorageManager.remove(id));
30+
}
31+
32+
if (Array.isArray(session?.fileState)) {
33+
dispatcher({ type: T.SET_FILE_STATE, payload: session.fileState });
34+
}
35+
if (typeof session?.uploadedDirName === 'string') {
36+
dispatcher({ type: T.SET_DIR_NAME, payload: session.uploadedDirName });
37+
}
38+
39+
if (validIDs.length === 0) return;
40+
dispatcher({
41+
type: T.ADD_GRAPH_BULK,
42+
payload: {
43+
graphs: validIDs.map((graphID) => ({ graphID })),
44+
activeGraphID: session?.activeGraphID || null,
45+
},
46+
});
47+
};
48+
restoreWorkspace();
49+
50+
return () => {
51+
mounted = false;
52+
};
2353
}, []);
2454

55+
React.useEffect(() => {
56+
const openGraphIDs = superState.graphs
57+
.map((graph) => graph.graphID)
58+
.filter((graphID) => typeof graphID === 'string' && graphID);
59+
const activeGraphID = superState.curGraphIndex >= 0 && superState.curGraphIndex < superState.graphs.length
60+
? superState.graphs[superState.curGraphIndex].graphID
61+
: null;
62+
localStorageManager.saveSession({
63+
openGraphIDs,
64+
activeGraphID,
65+
fileState: superState.fileState,
66+
uploadedDirName: superState.uploadedDirName,
67+
});
68+
}, [
69+
superState.graphs,
70+
superState.curGraphIndex,
71+
superState.fileState,
72+
superState.uploadedDirName,
73+
]);
74+
2575
// Remote server implementation - Not being used.
2676
// useEffect(() => {
2777
// if (!loadedFromStorage) return;
@@ -84,6 +134,7 @@ const GraphComp = (props) => {
84134
isOpen={superState.confirmModal.open}
85135
title="Confirm"
86136
message={superState.confirmModal.message}
137+
actions={superState.confirmModal.actions}
87138
onConfirm={() => {
88139
if (superState.confirmModal.onConfirm) superState.confirmModal.onConfirm();
89140
dispatcher({ type: T.SET_CONFIRM_MODAL, payload: { open: false, message: '', onConfirm: null } });

src/component/fileBrowser.jsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ const LocalFileBrowser = ({ superState, dispatcher }) => {
5353
}, [superState.fileState]);
5454

5555
const handleSelectFile = (data) => {
56+
if (!data.fileObj) {
57+
toast.info('File handle is not available after reload. Re-open the file/directory.');
58+
return;
59+
}
5660
const fileExtensions = ['exe'];
5761
const fileExt = data.fileObj.name.split('.').pop().toLowerCase();
5862
if (fileExtensions.includes(fileExt)) {

src/component/modals/ConfirmModal.jsx

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,34 @@ import ParentModal from './ParentModal';
33
import './confirmModal.css';
44

55
const ConfirmModal = ({
6-
isOpen, title, message, onConfirm, onCancel,
6+
isOpen, title, message, onConfirm, onCancel, actions,
77
}) => (
88
<ParentModal ModelOpen={isOpen} closeModal={onCancel} title={title}>
99
<div className="confirm-modal-content">
1010
<div className="confirm-modal-message">{message}</div>
1111
<div className="confirm-modal-actions">
12-
<button type="button" className="confirm-btn" onClick={onConfirm}>Yes</button>
13-
<button type="button" className="cancel-btn" onClick={onCancel}>No</button>
12+
{
13+
actions && actions.length
14+
? actions.map((action) => (
15+
<button
16+
key={action.label}
17+
type="button"
18+
className={action.className || 'cancel-btn'}
19+
onClick={() => {
20+
if (action.onClick) action.onClick();
21+
onCancel();
22+
}}
23+
>
24+
{action.label}
25+
</button>
26+
))
27+
: (
28+
<>
29+
<button type="button" className="confirm-btn" onClick={onConfirm}>Yes</button>
30+
<button type="button" className="cancel-btn" onClick={onCancel}>No</button>
31+
</>
32+
)
33+
}
1434
</div>
1535
</div>
1636
</ParentModal>

src/component/modals/confirmModal.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
.confirm-modal-message {
66
margin-bottom: 20px;
77
font-size: 1.1em;
8+
white-space: pre-line;
89
}
910
.confirm-modal-actions {
1011
display: flex;

src/graph-builder/graph-core/6-server.js

Lines changed: 91 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { toast } from 'react-toastify';
22
import Axios from 'axios';
33
import { actionType as T } from '../../reducer';
4-
import { EXECUTION_ENGINE_URL } from '../../serverCon/config';
4+
import serverConConfig, { EXECUTION_ENGINE_URL } from '../../serverCon/config';
55
import GraphLoadSave from './5-load-save';
66
// import {
77
// postGraph, updateGraph, forceUpdateGraph, getGraph, getGraphWithHashCheck,
@@ -11,6 +11,86 @@ import {
1111
} from '../../serverCon/crud_http';
1212

1313
class GraphServer extends GraphLoadSave {
14+
static isSyncConflictError(err) {
15+
const msg = (err && err.message ? err.message : '').toLowerCase();
16+
return msg.includes('different history')
17+
|| msg.includes('latest changes')
18+
|| msg.includes('can not update');
19+
}
20+
21+
showSyncConflictModal(reason) {
22+
const localHash = this.actionArr.length ? this.actionArr.at(-1).hash : 'None';
23+
const setModal = (remoteHash) => {
24+
const message = [
25+
reason || 'Sync conflict detected.',
26+
`Local hash: ${localHash}`,
27+
`Remote hash: ${remoteHash || 'Remote history is newer/different'}`,
28+
'Choose how to resolve this conflict.',
29+
].join('\n');
30+
this.dispatcher({
31+
type: T.SET_CONFIRM_MODAL,
32+
payload: {
33+
open: true,
34+
message,
35+
actions: [
36+
{
37+
label: 'Pull remote',
38+
className: 'confirm-btn',
39+
onClick: () => this.forcePullFromServer(),
40+
},
41+
{
42+
label: 'Force push local',
43+
className: 'confirm-btn',
44+
onClick: () => {
45+
if (this.serverID) {
46+
forceUpdateGraph(this.serverID, this.getGraphML()).catch((err) => {
47+
toast.error(err.response?.data?.message || err.message);
48+
});
49+
} else {
50+
postGraph(this.getGraphML()).then((serverID) => {
51+
this.set({ serverID });
52+
}).catch((err) => {
53+
toast.error(err.response?.data?.message || err.message);
54+
});
55+
}
56+
},
57+
},
58+
{
59+
label: 'Open remote in new tab',
60+
className: 'cancel-btn',
61+
onClick: () => {
62+
if (!this.serverID) return;
63+
const remotePath = serverConConfig.getGraph(this.serverID);
64+
const remoteURL = `${serverConConfig.baseURL}${remotePath}`;
65+
window.open(remoteURL, '_blank', 'noopener,noreferrer');
66+
},
67+
},
68+
{
69+
label: 'Cancel',
70+
className: 'cancel-btn',
71+
onClick: null,
72+
},
73+
],
74+
},
75+
});
76+
};
77+
if (!this.serverID) {
78+
setModal('Unknown');
79+
return;
80+
}
81+
getGraph(this.serverID).then((graphXML) => {
82+
try {
83+
const doc = new DOMParser().parseFromString(graphXML, 'application/xml');
84+
const hashes = doc.getElementsByTagName('hash');
85+
setModal(hashes.length ? (hashes[hashes.length - 1].textContent || '') : '');
86+
} catch {
87+
setModal('Unavailable');
88+
}
89+
}).catch(() => {
90+
setModal('Unavailable');
91+
});
92+
}
93+
1494
set(config) {
1595
const { serverID } = config;
1696
super.set(config);
@@ -73,6 +153,10 @@ class GraphServer extends GraphLoadSave {
73153
updateGraph(this.serverID, this.getGraphML()).then(() => {
74154

75155
}).catch((err) => {
156+
if (GraphServer.isSyncConflictError(err)) {
157+
this.showSyncConflictModal('Cannot push: local and remote histories diverged.');
158+
return;
159+
}
76160
toast.error(err.response?.data?.message || err.message);
77161
});
78162
} else {
@@ -127,8 +211,12 @@ class GraphServer extends GraphLoadSave {
127211
if (this.serverID) {
128212
getGraphWithHashCheck(this.serverID, this.actionArr.at(-1).hash).then((graphXML) => {
129213
this.setGraphML(graphXML);
130-
}).catch(() => {
131-
214+
}).catch((err) => {
215+
if (GraphServer.isSyncConflictError(err)) {
216+
this.showSyncConflictModal('Cannot pull: local and remote histories diverged.');
217+
return;
218+
}
219+
toast.error(err.response?.data?.message || err.message);
132220
});
133221
} else {
134222
toast.success('Not on server');

0 commit comments

Comments
 (0)