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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@

## What's new in this fork

### 2026-06-19 — Favorite scripts

Scripts can be pinned as favorites for quick access. A star button on each script
in the sidebar toggles its favorite state, and favorited scripts appear in a
dedicated **Favorites** section at the top of the sidebar. The selection is
persisted to `localStorage`.

### 2026-06-19 — Multi-tab workflow: status, notifications, shortcuts

Builds on the in-app tabs to make working with several scripts at once smoother.
Expand Down
35 changes: 35 additions & 0 deletions web-src/src/main-app/components/scripts/ScriptListItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@
Failed to parse config file
</v-tooltip>
<template #append>
<v-icon
class="favorite-toggle"
:class="{ 'is-favorite': isFavorite }"
:size="18"
:title="isFavorite ? 'Remove from favorites' : 'Add to favorites'"
@click.stop.prevent="toggleFavorite"
>{{ isFavorite ? 'star' : 'star_border' }}</v-icon>
<v-progress-circular
v-if="descriptor.state === 'executing'"
:size="20"
Expand All @@ -26,6 +33,7 @@
import {forEachKeyValue} from '@/common/utils/common';
import {useScriptsStore} from '@/main-app/stores/scripts'
import {useExecutionsStore} from '@/main-app/stores/executions'
import {useScriptFavoritesStore} from '@/main-app/stores/scriptFavorites'
import {scriptNameToHash} from '../../utils/model_helper';

export default {
Expand All @@ -47,9 +55,15 @@ export default {
},
selectedScript() {
return useScriptsStore().selectedScript
},
isFavorite() {
return useScriptFavoritesStore().isFavorite(this.script.name)
}
},
methods: {
toggleFavorite() {
useScriptFavoritesStore().toggle(this.script.name)
},
getState(scriptName) {
if (this.script.parsing_failed) {
return 'cannot-parse'
Expand Down Expand Up @@ -85,4 +99,25 @@ export default {
color: var(--primary-color);
font-weight: 500;
}

/* Star is hidden until row hover, but stays visible once a script is favorited. */
.favorite-toggle {
color: var(--font-color-disabled);
opacity: 0;
margin-right: 4px;
transition: opacity 0.15s ease, color 0.15s ease;
}

.script-list-item:hover .favorite-toggle {
opacity: 1;
}

.favorite-toggle:hover {
color: var(--font-color-medium);
}

.favorite-toggle.is-favorite {
opacity: 1;
color: #f5b301;
}
</style>
38 changes: 38 additions & 0 deletions web-src/src/main-app/components/scripts/ScriptsList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@
open-strategy="single"
class="scripts-list"
>
<template v-if="favoriteScripts.length">
<v-list-subheader class="section-header">Favorites</v-list-subheader>
<ScriptListItem
v-for="script in favoriteScripts"
:key="'fav-' + script.name"
:script="script"
/>
<v-divider class="favorites-divider" />
</template>

<template v-for="item in items" :key="item.name">
<ScriptListGroup v-if="item.isGroup" :group="item" />
<ScriptListItem v-else :script="item" />
Expand All @@ -14,6 +24,7 @@
<script>
import {isBlankString, isEmptyArray, isEmptyString, isNull, removeElement} from '@/common/utils/common';
import {useScriptsStore} from '@/main-app/stores/scripts'
import {useScriptFavoritesStore} from '@/main-app/stores/scriptFavorites'
import ScriptListGroup from './ScriptListGroup';
import ScriptListItem from './ScriptListItem';

Expand Down Expand Up @@ -41,6 +52,20 @@ export default {
return useScriptsStore().selectedScript
},

favoriteScripts() {
const favorites = useScriptFavoritesStore().favorites
const search = this.searchText
const byName = {}
for (const script of this.scripts) {
byName[script.name] = script
}
return favorites
.map(name => byName[name])
.filter(script => !isNull(script) && script !== undefined)
.filter(script =>
isEmptyString(search) || script.name.toLowerCase().includes(search.toLowerCase()))
},

items() {
let groups = this.scripts.filter(script => !isBlankString(script.group))
.map(script => script.group)
Expand Down Expand Up @@ -97,4 +122,17 @@ export default {
overflow-wrap: normal;
flex-grow: 1;
}

.section-header {
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.03em;
text-transform: uppercase;
color: var(--font-color-medium);
min-height: 2rem;
}

.favorites-divider {
margin: 4px 0;
}
</style>
56 changes: 56 additions & 0 deletions web-src/src/main-app/stores/scriptFavorites.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import {defineStore} from 'pinia'
import {isNull} from '@/common/utils/common'

const STORAGE_KEY = 'script_server_favorites'

function loadFavorites() {
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 []
}

/**
* Pinned/favorite scripts, shown in a dedicated section at the top of the
* sidebar for quick access. Persisted to localStorage.
*/
export const useScriptFavoritesStore = defineStore('scriptFavorites', {
state: () => ({
favorites: loadFavorites()
}),

actions: {
isFavorite(scriptName) {
return this.favorites.includes(scriptName)
},

toggle(scriptName) {
if (isNull(scriptName)) {
return
}
const index = this.favorites.indexOf(scriptName)
if (index === -1) {
this.favorites.push(scriptName)
} else {
this.favorites.splice(index, 1)
}
this._persist()
},

_persist() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.favorites))
} catch (e) {
// ignore persistence failures, favorites still work in-session
}
}
}
})
50 changes: 50 additions & 0 deletions web-src/tests/unit/main-app/scripts/ScriptList_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import router from '@/main-app/router/router';
import {mount} from '@vue/test-utils';
import {createPinia, setActivePinia} from 'pinia';
import {useScriptsStore} from '@/main-app/stores/scripts';
import {useScriptFavoritesStore} from '@/main-app/stores/scriptFavorites';
import {attachToDocument, triggerSingleClick, vueTicks} from '../../test_utils';

describe('Test ScriptConfig', function () {
Expand Down Expand Up @@ -252,4 +253,53 @@ describe('Test ScriptConfig', function () {
});

});

describe('Test favorites', function () {
function favoritesHeader() {
return listComponent.vm.$el.querySelector('.section-header');
}

function favoriteItemTitles() {
return [...listComponent.vm.$el.querySelectorAll('.v-list-item')]
.filter(el => el.querySelector('.favorite-toggle.is-favorite'))
.map(el => getItemTitle(el));
}

it('shows no Favorites section when there are none', async function () {
scriptsStore.scripts = [{'name': 'abc'}, {'name': 'xyz'}];
await vueTicks();

expect(favoritesHeader()).toBeFalsy();
});

it('shows a favorited script in the Favorites section', async function () {
scriptsStore.scripts = [{'name': 'abc'}, {'name': 'xyz', 'group': 'g1'}];
useScriptFavoritesStore().toggle('xyz');
await vueTicks();

expect(favoritesHeader().textContent.trim()).toBe('Favorites');
// 'xyz' appears once in the favorites section and once in its group
expect(favoriteItemTitles()).toEqual(['xyz', 'xyz']);
});

it('drops the section again when the last favorite is removed', async function () {
scriptsStore.scripts = [{'name': 'abc'}];
const favorites = useScriptFavoritesStore();
favorites.toggle('abc');
await vueTicks();
expect(favoritesHeader()).toBeTruthy();

favorites.toggle('abc');
await vueTicks();
expect(favoritesHeader()).toBeFalsy();
});

it('ignores favorites that no longer exist', async function () {
scriptsStore.scripts = [{'name': 'abc'}];
useScriptFavoritesStore().toggle('ghost');
await vueTicks();

expect(favoritesHeader()).toBeFalsy();
});
});
});
79 changes: 79 additions & 0 deletions web-src/tests/unit/main-app/stores/scriptFavorites_test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import {createPinia, setActivePinia} from 'pinia';
import {useScriptFavoritesStore} from '@/main-app/stores/scriptFavorites';

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];
}
};
}

describe('Test scriptFavorites store', function () {
let store;

beforeEach(function () {
vi.stubGlobal('localStorage', inMemoryLocalStorage());
setActivePinia(createPinia());
store = useScriptFavoritesStore();
});

afterEach(function () {
vi.unstubAllGlobals();
});

it('starts empty', function () {
expect(store.favorites).toEqual([]);
expect(store.isFavorite('a')).toBe(false);
});

it('toggles a script on', function () {
store.toggle('a');
expect(store.favorites).toEqual(['a']);
expect(store.isFavorite('a')).toBe(true);
});

it('toggles a script off', function () {
store.toggle('a');
store.toggle('a');
expect(store.favorites).toEqual([]);
expect(store.isFavorite('a')).toBe(false);
});

it('keeps multiple favorites in insertion order', function () {
store.toggle('a');
store.toggle('b');
store.toggle('c');
expect(store.favorites).toEqual(['a', 'b', 'c']);
});

it('ignores a null script name', function () {
store.toggle(null);
expect(store.favorites).toEqual([]);
});

it('persists to localStorage', function () {
store.toggle('a');
store.toggle('b');
expect(JSON.parse(localStorage.getItem('script_server_favorites'))).toEqual(['a', 'b']);
});

it('restores persisted favorites on init', function () {
localStorage.setItem('script_server_favorites', JSON.stringify(['x', 'y']));
setActivePinia(createPinia());
const restored = useScriptFavoritesStore();
expect(restored.favorites).toEqual(['x', 'y']);
});

it('falls back to empty when storage is malformed', function () {
localStorage.setItem('script_server_favorites', 'not json');
setActivePinia(createPinia());
const restored = useScriptFavoritesStore();
expect(restored.favorites).toEqual([]);
});
});
Loading