+
+
diff --git a/web-src/src/main-app/components/scripts/ScriptListItem.vue b/web-src/src/main-app/components/scripts/ScriptListItem.vue
index b3a8cdf8..70b965a9 100644
--- a/web-src/src/main-app/components/scripts/ScriptListItem.vue
+++ b/web-src/src/main-app/components/scripts/ScriptListItem.vue
@@ -70,7 +70,19 @@ export default {
diff --git a/web-src/src/main-app/components/scripts/ScriptTabs.vue b/web-src/src/main-app/components/scripts/ScriptTabs.vue
new file mode 100644
index 00000000..28161bbe
--- /dev/null
+++ b/web-src/src/main-app/components/scripts/ScriptTabs.vue
@@ -0,0 +1,98 @@
+
+
+
+ {{ scriptName }}
+ close
+
+
+
+
+
+
+
diff --git a/web-src/src/main-app/components/scripts/script-view.vue b/web-src/src/main-app/components/scripts/script-view.vue
index 749c176f..ed3ea2f5 100644
--- a/web-src/src/main-app/components/scripts/script-view.vue
+++ b/web-src/src/main-app/components/scripts/script-view.vue
@@ -5,6 +5,7 @@
@@ -492,26 +493,18 @@ export default {
}
.actions-panel {
- margin-top: 8px;
+ margin-top: 16px;
display: flex;
+ align-items: center;
+ gap: 12px;
}
.actions-panel > .button-gap {
- flex: 3 1 1px;
-}
-
-.button-execute {
- flex: 4 1 312px;
-}
-
-.button-stop {
- margin-left: 16px;
- flex: 1 1 104px;
+ flex: 1 1 auto;
}
.schedule-button {
- margin-left: 32px;
- flex: 1 0 auto;
+ flex: 0 0 auto;
}
.script-input-panel {
diff --git a/web-src/src/main-app/index.js b/web-src/src/main-app/index.js
index 0ee905fc..b2017001 100644
--- a/web-src/src/main-app/index.js
+++ b/web-src/src/main-app/index.js
@@ -10,12 +10,14 @@ import MainApp from './MainApp.vue';
import router from './router/router'
import vueDirectives from '@/common/vueDirectives'
import vuetify from '@/common/vuetifyPlugin'
+import {initTheme, registerVuetifyTheme} from '@/common/utils/theme'
import {forEachKeyValue} from '@/common/utils/common';
import {useAuthStore} from '@/common/stores/auth';
import {useScriptsStore} from '@/main-app/stores/scripts';
import {useScriptConfigStore} from '@/main-app/stores/scriptConfig';
import {useScriptSetupStore} from '@/main-app/stores/scriptSetup';
import {useExecutionsStore} from '@/main-app/stores/executions';
+import {useScriptTabsStore} from '@/main-app/stores/scriptTabs';
const pinia = createPinia()
const app = createApp(MainApp)
@@ -28,11 +30,25 @@ app.use(pinia)
app.use(router)
app.use(vuetify)
+registerVuetifyTheme(vuetify.theme.global)
+initTheme()
+
const scriptsStore = useScriptsStore()
const scriptConfigStore = useScriptConfigStore()
const scriptSetupStore = useScriptSetupStore()
-watch(() => scriptsStore.selectedScript, (selectedScript) => {
+watch(() => scriptsStore.selectedScript, (selectedScript, previousScript) => {
+ const tabsStore = useScriptTabsStore()
+
+ // Snapshot the form values of the tab we're leaving so they can be restored
+ // when the user comes back to it.
+ if (!isNull(previousScript)) {
+ tabsStore.saveValues(previousScript, scriptSetupStore.parameterValues)
+ }
+ if (!isNull(selectedScript)) {
+ tabsStore.openTab(selectedScript)
+ }
+
scriptSetupStore.reset()
useScriptConfigStore().reloadScript(selectedScript)
useExecutionsStore().selectScript({selectedScript})
@@ -52,6 +68,15 @@ watch(() => scriptConfigStore.parameters, (parameters) => {
const scriptConfig = scriptConfigStore.scriptConfig
const scriptName = scriptConfig ? scriptConfig.name : null
scriptSetupStore.initFromParameters({scriptName, parameters, scriptConfig})
+
+ // Restore the previously-entered values for this tab, once. consumeValues
+ // deletes them so the parameter echo from reloadModel doesn't loop here.
+ if (!isNull(scriptName)) {
+ const savedValues = useScriptTabsStore().consumeValues(scriptName)
+ if (savedValues && Object.keys(savedValues).length > 0) {
+ scriptSetupStore.reloadModel({values: savedValues, forceAllowedValues: false, scriptName})
+ }
+ }
})
axiosInstance.interceptors.response.use(
diff --git a/web-src/src/main-app/stores/scriptTabs.js b/web-src/src/main-app/stores/scriptTabs.js
new file mode 100644
index 00000000..60205037
--- /dev/null
+++ b/web-src/src/main-app/stores/scriptTabs.js
@@ -0,0 +1,90 @@
+import {defineStore} from 'pinia'
+import clone from 'lodash/clone'
+import {isNull} from '@/common/utils/common'
+
+const STORAGE_KEY = 'script_server_open_tabs'
+
+function loadOpenTabs() {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY)
+ if (raw) {
+ const parsed = JSON.parse(raw)
+ if (Array.isArray(parsed)) {
+ return parsed.filter(name => typeof name === 'string')
+ }
+ }
+ } catch (e) {
+ // localStorage unavailable (private mode, jsdom) — start empty
+ }
+ return []
+}
+
+/**
+ * In-app tabs: keeps the list of scripts the user has opened, plus a per-tab
+ * snapshot of the parameter form values so switching tabs preserves work in
+ * progress. The open-tab list is persisted to localStorage; the snapshotted
+ * values are session-only (executions reconnect on reload via executions.init).
+ */
+export const useScriptTabsStore = defineStore('scriptTabs', {
+ state: () => ({
+ openTabs: loadOpenTabs(),
+ tabValues: {}
+ }),
+
+ actions: {
+ openTab(scriptName) {
+ if (isNull(scriptName)) {
+ return
+ }
+ if (!this.openTabs.includes(scriptName)) {
+ this.openTabs.push(scriptName)
+ this._persist()
+ }
+ },
+
+ // Removes a tab. Returns the script name that should become active when
+ // the closed tab was the active one (the neighbour), or null if no tabs
+ // remain.
+ closeTab(scriptName) {
+ const index = this.openTabs.indexOf(scriptName)
+ if (index === -1) {
+ return null
+ }
+
+ this.openTabs.splice(index, 1)
+ delete this.tabValues[scriptName]
+ this._persist()
+
+ if (this.openTabs.length === 0) {
+ return null
+ }
+ return this.openTabs[Math.min(index, this.openTabs.length - 1)]
+ },
+
+ saveValues(scriptName, values) {
+ if (isNull(scriptName)) {
+ return
+ }
+ this.tabValues[scriptName] = clone(values)
+ },
+
+ // Returns the saved values for a tab and removes them, so the caller can
+ // restore them exactly once (avoids re-triggering on parameter echoes).
+ consumeValues(scriptName) {
+ const values = this.tabValues[scriptName]
+ if (isNull(values)) {
+ return null
+ }
+ delete this.tabValues[scriptName]
+ return values
+ },
+
+ _persist() {
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(this.openTabs))
+ } catch (e) {
+ // ignore persistence failures, tabs still work in-session
+ }
+ }
+ }
+})
diff --git a/web-src/tests/unit/main-app/components/MainAppSidebar_test.js b/web-src/tests/unit/main-app/components/MainAppSidebar_test.js
index 72e80b38..43786903 100644
--- a/web-src/tests/unit/main-app/components/MainAppSidebar_test.js
+++ b/web-src/tests/unit/main-app/components/MainAppSidebar_test.js
@@ -31,36 +31,36 @@ describe('Test MainAppSidebar', function () {
describe('Test title', function () {
it('test title from config', async function () {
- const header = sidebar.find('.server-header');
+ const name = sidebar.find('.server-name');
- expect(header.text()).toBe('Custom name');
+ expect(name.text()).toBe('Custom name');
});
it('test change title in config', async function () {
useServerConfigStore().serverName = 'Another name';
await vueTicks();
- const header = sidebar.find('.server-header');
+ const name = sidebar.find('.server-name');
- expect(header.text()).toBe('Another name');
+ expect(name.text()).toBe('Another name');
});
it('test default title when missing', async function () {
useServerConfigStore().serverName = null;
await vueTicks();
- const header = sidebar.find('.server-header');
+ const name = sidebar.find('.server-name');
- expect(header.text()).toBe('Script server');
+ expect(name.text()).toBe('Script server');
});
it('test long title', async function () {
useServerConfigStore().serverName = 'Some very very long title';
await vueTicks();
- const header = sidebar.find('.server-header');
+ const name = sidebar.find('.server-name');
- expect(header.classes()).toContain('header-gt-21-chars');
+ expect(name.classes()).toContain('header-gt-21-chars');
});
});
diff --git a/web-src/tests/unit/main-app/stores/scriptTabs_test.js b/web-src/tests/unit/main-app/stores/scriptTabs_test.js
new file mode 100644
index 00000000..43b2e025
--- /dev/null
+++ b/web-src/tests/unit/main-app/stores/scriptTabs_test.js
@@ -0,0 +1,132 @@
+import {createPinia, setActivePinia} from 'pinia';
+import {useScriptTabsStore} from '@/main-app/stores/scriptTabs';
+
+function inMemoryLocalStorage() {
+ const data = {};
+ return {
+ getItem: (k) => (k in data ? data[k] : null),
+ setItem: (k, v) => {
+ data[k] = String(v);
+ },
+ removeItem: (k) => {
+ delete data[k];
+ },
+ _data: data
+ };
+}
+
+describe('Test scriptTabs store', function () {
+ let store;
+
+ beforeEach(function () {
+ vi.stubGlobal('localStorage', inMemoryLocalStorage());
+ setActivePinia(createPinia());
+ store = useScriptTabsStore();
+ });
+
+ afterEach(function () {
+ vi.unstubAllGlobals();
+ });
+
+ describe('openTab', function () {
+ it('adds a tab', function () {
+ store.openTab('scriptA');
+ expect(store.openTabs).toEqual(['scriptA']);
+ });
+
+ it('does not duplicate an already-open tab', function () {
+ store.openTab('scriptA');
+ store.openTab('scriptA');
+ expect(store.openTabs).toEqual(['scriptA']);
+ });
+
+ it('ignores null', function () {
+ store.openTab(null);
+ expect(store.openTabs).toEqual([]);
+ });
+
+ it('persists to localStorage', function () {
+ store.openTab('scriptA');
+ store.openTab('scriptB');
+ expect(JSON.parse(localStorage.getItem('script_server_open_tabs')))
+ .toEqual(['scriptA', 'scriptB']);
+ });
+ });
+
+ describe('closeTab', function () {
+ beforeEach(function () {
+ store.openTab('a');
+ store.openTab('b');
+ store.openTab('c');
+ });
+
+ it('removes the tab', function () {
+ store.closeTab('b');
+ expect(store.openTabs).toEqual(['a', 'c']);
+ });
+
+ it('returns the neighbour at the same index', function () {
+ expect(store.closeTab('b')).toBe('c');
+ });
+
+ it('returns the previous tab when closing the last one', function () {
+ expect(store.closeTab('c')).toBe('b');
+ });
+
+ it('returns null when no tabs remain', function () {
+ store.closeTab('a');
+ store.closeTab('b');
+ expect(store.closeTab('c')).toBeNull();
+ });
+
+ it('returns null for an unknown tab', function () {
+ expect(store.closeTab('unknown')).toBeNull();
+ });
+
+ it('drops saved values for the closed tab', function () {
+ store.saveValues('b', {x: 1});
+ store.closeTab('b');
+ expect(store.consumeValues('b')).toBeNull();
+ });
+ });
+
+ describe('saveValues / consumeValues', function () {
+ it('stores a clone, not the live reference', function () {
+ const values = {x: 1};
+ store.saveValues('a', values);
+ values.x = 2;
+ expect(store.consumeValues('a')).toEqual({x: 1});
+ });
+
+ it('returns null when nothing is saved', function () {
+ expect(store.consumeValues('a')).toBeNull();
+ });
+
+ it('consumes the values (one-shot)', function () {
+ store.saveValues('a', {x: 1});
+ expect(store.consumeValues('a')).toEqual({x: 1});
+ expect(store.consumeValues('a')).toBeNull();
+ });
+
+ it('ignores a null script name', function () {
+ store.saveValues(null, {x: 1});
+ expect(store.consumeValues(null)).toBeNull();
+ });
+ });
+
+ describe('restore from localStorage', function () {
+ it('loads previously persisted tabs on init', function () {
+ localStorage.setItem('script_server_open_tabs', JSON.stringify(['x', 'y']));
+ setActivePinia(createPinia());
+ const restored = useScriptTabsStore();
+ expect(restored.openTabs).toEqual(['x', 'y']);
+ });
+
+ it('falls back to empty when storage is malformed', function () {
+ localStorage.setItem('script_server_open_tabs', 'not json');
+ setActivePinia(createPinia());
+ const restored = useScriptTabsStore();
+ expect(restored.openTabs).toEqual([]);
+ });
+ });
+});