diff --git a/README.md b/README.md index 3bd278e4..ccad9737 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,21 @@ ## What's new in this fork +### 2026-06-19 — Multi-tab workflow: status, notifications, shortcuts + +Builds on the in-app tabs to make working with several scripts at once smoother. + +- **Per-tab status indicator**: each tab shows the aggregated status of its + script's executions — a spinner while running, a check when finished, an error + icon on failure — so background tabs are readable at a glance. +- **Completion notifications**: an in-app snackbar when a script finishes or + fails, plus a native browser notification when the app isn't focused (permission + requested from the Execute click). +- **Keyboard shortcuts**: `Ctrl`/`Cmd`+`Enter` to run the current script, + `Alt`+`←`/`→` to switch tabs, `Alt`+`W` to close the active tab. +- **Replay last execution**: a one-click button to re-run a script with the + parameters of its most recent execution (shown when history exists). + ### 2026-06-19 — UI refresh, dark mode and in-app tabs A visual refresh of the main app, a dark theme, and the ability to keep several diff --git a/web-src/src/main-app/MainApp.vue b/web-src/src/main-app/MainApp.vue index fce8176a..62835d35 100644 --- a/web-src/src/main-app/MainApp.vue +++ b/web-src/src/main-app/MainApp.vue @@ -18,6 +18,7 @@ + @@ -31,6 +32,9 @@ import FaviconManager from './components/FaviconManager'; import MainAppSidebar from './components/MainAppSidebar'; import MainAppContent from './components/scripts/MainAppContent'; import ScriptTabs from './components/scripts/ScriptTabs'; +import ExecutionNotifier from './components/ExecutionNotifier'; +import {scriptNameToHash} from './utils/model_helper'; +import {useScriptTabsStore} from '@/main-app/stores/scriptTabs'; import {usePageStore} from '@/main-app/stores/page' import {useAuthStore} from '@/common/stores/auth' import {useServerConfigStore} from '@/main-app/stores/serverConfig' @@ -44,6 +48,7 @@ export default { MainAppSidebar, MainAppContent, ScriptTabs, + ExecutionNotifier, AppWelcomePanel, DocumentTitleManager, FaviconManager @@ -68,6 +73,44 @@ export default { this.$router.afterEach((to) => { this.$refs.appLayout.setSidebarVisibility(false); }); + + window.addEventListener('keydown', this.handleTabShortcuts); + }, + + beforeUnmount() { + window.removeEventListener('keydown', this.handleTabShortcuts); + }, + + methods: { + // Tab navigation shortcuts. Uses Alt-based combos: Ctrl+W / Ctrl+Tab are + // reserved by the browser and can't be reliably intercepted. + handleTabShortcuts(event) { + if (!event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) { + return; + } + + const tabsStore = useScriptTabsStore(); + const openTabs = tabsStore.openTabs; + const activeScript = useScriptsStore().selectedScript; + + if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') { + if (openTabs.length === 0) { + return; + } + event.preventDefault(); + const step = event.key === 'ArrowRight' ? 1 : -1; + let index = openTabs.indexOf(activeScript); + index = index === -1 ? 0 : (index + step + openTabs.length) % openTabs.length; + this.$router.push('/' + scriptNameToHash(openTabs[index])); + } else if (event.key === 'w' || event.key === 'W') { + if (isEmptyString(activeScript)) { + return; + } + event.preventDefault(); + const nextScript = tabsStore.closeTab(activeScript); + this.$router.push(nextScript ? '/' + scriptNameToHash(nextScript) : '/'); + } + } } } diff --git a/web-src/src/main-app/components/ExecutionNotifier.vue b/web-src/src/main-app/components/ExecutionNotifier.vue new file mode 100644 index 00000000..694afa7f --- /dev/null +++ b/web-src/src/main-app/components/ExecutionNotifier.vue @@ -0,0 +1,91 @@ + + + diff --git a/web-src/src/main-app/components/scripts/ScriptTabs.vue b/web-src/src/main-app/components/scripts/ScriptTabs.vue index 28161bbe..083323b2 100644 --- a/web-src/src/main-app/components/scripts/ScriptTabs.vue +++ b/web-src/src/main-app/components/scripts/ScriptTabs.vue @@ -14,6 +14,25 @@ class="script-tab" @click="activate(scriptName)" > + + check_circle + error + {{ scriptName }} import {useScriptTabsStore} from '@/main-app/stores/scriptTabs' import {useScriptsStore} from '@/main-app/stores/scripts' +import {useExecutionsStore} from '@/main-app/stores/executions' import {scriptNameToHash} from '@/main-app/utils/model_helper' export default { @@ -41,6 +61,9 @@ export default { } }, methods: { + statusFor(scriptName) { + return useExecutionsStore().getScriptStatus(scriptName) + }, activate(scriptName) { if (scriptName === this.activeScript) { return @@ -78,6 +101,19 @@ export default { color: var(--primary-color); } +.tab-status { + margin-right: 6px; + flex-shrink: 0; +} + +.tab-status.status-finished { + color: var(--primary-color); +} + +.tab-status.status-error { + color: var(--error-color); +} + .tab-label { max-width: 18ch; overflow: hidden; 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 ed3ea2f5..3818685c 100644 --- a/web-src/src/main-app/components/scripts/script-view.vue +++ b/web-src/src/main-app/components/scripts/script-view.vue @@ -11,6 +11,16 @@ @click="executeScript"> Execute + + Replay + 0); }, @@ -294,11 +317,49 @@ export default { return true; }, + handleExecuteShortcut: function (event) { + if (event.key !== 'Enter' || !(event.ctrlKey || event.metaKey)) { + return; + } + if (!this.enableExecuteButton || this.scheduleMode) { + return; + } + event.preventDefault(); + this.executeScript(); + }, + + requestNotificationPermission: function () { + // Called from the Execute click (a user gesture) so the browser allows the + // permission prompt. Used by ExecutionNotifier for background completions. + if (typeof Notification !== 'undefined' && Notification.permission === 'default') { + Notification.requestPermission().catch(() => {}); + } + }, + executeScript: function () { if (!this.validatePreExecution()) { return; } + this.requestNotificationPermission(); + this.startExecution(); + }, + + replayLastExecution: function () { + const values = getMostRecentValues(this.selectedScript); + if (isNull(values)) { + return; + } + + // Restore the last run's values into the form, then execute. reloadModel + // sets parameterValues synchronously, so startExecution picks them up. + useScriptSetupStore().reloadModel({ + values: deepCloneObject(values), + forceAllowedValues: true, + scriptName: this.selectedScript + }); + + this.requestNotificationPermission(); this.startExecution(); }, diff --git a/web-src/src/main-app/stores/executions.js b/web-src/src/main-app/stores/executions.js index ebb783f0..6b3d5f7c 100644 --- a/web-src/src/main-app/stores/executions.js +++ b/web-src/src/main-app/stores/executions.js @@ -25,6 +25,25 @@ export const useExecutionsStore = defineStore('executions', { }, actions: { + // Aggregated status of all executions for a given script, by priority: + // running > error > finished > none. Used by the tab status indicators. + getScriptStatus(scriptName) { + const statuses = Object.values(this.executors) + .filter(e => e.state.scriptName === scriptName) + .map(e => e.state.status) + + if (statuses.includes(STATUS_EXECUTING) || statuses.includes(STATUS_INITIALIZING)) { + return 'running' + } + if (statuses.includes(STATUS_ERROR) || statuses.includes(STATUS_DISCONNECTED)) { + return 'error' + } + if (statuses.includes(STATUS_FINISHED)) { + return 'finished' + } + return null + }, + init() { axiosInstance.get('executions/active') .then(({data: activeExecutionIds}) => { diff --git a/web-src/tests/unit/main-app/components/ExecutionNotifier_test.js b/web-src/tests/unit/main-app/components/ExecutionNotifier_test.js new file mode 100644 index 00000000..8a5d38f4 --- /dev/null +++ b/web-src/tests/unit/main-app/components/ExecutionNotifier_test.js @@ -0,0 +1,73 @@ +'use strict'; +import ExecutionNotifier from '@/main-app/components/ExecutionNotifier'; +import {mount} from '@vue/test-utils'; +import {createPinia, setActivePinia} from 'pinia'; +import {useExecutionsStore} from '@/main-app/stores/executions'; +import {attachToDocument} from '../../test_utils'; + +function executor(id, scriptName, status) { + return {state: {id, scriptName, status}}; +} + +describe('Test ExecutionNotifier', function () { + let notifier; + let executionsStore; + let pinia; + + beforeEach(function () { + pinia = createPinia(); + setActivePinia(pinia); + executionsStore = useExecutionsStore(); + notifier = mount(ExecutionNotifier, { + attachTo: attachToDocument(), + global: {plugins: [pinia]} + }); + }); + + afterEach(function () { + notifier.unmount(); + }); + + it('notifies when a running execution finishes', function () { + executionsStore.executors = {1: executor(1, 'myScript', 'executing')}; + notifier.vm.prevStatuses = {1: 'executing'}; + + executionsStore.executors[1].state.status = 'finished'; + notifier.vm.checkTransitions(); + + expect(notifier.vm.snackbar).toBe(true); + expect(notifier.vm.snackbarText).toBe('myScript finished'); + expect(notifier.vm.snackbarColor).toBe('primary'); + }); + + it('notifies a failure when a running execution errors', function () { + executionsStore.executors = {1: executor(1, 'myScript', 'executing')}; + notifier.vm.prevStatuses = {1: 'executing'}; + + executionsStore.executors[1].state.status = 'error'; + notifier.vm.checkTransitions(); + + expect(notifier.vm.snackbar).toBe(true); + expect(notifier.vm.snackbarText).toBe('myScript failed'); + expect(notifier.vm.snackbarColor).toBe('error'); + }); + + it('does not notify for a status change that is not a completion', function () { + executionsStore.executors = {1: executor(1, 'myScript', 'initializing')}; + notifier.vm.prevStatuses = {1: 'initializing'}; + + executionsStore.executors[1].state.status = 'executing'; + notifier.vm.checkTransitions(); + + expect(notifier.vm.snackbar).toBe(false); + }); + + it('does not re-notify when the status is unchanged', function () { + executionsStore.executors = {1: executor(1, 'myScript', 'finished')}; + notifier.vm.prevStatuses = {1: 'finished'}; + + notifier.vm.checkTransitions(); + + expect(notifier.vm.snackbar).toBe(false); + }); +}); diff --git a/web-src/tests/unit/main-app/components/scripts/ScriptTabs_test.js b/web-src/tests/unit/main-app/components/scripts/ScriptTabs_test.js new file mode 100644 index 00000000..289e10c9 --- /dev/null +++ b/web-src/tests/unit/main-app/components/scripts/ScriptTabs_test.js @@ -0,0 +1,85 @@ +'use strict'; +import ScriptTabs from '@/main-app/components/scripts/ScriptTabs'; +import {mount} from '@vue/test-utils'; +import {createPinia, setActivePinia} from 'pinia'; +import {useScriptsStore} from '@/main-app/stores/scripts'; +import {useExecutionsStore} from '@/main-app/stores/executions'; +import {useScriptTabsStore} from '@/main-app/stores/scriptTabs'; +import {attachToDocument} from '../../../test_utils'; +import router from '@/main-app/router/router'; + +describe('Test ScriptTabs', function () { + let pinia; + + function mountWith(openTabs, selectedScript, statusFn) { + pinia = createPinia(); + setActivePinia(pinia); + + useScriptTabsStore().openTabs = openTabs; + useScriptsStore().selectedScript = selectedScript; + vi.spyOn(useExecutionsStore(), 'getScriptStatus').mockImplementation(statusFn); + + return mount(ScriptTabs, { + attachTo: attachToDocument(), + global: {plugins: [pinia, router]} + }); + } + + afterEach(function () { + vi.restoreAllMocks(); + }); + + it('renders one tab per open script', function () { + const tabs = mountWith(['a', 'b'], 'a', () => null); + expect(tabs.findAll('.script-tab')).toHaveLength(2); + expect(tabs.text()).toContain('a'); + expect(tabs.text()).toContain('b'); + tabs.unmount(); + }); + + it('is hidden when no tabs are open', function () { + const tabs = mountWith([], null, () => null); + expect(tabs.find('.script-tabs').exists()).toBe(false); + tabs.unmount(); + }); + + it('shows a spinner for a running script', function () { + const tabs = mountWith(['a'], 'a', () => 'running'); + expect(tabs.find('.tab-status.v-progress-circular').exists()).toBe(true); + tabs.unmount(); + }); + + it('shows a check icon for a finished script', function () { + const tabs = mountWith(['a'], 'a', () => 'finished'); + const icon = tabs.find('.tab-status.status-finished'); + expect(icon.exists()).toBe(true); + expect(icon.text()).toBe('check_circle'); + tabs.unmount(); + }); + + it('shows an error icon for a failed script', function () { + const tabs = mountWith(['a'], 'a', () => 'error'); + const icon = tabs.find('.tab-status.status-error'); + expect(icon.exists()).toBe(true); + expect(icon.text()).toBe('error'); + tabs.unmount(); + }); + + it('shows no status icon for an idle script', function () { + const tabs = mountWith(['a'], 'a', () => null); + expect(tabs.find('.tab-status').exists()).toBe(false); + tabs.unmount(); + }); + + it('closing a tab removes it from the store', async function () { + const tabs = mountWith(['a', 'b'], 'b', () => null); + const store = useScriptTabsStore(); + + // close the non-active tab "a" via its close icon + const firstTab = tabs.findAll('.script-tab')[0]; + await firstTab.find('.tab-close').trigger('click'); + + expect(store.openTabs).toEqual(['b']); + tabs.unmount(); + }); +}); diff --git a/web-src/tests/unit/main-app/components/scripts/script-view_replay_test.js b/web-src/tests/unit/main-app/components/scripts/script-view_replay_test.js new file mode 100644 index 00000000..c9568c08 --- /dev/null +++ b/web-src/tests/unit/main-app/components/scripts/script-view_replay_test.js @@ -0,0 +1,100 @@ +'use strict'; + +vi.mock('@/common/utils/parameterHistory', () => ({ + getMostRecentValues: vi.fn(), + saveParameterHistory: vi.fn(), + loadParameterHistory: vi.fn(() => []), + shouldUseHistoricalValues: vi.fn(() => false), + removeParameterHistoryEntry: vi.fn(), + toggleFavoriteEntry: vi.fn() +})); + +import ScriptView from '@/main-app/components/scripts/script-view'; +import {getMostRecentValues} from '@/common/utils/parameterHistory'; +import {mount} from '@vue/test-utils'; +import {createPinia, setActivePinia} from 'pinia'; +import {useScriptConfigStore} from '@/main-app/stores/scriptConfig'; +import {useExecutionsStore} from '@/main-app/stores/executions'; +import {useScriptsStore} from '@/main-app/stores/scripts'; +import {useScriptSetupStore} from '@/main-app/stores/scriptSetup'; +import {attachToDocument} from '../../../test_utils'; + +describe('Test ScriptView replay', function () { + let pinia; + + beforeEach(function () { + pinia = createPinia(); + setActivePinia(pinia); + + const scriptConfigStore = useScriptConfigStore(); + scriptConfigStore.scriptConfig = {name: 'abc', description: ''}; + scriptConfigStore.loading = false; + + useScriptsStore().selectedScript = 'abc'; + + vi.spyOn(useExecutionsStore(), 'selectExecutor').mockImplementation((executor) => { + useExecutionsStore().currentExecutor = executor; + }); + + getMostRecentValues.mockReset(); + }); + + afterEach(function () { + vi.restoreAllMocks(); + }); + + function mountView() { + return mount(ScriptView, {attachTo: attachToDocument(), global: {plugins: [pinia]}}); + } + + it('hides the Replay button when there is no history', async function () { + getMostRecentValues.mockReturnValue(null); + const view = mountView(); + await view.vm.$nextTick(); + + expect(view.vm.hasLastExecution).toBe(false); + expect(view.find('.button-replay').exists()).toBe(false); + view.unmount(); + }); + + it('shows the Replay button when history exists', async function () { + getMostRecentValues.mockReturnValue({p1: '1'}); + const view = mountView(); + await view.vm.$nextTick(); + + expect(view.vm.hasLastExecution).toBe(true); + expect(view.find('.button-replay').exists()).toBe(true); + view.unmount(); + }); + + it('replays with the last execution values', async function () { + getMostRecentValues.mockReturnValue({p1: '1', p2: 'x'}); + const reload = vi.spyOn(useScriptSetupStore(), 'reloadModel').mockImplementation(() => {}); + const start = vi.spyOn(useExecutionsStore(), 'startExecution').mockImplementation(() => {}); + + const view = mountView(); + await view.vm.$nextTick(); + + view.vm.replayLastExecution(); + + expect(reload).toHaveBeenCalledWith(expect.objectContaining({ + values: {p1: '1', p2: 'x'}, + scriptName: 'abc' + })); + expect(start).toHaveBeenCalled(); + view.unmount(); + }); + + it('does nothing when there is no history to replay', async function () { + getMostRecentValues.mockReturnValue(null); + const start = vi.spyOn(useExecutionsStore(), 'startExecution').mockImplementation(() => {}); + + const view = mountView(); + await view.vm.$nextTick(); + + view.vm.replayLastExecution(); + + expect(start).not.toHaveBeenCalled(); + view.unmount(); + }); +}); diff --git a/web-src/tests/unit/main-app/stores/executions_status_test.js b/web-src/tests/unit/main-app/stores/executions_status_test.js new file mode 100644 index 00000000..2ce92854 --- /dev/null +++ b/web-src/tests/unit/main-app/stores/executions_status_test.js @@ -0,0 +1,75 @@ +import {createPinia, setActivePinia} from 'pinia'; +import {useExecutionsStore} from '@/main-app/stores/executions'; +import { + STATUS_DISCONNECTED, + STATUS_ERROR, + STATUS_EXECUTING, + STATUS_FINISHED, + STATUS_INITIALIZING +} from '@/main-app/stores/scriptExecutor'; + +function executor(id, scriptName, status) { + return {state: {id, scriptName, status}}; +} + +describe('Test executions getScriptStatus', function () { + let store; + + beforeEach(function () { + setActivePinia(createPinia()); + store = useExecutionsStore(); + }); + + it('returns null when the script has no executors', function () { + store.executors = {1: executor(1, 'other', STATUS_FINISHED)}; + expect(store.getScriptStatus('myScript')).toBeNull(); + }); + + it('returns running while executing', function () { + store.executors = {1: executor(1, 'myScript', STATUS_EXECUTING)}; + expect(store.getScriptStatus('myScript')).toBe('running'); + }); + + it('returns running while initializing', function () { + store.executors = {1: executor(1, 'myScript', STATUS_INITIALIZING)}; + expect(store.getScriptStatus('myScript')).toBe('running'); + }); + + it('returns finished when the only execution finished', function () { + store.executors = {1: executor(1, 'myScript', STATUS_FINISHED)}; + expect(store.getScriptStatus('myScript')).toBe('finished'); + }); + + it('returns error on error or disconnected', function () { + store.executors = {1: executor(1, 'myScript', STATUS_ERROR)}; + expect(store.getScriptStatus('myScript')).toBe('error'); + + store.executors = {1: executor(1, 'myScript', STATUS_DISCONNECTED)}; + expect(store.getScriptStatus('myScript')).toBe('error'); + }); + + it('prioritises running over error and finished', function () { + store.executors = { + 1: executor(1, 'myScript', STATUS_FINISHED), + 2: executor(2, 'myScript', STATUS_ERROR), + 3: executor(3, 'myScript', STATUS_EXECUTING) + }; + expect(store.getScriptStatus('myScript')).toBe('running'); + }); + + it('prioritises error over finished', function () { + store.executors = { + 1: executor(1, 'myScript', STATUS_FINISHED), + 2: executor(2, 'myScript', STATUS_ERROR) + }; + expect(store.getScriptStatus('myScript')).toBe('error'); + }); + + it('only considers executors of the requested script', function () { + store.executors = { + 1: executor(1, 'other', STATUS_EXECUTING), + 2: executor(2, 'myScript', STATUS_FINISHED) + }; + expect(store.getScriptStatus('myScript')).toBe('finished'); + }); +});