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

## What's new in this fork

### 2026-06-25 — Resizable sidebar

The scripts sidebar can now be resized: drag the handle on its right edge to set
the width (between 220 and 600 px), or double-click the handle to reset it to the
default 300 px. The chosen width is persisted in `localStorage`.

### 2026-06-25 — Search in script output

The output panel now has a find bar (search icon in the "Output" header): type to
Expand Down
105 changes: 101 additions & 4 deletions web-src/src/common/components/AppLayout.vue
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
<template>
<div class="app-layout">
<div ref="appSidebar" :class="{collapsed: !showSidebar}" class="app-sidebar shadow-8dp">
<div ref="appSidebar" :class="{collapsed: !showSidebar}" class="app-sidebar shadow-8dp"
:style="{width: sidebarWidth + 'px'}">
<slot name="sidebar"/>
</div>
<div v-show="!narrowView"
class="sidebar-resizer"
title="Drag to resize · double-click to reset"
@mousedown="startResize"
@dblclick="resetSidebarWidth"></div>
<div class="app-content">
<div ref="contentHeader"
:class="{borderless: !hasHeader, 'shadow-8dp': hasHeader}" class="content-header">
Expand Down Expand Up @@ -34,6 +40,27 @@

import {hasClass, isNull} from '@/common/utils/common';

const SIDEBAR_WIDTH_KEY = 'script_server_sidebar_width';
const DEFAULT_SIDEBAR_WIDTH = 300;
const MIN_SIDEBAR_WIDTH = 220;
const MAX_SIDEBAR_WIDTH = 600;

function clampSidebarWidth(value) {
return Math.min(MAX_SIDEBAR_WIDTH, Math.max(MIN_SIDEBAR_WIDTH, value));
}

function loadSidebarWidth() {
try {
const stored = parseInt(localStorage.getItem(SIDEBAR_WIDTH_KEY), 10);
if (!isNaN(stored)) {
return clampSidebarWidth(stored);
}
} catch (e) {
// localStorage unavailable (private mode, jsdom) — use the default
}
return DEFAULT_SIDEBAR_WIDTH;
}

export default {
name: 'AppLayout',
props: {
Expand All @@ -43,10 +70,13 @@ export default {
return {
narrowView: false,
showSidebar: false,
hasHeader: false
hasHeader: false,
sidebarWidth: DEFAULT_SIDEBAR_WIDTH
}
},
mounted() {
this.sidebarWidth = loadSidebarWidth();

const contentHeader = this.$refs.contentHeader;
const contentPanel = this.$refs.contentPanel;

Expand All @@ -65,9 +95,64 @@ export default {
resizeListener();
},

beforeUnmount() {
this._stopResizeListening();
},

methods: {
setSidebarVisibility(visible) {
this.showSidebar = visible;
},

startResize(event) {
if (this.narrowView) {
return;
}
event.preventDefault();
this._onResizeMove = (e) => this._doResize(e);
this._onResizeUp = () => this._stopResize();
document.addEventListener('mousemove', this._onResizeMove);
document.addEventListener('mouseup', this._onResizeUp);
// Avoid selecting page text and flip the cursor while dragging.
document.body.style.userSelect = 'none';
document.body.style.cursor = 'col-resize';
},

_doResize(event) {
// The sidebar starts at the viewport's left edge, so the pointer's X is
// the desired width.
this.sidebarWidth = clampSidebarWidth(event.clientX);
},

_stopResize() {
this._stopResizeListening();
this._persistSidebarWidth();
},

_stopResizeListening() {
if (this._onResizeMove) {
document.removeEventListener('mousemove', this._onResizeMove);
this._onResizeMove = null;
}
if (this._onResizeUp) {
document.removeEventListener('mouseup', this._onResizeUp);
this._onResizeUp = null;
}
document.body.style.userSelect = '';
document.body.style.cursor = '';
},

resetSidebarWidth() {
this.sidebarWidth = DEFAULT_SIDEBAR_WIDTH;
this._persistSidebarWidth();
},

_persistSidebarWidth() {
try {
localStorage.setItem(SIDEBAR_WIDTH_KEY, String(this.sidebarWidth));
} catch (e) {
// ignore persistence failures, the in-session width still applies
}
}
}
}
Expand Down Expand Up @@ -126,12 +211,24 @@ function updatedStylesBasedOnContent(contentHeader, contentPanel, appLayout) {
}

.app-sidebar {
width: 300px;
min-width: 300px;
/* width is set inline (resizable, persisted); don't let flex shrink it */
flex-shrink: 0;

border-right: 1px solid var(--separator-color);
}

.sidebar-resizer {
flex: 0 0 5px;
cursor: col-resize;
background: transparent;
z-index: 2;
transition: background-color 0.15s;
}

.sidebar-resizer:hover {
background-color: var(--primary-color);
}

.app-content {
flex: 1 1 0;

Expand Down
110 changes: 110 additions & 0 deletions web-src/tests/unit/common/AppLayout_resize_test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
'use strict';
import AppLayout from '@/common/components/AppLayout';
import {mount} from '@vue/test-utils';
import {attachToDocument} from '../test_utils';

function inMemoryLocalStorage(initial) {
const data = Object.assign({}, initial);
return {
getItem: (k) => (k in data ? data[k] : null),
setItem: (k, v) => {
data[k] = String(v);
},
removeItem: (k) => {
delete data[k];
},
_data: data
};
}

const KEY = 'script_server_sidebar_width';

describe('Test AppLayout resizable sidebar', function () {
let layout;

function mountLayout() {
return mount(AppLayout, {attachTo: attachToDocument()});
}

afterEach(function () {
if (layout) {
layout.unmount();
layout = null;
}
vi.unstubAllGlobals();
});

it('defaults to 300px when nothing is stored', function () {
vi.stubGlobal('localStorage', inMemoryLocalStorage());
layout = mountLayout();
expect(layout.vm.sidebarWidth).toBe(300);
});

it('restores a stored width', function () {
vi.stubGlobal('localStorage', inMemoryLocalStorage({[KEY]: '450'}));
layout = mountLayout();
expect(layout.vm.sidebarWidth).toBe(450);
});

it('clamps a too-small stored width up to the minimum', function () {
vi.stubGlobal('localStorage', inMemoryLocalStorage({[KEY]: '50'}));
layout = mountLayout();
expect(layout.vm.sidebarWidth).toBe(220);
});

it('clamps a too-large stored width down to the maximum', function () {
vi.stubGlobal('localStorage', inMemoryLocalStorage({[KEY]: '9999'}));
layout = mountLayout();
expect(layout.vm.sidebarWidth).toBe(600);
});

it('applies the width to the sidebar element', async function () {
vi.stubGlobal('localStorage', inMemoryLocalStorage({[KEY]: '420'}));
layout = mountLayout();
await layout.vm.$nextTick();
expect(layout.find('.app-sidebar').attributes('style')).toContain('width: 420px');
});

it('resizing clamps to bounds and updates the width', function () {
vi.stubGlobal('localStorage', inMemoryLocalStorage());
layout = mountLayout();

layout.vm._doResize({clientX: 380});
expect(layout.vm.sidebarWidth).toBe(380);

layout.vm._doResize({clientX: 10});
expect(layout.vm.sidebarWidth).toBe(220);

layout.vm._doResize({clientX: 5000});
expect(layout.vm.sidebarWidth).toBe(600);
});

it('persists the width when a resize ends', function () {
const ls = inMemoryLocalStorage();
vi.stubGlobal('localStorage', ls);
layout = mountLayout();

layout.vm._doResize({clientX: 333});
layout.vm._stopResize();

expect(ls._data[KEY]).toBe('333');
});

it('double-click resets to 300px and persists', function () {
const ls = inMemoryLocalStorage({[KEY]: '500'});
vi.stubGlobal('localStorage', ls);
layout = mountLayout();
expect(layout.vm.sidebarWidth).toBe(500);

layout.vm.resetSidebarWidth();

expect(layout.vm.sidebarWidth).toBe(300);
expect(ls._data[KEY]).toBe('300');
});

it('renders a resizer handle', function () {
vi.stubGlobal('localStorage', inMemoryLocalStorage());
layout = mountLayout();
expect(layout.find('.sidebar-resizer').exists()).toBe(true);
});
});