Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 43 additions & 0 deletions web-src/src/main-app/MainApp.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
</AppLayout>
<DocumentTitleManager/>
<FaviconManager/>
<ExecutionNotifier/>
</div>
</template>

Expand All @@ -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'
Expand All @@ -44,6 +48,7 @@ export default {
MainAppSidebar,
MainAppContent,
ScriptTabs,
ExecutionNotifier,
AppWelcomePanel,
DocumentTitleManager,
FaviconManager
Expand All @@ -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) : '/');
}
}
}
}
</script>
Expand Down
91 changes: 91 additions & 0 deletions web-src/src/main-app/components/ExecutionNotifier.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<template>
<v-snackbar
v-model="snackbar"
:color="snackbarColor"
:timeout="5000"
location="bottom right"
>
{{ snackbarText }}
<template #actions>
<v-btn variant="text" @click="snackbar = false">Close</v-btn>
</template>
</v-snackbar>
</template>

<script>
import {useExecutionsStore} from '@/main-app/stores/executions'
import {STATUS_ERROR, STATUS_FINISHED, STATUS_EXECUTING, STATUS_INITIALIZING} from '@/main-app/stores/scriptExecutor'

export default {
name: 'ExecutionNotifier',
data() {
return {
snackbar: false,
snackbarText: '',
snackbarColor: 'primary',
prevStatuses: {}
}
},
computed: {
executors() {
return useExecutionsStore().executors
},
// A reactive signature that changes whenever any executor's status changes,
// so the watcher fires without needing a deep watch on the map.
statusSignature() {
return Object.values(this.executors)
.map(e => e.state.id + ':' + e.state.status)
.join('|')
}
},
watch: {
statusSignature() {
this.checkTransitions()
}
},
mounted() {
// Seed the baseline so executions reconnected on page load don't notify.
this.syncStatuses()
},
methods: {
syncStatuses() {
for (const executor of Object.values(this.executors)) {
this.prevStatuses[executor.state.id] = executor.state.status
}
},
checkTransitions() {
for (const executor of Object.values(this.executors)) {
const id = executor.state.id
const current = executor.state.status
const previous = this.prevStatuses[id]

if (current !== previous) {
const wasRunning = previous === STATUS_EXECUTING || previous === STATUS_INITIALIZING
if (wasRunning && (current === STATUS_FINISHED || current === STATUS_ERROR)) {
this.notify(executor.state.scriptName, current === STATUS_FINISHED)
}
this.prevStatuses[id] = current
}
}
},
notify(scriptName, succeeded) {
this.snackbarText = `${scriptName} ${succeeded ? 'finished' : 'failed'}`
this.snackbarColor = succeeded ? 'primary' : 'error'
this.snackbar = true

// Native notification only when the app isn't focused and the user has
// granted permission (requested on Execute — a user gesture).
if (document.hidden && this.canNotify()) {
try {
new Notification(`Script ${succeeded ? 'finished' : 'failed'}`, {body: scriptName})
} catch (e) {
// some browsers throw if construction is not allowed — ignore
}
}
},
canNotify() {
return typeof Notification !== 'undefined' && Notification.permission === 'granted'
}
}
}
</script>
36 changes: 36 additions & 0 deletions web-src/src/main-app/components/scripts/ScriptTabs.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,25 @@
class="script-tab"
@click="activate(scriptName)"
>
<v-progress-circular
v-if="statusFor(scriptName) === 'running'"
class="tab-status"
:size="13"
:width="2"
color="primary"
indeterminate
/>
<v-icon
v-else-if="statusFor(scriptName) === 'finished'"
class="tab-status status-finished"
size="15"
>check_circle</v-icon>
<v-icon
v-else-if="statusFor(scriptName) === 'error'"
class="tab-status status-error"
size="15"
>error</v-icon>

<span class="tab-label">{{ scriptName }}</span>
<v-icon
class="tab-close"
Expand All @@ -28,6 +47,7 @@
<script>
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 {
Expand All @@ -41,6 +61,9 @@ export default {
}
},
methods: {
statusFor(scriptName) {
return useExecutionsStore().getScriptStatus(scriptName)
},
activate(scriptName) {
if (scriptName === this.activeScript) {
return
Expand Down Expand Up @@ -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;
Expand Down
61 changes: 61 additions & 0 deletions web-src/src/main-app/components/scripts/script-view.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,16 @@
@click="executeScript">
Execute
</v-btn>
<v-btn v-if="hasLastExecution"
variant="text"
color="primary"
prepend-icon="replay"
:disabled="!enableExecuteButton || scheduleMode"
class="button-replay"
title="Re-run with the parameters of the last execution"
@click="replayLastExecution">
Replay
</v-btn>
<v-btn :disabled="!enableStopButton"
:color="killEnabled ? 'red-darken-3' : 'red-lighten-1'"
class="button-stop"
Expand Down Expand Up @@ -63,6 +73,7 @@

import LogPanel from '@/common/components/log_panel'
import {deepCloneObject, forEachKeyValue, isEmptyObject, isEmptyString, isNull} from '@/common/utils/common';
import {getMostRecentValues} from '@/common/utils/parameterHistory';
import ScheduleButton from '@/main-app/components/scripts/ScheduleButton';
import ScriptLoadingText from '@/main-app/components/scripts/ScriptLoadingText';
import ScriptViewScheduleHolder from '@/main-app/components/scripts/ScriptViewScheduleHolder';
Expand Down Expand Up @@ -95,6 +106,11 @@ export default {

mounted: function () {
this.id = 'script-panel-' + this.$.uid;
window.addEventListener('keydown', this.handleExecuteShortcut);
},

beforeUnmount: function () {
window.removeEventListener('keydown', this.handleExecuteShortcut);
},

components: {
Expand Down Expand Up @@ -134,6 +150,13 @@ export default {
return useScriptsStore().selectedScript
},

hasLastExecution() {
// Touch currentExecutor so this recomputes after an execution starts
// (parameter history has no reactive dependency of its own).
this.currentExecutor;
return !isNull(this.selectedScript) && !isNull(getMostRecentValues(this.selectedScript));
},

hasErrors: function () {
return !isNull(this.shownErrors) && (this.shownErrors.length > 0);
},
Expand Down Expand Up @@ -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();
},

Expand Down
19 changes: 19 additions & 0 deletions web-src/src/main-app/stores/executions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}) => {
Expand Down
Loading