Skip to content

Commit 96ab2d5

Browse files
authored
Merge pull request #2176 from MetRonnie/save-layout
Persist saved workspace layouts
2 parents 9134707 + df3359c commit 96ab2d5

File tree

25 files changed

+461
-135
lines changed

25 files changed

+461
-135
lines changed

changes.d/2176.feat.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Workspace tab layout is now remembered beyond the current browser session.

src/components/cylc/Drawer.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ import Header from '@/components/cylc/Header.vue'
8787
import Workflows from '@/views/Workflows.vue'
8888
import { mdiHome, mdiInformationOutline } from '@mdi/js'
8989
import pkg from '@/../package.json'
90-
import { when } from '@/utils'
90+
import { when } from '@/utils/reactivity'
9191
import { useDrawer } from '@/utils/toolbar'
9292
9393
export const initialWidth = 260

src/components/cylc/log/Log.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
4242
<script>
4343
import { useTemplateRef, watch, onBeforeUnmount, nextTick } from 'vue'
4444
import { useScroll, useVModel, whenever } from '@vueuse/core'
45-
import { when } from '@/utils'
45+
import { when } from '@/utils/reactivity'
4646
import {
4747
mdiMouseMoveUp
4848
} from '@mdi/js'

src/components/cylc/tree/TreeItem.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ import {
176176
isFlowNone,
177177
} from '@/utils/tasks'
178178
import { getIndent, getNodeChildren } from '@/components/cylc/tree/util'
179-
import { once } from '@/utils'
179+
import { once } from '@/utils/reactivity'
180180
import { useToggle } from '@vueuse/core'
181181
import FlowNumsChip from '@/components/cylc/common/FlowNumsChip.vue'
182182

src/components/cylc/workspace/Lumino.vue

Lines changed: 88 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -39,14 +39,15 @@ 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'
47-
import { when } from '@/utils'
46+
import { watchWithControl } from '@/utils/reactivity'
47+
import { replacer, reviver } from '@/utils/json'
4848
import { useDefaultView } from '@/views/views'
4949
import { eventBus } from '@/services/eventBus'
50+
import { useWorkspaceLayoutsCache } from '@/composables/cacheStorage'
5051
5152
import '@lumino/default-theme/style'
5253
@@ -61,14 +62,12 @@ import '@lumino/default-theme/style'
6162
*/
6263
6364
/**
64-
* Mitt event for adding a view to the workspace.
65-
* @typedef {Object} AddViewEvent
66-
* @property {string} name - the view to add
65+
* Options for views in the workspace.
66+
* @typedef {Object} IViewOptions
67+
* @property {string} name - the view component name
6768
* @property {Record<string,*>} initialOptions - prop passed to the view component
6869
*/
6970
70-
const $store = useStore()
71-
7271
const props = defineProps({
7372
workflowName: {
7473
type: String,
@@ -98,7 +97,7 @@ const mainDiv = ref(null)
9897
/**
9998
* Mapping of widget ID to the name of view component and its initialOptions prop.
10099
*
101-
* @type {import('vue').Ref<Map<string, AddViewEvent>>}
100+
* @type {import('vue').Ref<Map<string, IViewOptions>>}
102101
*/
103102
const views = ref(new Map())
104103
@@ -115,21 +114,35 @@ const resizeObserver = new ResizeObserver(() => {
115114
boxPanel.update()
116115
})
117116
118-
onMounted(() => {
117+
const layoutsCache = useWorkspaceLayoutsCache()
118+
const layoutWatcher = watchWithControl(views, saveLayout, { deep: true })
119+
120+
onMounted(async () => {
121+
// Store any add-view events that occur before the layout is ready
122+
// (e.g. when opening log view from command menu):
123+
const bufferedAddViewEvents = []
124+
eventBus.on('add-view', (e) => bufferedAddViewEvents.push(e))
125+
119126
// Attach box panel to DOM:
120127
Widget.attach(boxPanel, mainDiv.value)
121128
// Watch for resize of the main element to trigger relayout:
122129
resizeObserver.observe(mainDiv.value)
130+
131+
await getLayout()
132+
dockPanel.layoutModified.connect(() => layoutWatcher.trigger())
133+
134+
eventBus.off('add-view')
123135
eventBus.on('add-view', addView)
136+
bufferedAddViewEvents.forEach((e) => addView(e))
124137
eventBus.on('lumino:deleted', onWidgetDeleted)
125-
getLayout(props.workflowName)
138+
eventBus.on('reset-workspace-layout', resetToDefault)
126139
})
127140
128141
onBeforeUnmount(() => {
129142
resizeObserver.disconnect()
130143
eventBus.off('add-view', addView)
131144
eventBus.off('lumino:deleted', onWidgetDeleted)
132-
saveLayout()
145+
eventBus.off('reset-workspace-layout', resetToDefault)
133146
// Register with Lumino that the dock panel is no longer used,
134147
// otherwise uncaught errors can occur when restoring layout
135148
dockPanel.dispose()
@@ -138,85 +151,105 @@ onBeforeUnmount(() => {
138151
/**
139152
* Create a widget and add it to the dock.
140153
*
141-
* @param {AddViewEvent} event
154+
* @param {IViewOptions} param0
142155
* @param {boolean} onTop
143156
*/
144-
const addView = ({ name, initialOptions = {} }, onTop = true) => {
157+
async function addView ({ name, initialOptions = {} }, onTop = true) {
158+
layoutWatcher.pause()
145159
const id = uniqueId('widget')
146160
const luminoWidget = new LuminoWidget(id, startCase(name), /* closable */ true)
147161
dockPanel.addWidget(luminoWidget, { mode: 'tab-after' })
162+
if (onTop) {
163+
dockPanel.selectWidget(luminoWidget)
164+
}
148165
// give time for Lumino's widget DOM element to be created
149-
nextTick(() => {
150-
views.value.set(id, { name, initialOptions })
151-
if (onTop) {
152-
dockPanel.selectWidget(luminoWidget)
153-
}
154-
})
166+
await nextTick()
167+
views.value.set(id, { name, initialOptions })
168+
layoutWatcher.resume()
169+
layoutWatcher.trigger()
155170
}
156171
157172
/**
158173
* Remove all the widgets present in the DockPanel.
159174
*/
160-
const closeAllViews = () => {
175+
async function closeAllViews () {
161176
for (const widget of Array.from(dockPanel.widgets())) {
162177
widget.close()
163178
}
179+
await nextTick()
164180
}
165181
166182
/**
167-
* Get the saved layout (if there is one) for the given workflow,
183+
* Get the saved layout (if there is one) for the current workflow,
168184
* else add the default view.
169-
*
170-
* @param {string} workflowName
171185
*/
172-
const getLayout = (workflowName) => {
173-
restoreLayout(workflowName) || addView({ name: defaultView.value })
186+
async function getLayout () {
187+
await restoreLayout() || await addView({ name: defaultView.value })
174188
}
175189
176190
/**
177-
* Save the current layout/views to the store.
191+
* Save the current layout/views to cache storage.
178192
*/
179-
const saveLayout = () => {
180-
$store.commit('app/saveLayout', {
181-
workflowName: props.workflowName,
193+
async function saveLayout () {
194+
// Serialize layout first to synchronously capture the current state
195+
const serializedLayout = JSON.stringify({
182196
layout: dockPanel.saveLayout(),
183-
views: new Map(views.value),
184-
})
197+
views: views.value,
198+
}, replacer)
199+
const cache = await layoutsCache
200+
// Overrides on FIFO basis:
201+
await cache.put(props.workflowName, new Response(
202+
serializedLayout,
203+
{ headers: { 'Content-Type': 'application/json' } }
204+
))
205+
const keys = await cache.keys()
206+
if (keys.length > 100) {
207+
await cache.delete(keys[0])
208+
}
185209
}
186210
187211
/**
188-
* Restore the layout for this workflow from the store, if it was saved.
212+
* Return the saved layout for this workflow from cache storage, if it was saved.
213+
*/
214+
async function getStoredLayout () {
215+
const cache = await layoutsCache
216+
const stored = await cache.match(props.workflowName).then((r) => r?.text())
217+
if (stored) {
218+
return JSON.parse(
219+
stored,
220+
(key, value) => reviver(key, LuminoWidget.layoutReviver(key, value))
221+
)
222+
}
223+
}
224+
225+
/**
226+
* Restore the layout for this workflow, if it was saved.
189227
*
190-
* @param {string} workflowName
191-
* @returns {boolean} true if the layout was restored, false otherwise
228+
* @returns true if the layout was restored, false otherwise
192229
*/
193-
const restoreLayout = (workflowName) => {
194-
const stored = $store.state.app.workspaceLayouts.get(workflowName)
230+
async function restoreLayout () {
231+
const stored = await getStoredLayout()
195232
if (stored) {
233+
layoutWatcher.pause()
196234
dockPanel.restoreLayout(stored.layout)
197235
// Wait for next tick so that Lumino has created the widget divs that the
198236
// views will be teleported into
199-
nextTick(() => {
200-
views.value = stored.views
201-
})
237+
await nextTick()
238+
views.value = stored.views
239+
layoutWatcher.resume()
202240
return true
203241
}
204242
return false
205243
}
206244
207245
/**
208-
* Save & close the current layout and open the one for the given workflow.
209-
*
210-
* @param {string} workflowName
246+
* Reset the workspace layout to a single tab with the default view.
211247
*/
212-
const changeLayout = (workflowName) => {
213-
saveLayout()
214-
closeAllViews()
215-
// Wait if necessary for the workflowName prop to be updated to the new value:
216-
when(
217-
() => props.workflowName === workflowName,
218-
() => getLayout(workflowName),
219-
)
248+
async function resetToDefault () {
249+
layoutWatcher.pause()
250+
await closeAllViews()
251+
await addView({ name: defaultView.value })
252+
// (addView resumes the layout watcher)
220253
}
221254
222255
/**
@@ -225,13 +258,12 @@ const changeLayout = (workflowName) => {
225258
* @param {string} id - widget ID
226259
*/
227260
const onWidgetDeleted = (id) => {
261+
// layoutWatcher will be triggered by DockPanel.layoutModified, so pause to avoid duplicate trigger:
262+
layoutWatcher.pause()
228263
views.value.delete(id)
264+
layoutWatcher.resume()
229265
if (!views.value.size) {
230266
emit('emptied')
231267
}
232268
}
233-
234-
defineExpose({
235-
changeLayout,
236-
})
237269
</script>

src/components/cylc/workspace/Toolbar.vue

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,17 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
167167
</template>
168168
<v-list-item-title>{{ startCase(name) }}</v-list-item-title>
169169
</v-list-item>
170+
<v-divider/>
171+
<v-card-actions class="mb-n2">
172+
<v-btn
173+
@click="eventBus.emit('reset-workspace-layout')"
174+
:prepend-icon="$options.icons.mdiArrowULeftTop"
175+
class="flex-grow-1"
176+
data-cy="reset-layout-btn"
177+
>
178+
Reset layout
179+
</v-btn>
180+
</v-card-actions>
170181
</v-list>
171182
</v-menu>
172183
</v-btn>
@@ -216,9 +227,10 @@ import {
216227
mdiViewList,
217228
mdiAccount,
218229
mdiChevronDown,
230+
mdiArrowULeftTop,
219231
} from '@mdi/js'
220232
import { startCase } from 'lodash'
221-
import { until } from '@/utils'
233+
import { until } from '@/utils/reactivity'
222234
import { useDrawer, useNavBtn, toolbarHeight } from '@/utils/toolbar'
223235
import WorkflowState from '@/model/WorkflowState.model'
224236
import graphql from '@/mixins/graphql'
@@ -472,6 +484,7 @@ export default {
472484
mdiCog,
473485
mdiAccount,
474486
mdiChevronDown,
487+
mdiArrowULeftTop,
475488
},
476489
}
477490
</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
}

0 commit comments

Comments
 (0)