Skip to content

Commit ea7bbbc

Browse files
committed
Save workspace layout to cache storage so it persists beyond browser session
1 parent f1c1526 commit ea7bbbc

File tree

17 files changed

+367
-90
lines changed

17 files changed

+367
-90
lines changed

src/components/cylc/workspace/Lumino.vue

Lines changed: 83 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,12 @@ import {
3939
onMounted,
4040
ref,
4141
} from 'vue'
42-
import { useStore } from 'vuex'
43-
import { startCase, uniqueId } from 'lodash'
42+
import { startCase, uniqueId } from 'lodash-es'
4443
import WidgetComponent from '@/components/cylc/workspace/Widget.vue'
45-
import LuminoWidget from '@/components/cylc/workspace/lumino-widget'
44+
import { LuminoWidget } from '@/components/cylc/workspace/luminoWidget'
4645
import { BoxPanel, DockPanel, Widget } from '@lumino/widgets'
46+
import { watchWithControl } from '@/utils/reactivity'
47+
import { replacer, reviver } from '@/utils/json'
4748
import { useDefaultView } from '@/views/views'
4849
import { eventBus } from '@/services/eventBus'
4950
@@ -66,8 +67,6 @@ import '@lumino/default-theme/style'
6667
* @property {Record<string,*>} initialOptions - prop passed to the view component
6768
*/
6869
69-
const $store = useStore()
70-
7170
const props = defineProps({
7271
workflowName: {
7372
type: String,
@@ -114,21 +113,35 @@ const resizeObserver = new ResizeObserver(() => {
114113
boxPanel.update()
115114
})
116115
117-
onMounted(() => {
116+
const layoutsCache = window.caches.open('workspace-layouts')
117+
const layoutWatcher = watchWithControl(views, saveLayout, { deep: true })
118+
119+
onMounted(async () => {
120+
// Store any add-view events that occur before the layout is ready
121+
// (e.g. when opening log view from command menu):
122+
const bufferedAddViewEvents = []
123+
eventBus.on('add-view', (e) => bufferedAddViewEvents.push(e))
124+
118125
// Attach box panel to DOM:
119126
Widget.attach(boxPanel, mainDiv.value)
120127
// Watch for resize of the main element to trigger relayout:
121128
resizeObserver.observe(mainDiv.value)
129+
130+
await getLayout()
131+
dockPanel.layoutModified.connect(() => layoutWatcher.trigger())
132+
133+
eventBus.off('add-view')
122134
eventBus.on('add-view', addView)
135+
bufferedAddViewEvents.forEach((e) => addView(e))
123136
eventBus.on('lumino:deleted', onWidgetDeleted)
124-
getLayout(props.workflowName)
137+
eventBus.on('reset-workspace-layout', resetToDefault)
125138
})
126139
127140
onBeforeUnmount(() => {
128141
resizeObserver.disconnect()
129142
eventBus.off('add-view', addView)
130143
eventBus.off('lumino:deleted', onWidgetDeleted)
131-
saveLayout()
144+
eventBus.off('reset-workspace-layout', resetToDefault)
132145
// Register with Lumino that the dock panel is no longer used,
133146
// otherwise uncaught errors can occur when restoring layout
134147
dockPanel.dispose()
@@ -140,71 +153,104 @@ onBeforeUnmount(() => {
140153
* @param {AddViewEvent} event
141154
* @param {boolean} onTop
142155
*/
143-
const addView = ({ name, initialOptions = {} }, onTop = true) => {
156+
async function addView ({ name, initialOptions = {} }, onTop = true) {
157+
layoutWatcher.pause()
144158
const id = uniqueId('widget')
145159
const luminoWidget = new LuminoWidget(id, startCase(name), /* closable */ true)
146160
dockPanel.addWidget(luminoWidget, { mode: 'tab-after' })
161+
if (onTop) {
162+
dockPanel.selectWidget(luminoWidget)
163+
}
147164
// give time for Lumino's widget DOM element to be created
148-
nextTick(() => {
149-
views.value.set(id, { name, initialOptions })
150-
if (onTop) {
151-
dockPanel.selectWidget(luminoWidget)
152-
}
153-
})
165+
await nextTick()
166+
views.value.set(id, { name, initialOptions })
167+
layoutWatcher.resume()
168+
layoutWatcher.trigger()
154169
}
155170
156171
/**
157172
* Remove all the widgets present in the DockPanel.
158173
*/
159-
// (This is likely to be used in the future)
160-
// eslint-disable-next-line no-unused-vars
161-
const closeAllViews = () => {
174+
async function closeAllViews () {
162175
for (const widget of Array.from(dockPanel.widgets())) {
163176
widget.close()
164177
}
178+
await nextTick()
165179
}
166180
167181
/**
168-
* Get the saved layout (if there is one) for the given workflow,
182+
* Get the saved layout (if there is one) for the current workflow,
169183
* else add the default view.
170-
*
171-
* @param {string} workflowName
172184
*/
173-
const getLayout = (workflowName) => {
174-
restoreLayout(workflowName) || addView({ name: defaultView.value })
185+
async function getLayout () {
186+
await restoreLayout() || await addView({ name: defaultView.value })
175187
}
176188
177189
/**
178-
* Save the current layout/views to the store.
190+
* Save the current layout/views to cache storage.
179191
*/
180-
const saveLayout = () => {
181-
$store.commit('app/saveLayout', {
182-
workflowName: props.workflowName,
192+
async function saveLayout () {
193+
// Serialize layout first to synchronously capture the current state
194+
const serializedLayout = JSON.stringify({
183195
layout: dockPanel.saveLayout(),
184-
views: new Map(views.value),
185-
})
196+
views: views.value,
197+
}, replacer)
198+
const cache = await layoutsCache
199+
// Overrides on FIFO basis:
200+
await cache.put(props.workflowName, new Response(
201+
serializedLayout,
202+
{ headers: { 'Content-Type': 'application/json' } }
203+
))
204+
const keys = await cache.keys()
205+
if (keys.length > 100) {
206+
await cache.delete(keys[0])
207+
}
208+
}
209+
210+
/**
211+
* Return the saved layout for this workflow from cache storage, if it was saved.
212+
*/
213+
async function getStoredLayout () {
214+
const cache = await layoutsCache
215+
const stored = await cache.match(props.workflowName).then((r) => r?.text())
216+
if (stored) {
217+
return JSON.parse(
218+
stored,
219+
(key, value) => reviver(key, LuminoWidget.layoutReviver(key, value))
220+
)
221+
}
186222
}
187223
188224
/**
189-
* Restore the layout for this workflow from the store, if it was saved.
225+
* Restore the layout for this workflow, if it was saved.
190226
*
191-
* @param {string} workflowName
192-
* @returns {boolean} true if the layout was restored, false otherwise
227+
* @returns true if the layout was restored, false otherwise
193228
*/
194-
const restoreLayout = (workflowName) => {
195-
const stored = $store.state.app.workspaceLayouts.get(workflowName)
229+
async function restoreLayout () {
230+
const stored = await getStoredLayout()
196231
if (stored) {
232+
layoutWatcher.pause()
197233
dockPanel.restoreLayout(stored.layout)
198234
// Wait for next tick so that Lumino has created the widget divs that the
199235
// views will be teleported into
200-
nextTick(() => {
201-
views.value = stored.views
202-
})
236+
await nextTick()
237+
views.value = stored.views
238+
layoutWatcher.resume()
203239
return true
204240
}
205241
return false
206242
}
207243
244+
/**
245+
* Reset the workspace layout to a single tab with the default view.
246+
*/
247+
async function resetToDefault () {
248+
layoutWatcher.pause()
249+
await closeAllViews()
250+
await addView({ name: defaultView.value })
251+
// (addView resumes the layout watcher)
252+
}
253+
208254
/**
209255
* React to a deleted event.
210256
*

src/components/cylc/workspace/Toolbar.vue

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,17 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
160160
</template>
161161
<v-list-item-title>{{ startCase(name) }}</v-list-item-title>
162162
</v-list-item>
163+
<v-divider/>
164+
<v-card-actions class="mb-n2">
165+
<v-btn
166+
@click="eventBus.emit('reset-workspace-layout')"
167+
:prepend-icon="$options.icons.mdiArrowULeftTop"
168+
class="flex-grow-1"
169+
data-cy="reset-layout-btn"
170+
>
171+
Reset layout
172+
</v-btn>
173+
</v-card-actions>
163174
</v-list>
164175
</v-menu>
165176
</v-btn>
@@ -207,7 +218,8 @@ import {
207218
mdiPlusBoxMultiple,
208219
mdiStop,
209220
mdiViewList,
210-
mdiAccount
221+
mdiAccount,
222+
mdiArrowULeftTop
211223
} from '@mdi/js'
212224
import { startCase } from 'lodash'
213225
import { until } from '@/utils/reactivity'
@@ -456,7 +468,8 @@ export default {
456468
run: mdiPlay,
457469
stop: mdiStop,
458470
mdiCog,
459-
mdiAccount
471+
mdiAccount,
472+
mdiArrowULeftTop,
460473
},
461474
}
462475
</script>

src/components/cylc/workspace/lumino-widget.js renamed to src/components/cylc/workspace/luminoWidget.js

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
/**
1+
/*
22
* Copyright (C) NIWA & British Crown (Met Office) & Contributors.
33
*
44
* This program is free software: you can redistribute it and/or modify
@@ -28,7 +28,7 @@ import { eventBus } from '@/services/eventBus'
2828
* Events in the widget will be propagated to the Vue component. Event
2929
* listeners much be attached to the DOM element with the widget ID.
3030
*/
31-
export default class LuminoWidget extends Widget {
31+
export class LuminoWidget extends Widget {
3232
/**
3333
* Create a LuminoWidget object.
3434
* @param {string} id - unique ID of the widget
@@ -87,4 +87,25 @@ export default class LuminoWidget extends Widget {
8787
eventBus.emit(`lumino:show:${this.id}`)
8888
super.onAfterShow(msg)
8989
}
90+
91+
toJSON () {
92+
// Allow the widget to be serialized.
93+
// We only need to store this limited info when saving the layout,
94+
// allowing us to entirely recreate the widget when restoring the layout.
95+
return {
96+
name: this.name,
97+
id: this.id,
98+
closable: this.closable,
99+
}
100+
}
101+
102+
/**
103+
* JSON.parse reviver function for reconstructing a serialized Lumino ILayoutConfig.
104+
*/
105+
static layoutReviver (key, value) {
106+
if (key === 'widgets') {
107+
return value.map((w) => new LuminoWidget(w.id, w.name, w.closable))
108+
}
109+
return value
110+
}
90111
}

src/store/app.module.js

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
/**
1+
/*
22
* Copyright (C) NIWA & British Crown (Met Office) & Contributors.
33
*
44
* This program is free software: you can redistribute it and/or modify
@@ -15,29 +15,14 @@
1515
* along with this program. If not, see <http://www.gnu.org/licenses/>.
1616
*/
1717

18-
import { markRaw } from 'vue'
19-
2018
const state = () => ({
2119
title: null,
22-
workspaceLayouts: new Map(),
2320
})
2421

2522
const mutations = {
2623
setTitle (state, title) {
2724
state.title = title
2825
},
29-
saveLayout ({ workspaceLayouts }, { workflowName, layout, views }) {
30-
// Delete and re-add to keep this FIFO
31-
workspaceLayouts.delete(workflowName)
32-
/* NOTE: use markRaw to prevent proxying of the Lumino layout in particular.
33-
It is not necessary for this saved state to be reactive, and moreover
34-
proxying the layout breaks some parts of the 3rd party Lumino backend. */
35-
workspaceLayouts.set(workflowName, markRaw({ layout, views }))
36-
if (workspaceLayouts.size > 100) {
37-
const firstKey = workspaceLayouts.keys().next().value
38-
workspaceLayouts.delete(firstKey)
39-
}
40-
}
4126
}
4227

4328
export const app = {

src/utils/initialOptions.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
/**
1+
/*
22
* Copyright (C) NIWA & British Crown (Met Office) & Contributors.
33
*
44
* This program is free software: you can redistribute it and/or modify
@@ -57,7 +57,7 @@ export function useInitialOptions (name, { props, emit }, defaultValue) {
5757
updateInitialOptionsEvent,
5858
{ ...props.initialOptions, [name]: val }
5959
),
60-
{ immediate: true, deep: true }
60+
{ deep: true }
6161
)
6262
return _ref
6363
}

src/utils/json.js

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/*
2+
* Copyright (C) NIWA & British Crown (Met Office) & Contributors.
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
16+
*/
17+
18+
// JSON utilities.
19+
20+
/**
21+
* Custom replacer function for JSON.stringify to handle JS objects that
22+
* cannot otherwise be serialized.
23+
*/
24+
export function replacer (key, value) {
25+
if (value instanceof Map) {
26+
return { _jsonType: 'Map', _val: Array.from(value) }
27+
}
28+
if (value instanceof Set) {
29+
return { _jsonType: 'Set', _val: Array.from(value) }
30+
}
31+
return value
32+
}
33+
34+
/**
35+
* Custom reviver function for JSON.parse to handle JS objects that
36+
* cannot otherwise be serialized.
37+
*/
38+
export function reviver (key, value) {
39+
if (value?._jsonType === 'Map') {
40+
return new Map(value._val)
41+
}
42+
if (value?._jsonType === 'Set') {
43+
return new Set(value._val)
44+
}
45+
return value
46+
}

0 commit comments

Comments
 (0)