diff --git a/client/package.json b/client/package.json
index f5e5363e9c1a..26e9959cb5ea 100644
--- a/client/package.json
+++ b/client/package.json
@@ -133,7 +133,6 @@
"vue-virtual-scroll-list": "^2.3.5",
"vue2-teleport": "^1.0.1",
"vuedraggable": "^2.24.3",
- "winbox": "^0.2.82",
"xml-beautifier": "^0.5.0",
"yaml": "^2.6.1"
},
diff --git a/client/pnpm-lock.yaml b/client/pnpm-lock.yaml
index 41ef10794717..4059860d64b3 100644
--- a/client/pnpm-lock.yaml
+++ b/client/pnpm-lock.yaml
@@ -322,9 +322,6 @@ importers:
vuedraggable:
specifier: ^2.24.3
version: 2.24.3
- winbox:
- specifier: ^0.2.82
- version: 0.2.82
xml-beautifier:
specifier: ^0.5.0
version: 0.5.0
@@ -4523,9 +4520,6 @@ packages:
engines: {node: '>=8'}
hasBin: true
- winbox@0.2.82:
- resolution: {integrity: sha512-fMHaLnnuhyw1mLvzmCTWMpRKDfXUK/Z08VdbyBqDaITFFAd3Fx2BGDCF6rzGSkWrQgo8fHTFpFICofyM36FTnA==}
-
wrap-ansi@6.2.0:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'}
@@ -9001,8 +8995,6 @@ snapshots:
siginfo: 2.0.0
stackback: 0.0.2
- winbox@0.2.82: {}
-
wrap-ansi@6.2.0:
dependencies:
ansi-styles: 4.3.0
diff --git a/client/src/components/ChatGXY.vue b/client/src/components/ChatGXY.vue
index 4bf5ed430a62..19fc0ad06ee4 100644
--- a/client/src/components/ChatGXY.vue
+++ b/client/src/components/ChatGXY.vue
@@ -320,7 +320,7 @@ async function deleteCurrentChat() {
}
}
-function popOutToScratchbook() {
+function popOutToWindowManager() {
const Galaxy = getGalaxyInstance();
const path = currentChatId.value ? `/chatgxy/${currentChatId.value}` : "/chatgxy";
const url = `${path}?compact=true`;
@@ -349,7 +349,7 @@ function popOutToScratchbook() {
diff --git a/client/src/components/Masthead/Masthead.test.js b/client/src/components/Masthead/Masthead.test.js
index 4052d1a0fa41..807e0ee516ec 100644
--- a/client/src/components/Masthead/Masthead.test.js
+++ b/client/src/components/Masthead/Masthead.test.js
@@ -1,3 +1,4 @@
+import { faTh } from "@fortawesome/free-solid-svg-icons";
import { createTestingPinia } from "@pinia/testing";
import { getFakeRegisteredUser } from "@tests/test-data";
import { getLocalVue } from "@tests/vitest/helpers";
@@ -7,7 +8,6 @@ import flushPromises from "flush-promises";
import { PiniaVuePlugin } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
-import { WindowManager } from "@/entry/analysis/window-manager";
import { useUserStore } from "@/stores/userStore";
import { loadMastheadWebhooks } from "./_webhooks";
@@ -28,7 +28,7 @@ setupMockConfig({});
describe("Masthead.vue", () => {
let wrapper;
let localVue;
- let windowManager;
+ let windowTab;
let testPinia;
function stubLoadWebhooks(items) {
@@ -46,8 +46,16 @@ describe("Masthead.vue", () => {
localVue.use(PiniaVuePlugin);
testPinia = createTestingPinia({ createSpy: vi.fn });
- windowManager = new WindowManager({});
- const windowTab = windowManager.getTab();
+ windowTab = {
+ id: "enable-window-manager",
+ icon: faTh,
+ tooltip: "Enable/Disable Window Manager",
+ visible: true,
+ _active: false,
+ onclick: function () {
+ this._active = !this._active;
+ },
+ };
const userStore = useUserStore();
userStore.currentUser = currentUser;
@@ -71,9 +79,9 @@ describe("Masthead.vue", () => {
it("should display window manager button", async () => {
expect(wrapper.find("#enable-window-manager a svg").exists()).toBe(true);
- expect(windowManager.active).toBe(false);
+ expect(windowTab._active).toBe(false);
await wrapper.find("#enable-window-manager a").trigger("click");
- expect(windowManager.active).toBe(true);
+ expect(windowTab._active).toBe(true);
});
it("should load webhooks on creation", async () => {
diff --git a/client/src/components/WindowManager/WindowManagerWindow.vue b/client/src/components/WindowManager/WindowManagerWindow.vue
new file mode 100644
index 000000000000..714e67f16e9c
--- /dev/null
+++ b/client/src/components/WindowManager/WindowManagerWindow.vue
@@ -0,0 +1,307 @@
+
+
+
+
+
+
+
diff --git a/client/src/entry/analysis/App.vue b/client/src/entry/analysis/App.vue
index 64bd2a784a02..874a06dde1b9 100644
--- a/client/src/entry/analysis/App.vue
+++ b/client/src/entry/analysis/App.vue
@@ -42,6 +42,9 @@
+
+
+
@@ -64,8 +67,7 @@ import { useHistoryStore } from "@/stores/historyStore";
import { useNotificationsStore } from "@/stores/notificationsStore";
import { useTourStore } from "@/stores/tourStore";
import { useUserStore } from "@/stores/userStore";
-
-import { WindowManager } from "./window-manager";
+import { useWindowManagerStore } from "@/stores/windowManagerStore";
import Alert from "@/components/Alert.vue";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
@@ -74,12 +76,14 @@ import Masthead from "@/components/Masthead/Masthead.vue";
import BroadcastsOverlay from "@/components/Notifications/Broadcasts/BroadcastsOverlay.vue";
import TourRunner from "@/components/Tour/TourRunner.vue";
import UploadModal from "@/components/Upload/UploadModal.vue";
+import WindowManagerWindow from "@/components/WindowManager/WindowManagerWindow.vue";
export default {
components: {
Alert,
DragGhost,
Masthead,
+ WindowManagerWindow,
Toast,
ConfirmDialog,
UploadModal,
@@ -105,6 +109,8 @@ export default {
const uploadModal = ref(null);
setGlobalUploadModal(uploadModal);
+ const windowManagerStore = useWindowManagerStore();
+
// Treat any iframe context as embedded: scratchbook pops dataset
// displays into ``WinBox`` iframes that hit the same routes without
// an ``embed`` query param, and each one would otherwise open its own
@@ -168,13 +174,13 @@ export default {
currentTheme,
embedded,
currentTour,
+ windowManagerStore,
};
},
data() {
return {
config: getGalaxyInstance().config,
resendUrl: `${getAppRoot()}user/resend_verification`,
- windowManager: null,
};
},
computed: {
@@ -202,7 +208,7 @@ export default {
return null;
},
windowTab() {
- return this.windowManager.getTab();
+ return this.windowManagerStore.getTab();
},
},
watch: {
@@ -214,9 +220,9 @@ export default {
mounted() {
if (!this.embedded) {
this.Galaxy = getGalaxyInstance();
- this.Galaxy.frame = this.windowManager;
if (this.showMasthead) {
- this.windowManager.restore();
+ this.Galaxy.frame = this.windowManagerStore;
+ this.windowManagerStore.restore();
}
if (this.Galaxy.config.interactivetools_enable) {
this.startWatchingEntryPoints();
@@ -228,10 +234,8 @@ export default {
},
created() {
if (!this.embedded) {
- this.windowManager = new WindowManager();
-
window.onbeforeunload = () => {
- if (this.confirmation || this.windowManager.beforeUnload()) {
+ if (this.confirmation || this.windowManagerStore.beforeUnload()) {
return "Are you sure you want to leave the page?";
}
};
diff --git a/client/src/entry/analysis/window-manager.js b/client/src/entry/analysis/window-manager.js
deleted file mode 100644
index 099985db151b..000000000000
--- a/client/src/entry/analysis/window-manager.js
+++ /dev/null
@@ -1,155 +0,0 @@
-/** Adds window manager masthead icon and functionality **/
-import "winbox/src/css/winbox.css";
-
-import { faTh } from "@fortawesome/free-solid-svg-icons";
-import WinBox from "winbox/src/js/winbox.js";
-
-import _l from "@/utils/localization";
-import { withPrefix } from "@/utils/redirect";
-
-const STORAGE_KEY = "galaxy-scratchbook-windows";
-
-export class WindowManager {
- constructor(options) {
- options = options || {};
- this.counter = 0;
- this.active = false;
- this.zIndexInitialized = false;
- this.windows = new Map();
- this._saveTimeout = null;
- }
-
- /** Return window masthead tab props */
- getTab() {
- return {
- id: "enable-window-manager",
- icon: faTh,
- tooltip: _l("Enable/Disable Window Manager"),
- visible: true,
- onclick: () => {
- this.active = !this.active;
- },
- };
- }
-
- /** Add and display a new window based on options. */
- add(options, layout = 10, margin = 20) {
- const id = crypto.randomUUID();
- const originalUrl = options.url;
- const url = this._build_url(withPrefix(originalUrl), { hide_panels: true, hide_masthead: true });
- const x = options.x ?? this.counter * margin;
- const y = options.y ?? (this.counter % layout) * margin;
- this.counter++;
- const params = {
- title: options.title || "Window",
- url: url,
- x: x,
- y: y,
- width: options.width,
- height: options.height,
- onclose: () => {
- this.counter--;
- this.windows.delete(id);
- this._saveWindows();
- },
- onmove: (newX, newY) => {
- const entry = this.windows.get(id);
- if (entry) {
- entry.x = newX;
- entry.y = newY;
- this._saveWindows();
- }
- },
- onresize: (newW, newH) => {
- const entry = this.windows.get(id);
- if (entry) {
- entry.width = newW;
- entry.height = newH;
- this._saveWindows();
- }
- },
- };
- // Set z-index floor on the first window only to position above
- // Galaxy UI (masthead is z-index 900). Subsequent windows omit
- // index so WinBox auto-increments correctly.
- if (!this.zIndexInitialized) {
- params.index = 850;
- this.zIndexInitialized = true;
- }
- const win = WinBox.new(params);
- // Overlay to capture clicks on unfocused windows, since mousedown
- // doesn't propagate out of iframes. WinBox toggles the "focus"
- // class, and CSS sets pointer-events:none on the overlay for the
- // focused window so the iframe works normally.
- const overlay = document.createElement("div");
- overlay.className = "iframe-focus-overlay";
- overlay.addEventListener("mousedown", () => win.focus());
- win.body.appendChild(overlay);
-
- this.windows.set(id, {
- id,
- title: params.title,
- url: originalUrl,
- x: x,
- y: y,
- width: win.width,
- height: win.height,
- });
- this._saveWindows();
- }
-
- /** Restore windows saved from a previous session. */
- restore() {
- const saved = this._loadWindows();
- if (saved.length > 0) {
- this.active = true;
- for (const entry of saved) {
- this.add({
- title: entry.title,
- url: entry.url,
- x: entry.x,
- y: entry.y,
- width: entry.width,
- height: entry.height,
- });
- }
- }
- }
-
- /** Called before closing all windows. */
- beforeUnload() {
- return this.counter > 0;
- }
-
- _saveWindows() {
- clearTimeout(this._saveTimeout);
- this._saveTimeout = setTimeout(() => {
- try {
- const data = JSON.stringify([...this.windows.values()]);
- localStorage.setItem(STORAGE_KEY, data);
- } catch (e) {
- // localStorage full or unavailable — silently ignore
- }
- }, 200);
- }
-
- _loadWindows() {
- try {
- const raw = localStorage.getItem(STORAGE_KEY);
- return raw ? JSON.parse(raw) : [];
- } catch (e) {
- return [];
- }
- }
-
- /** Url helper */
- _build_url(url, options) {
- if (url) {
- url += url.indexOf("?") == -1 ? "?" : "&";
- Object.entries(options).forEach(([key, value]) => {
- url += `${key}=${value}&`;
- });
- return url;
- }
- }
-}
diff --git a/client/src/entry/analysis/window-manager.test.js b/client/src/entry/analysis/window-manager.test.js
deleted file mode 100644
index c35f93b8dc71..000000000000
--- a/client/src/entry/analysis/window-manager.test.js
+++ /dev/null
@@ -1,200 +0,0 @@
-import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
-
-// Capture callbacks passed to WinBox so tests can invoke them
-let lastWinBoxParams = null;
-const mockWinBoxInstance = {
- body: document.createElement("div"),
- width: 400,
- height: 300,
- focus: vi.fn(),
-};
-
-vi.mock("winbox/src/js/winbox.js", () => ({
- default: {
- new: vi.fn((params) => {
- lastWinBoxParams = params;
- return { ...mockWinBoxInstance };
- }),
- },
-}));
-
-vi.mock("winbox/src/css/winbox.css", () => ({}));
-
-vi.mock("@/utils/redirect", () => ({
- withPrefix: (url) => url,
-}));
-
-vi.mock("@/utils/localization", () => ({
- default: (s) => s,
-}));
-
-const STORAGE_KEY = "galaxy-scratchbook-windows";
-
-// Dynamic import so mocks are in place before the module loads
-let WindowManager;
-
-describe("WindowManager persistence", () => {
- beforeAll(async () => {
- ({ WindowManager } = await import("./window-manager"));
- });
-
- beforeEach(() => {
- localStorage.clear();
- lastWinBoxParams = null;
- vi.useFakeTimers();
- });
-
- afterEach(() => {
- vi.useRealTimers();
- });
-
- function flushSave() {
- vi.advanceTimersByTime(200);
- }
-
- it("saves window metadata to localStorage on add", () => {
- const wm = new WindowManager();
- wm.add({ title: "Chat", url: "/chat" });
- flushSave();
-
- const stored = JSON.parse(localStorage.getItem(STORAGE_KEY));
- expect(stored).toHaveLength(1);
- expect(stored[0].title).toBe("Chat");
- expect(stored[0].url).toBe("/chat");
- });
-
- it("stores original url without hide_panels params", () => {
- const wm = new WindowManager();
- wm.add({ title: "Test", url: "/some/page" });
- flushSave();
-
- const stored = JSON.parse(localStorage.getItem(STORAGE_KEY));
- expect(stored[0].url).toBe("/some/page");
- expect(stored[0].url).not.toContain("hide_panels");
- });
-
- it("removes window from storage on close", () => {
- const wm = new WindowManager();
- wm.add({ title: "Win1", url: "/a" });
- flushSave();
-
- // Trigger the onclose callback WinBox would call
- lastWinBoxParams.onclose();
- flushSave();
-
- const stored = JSON.parse(localStorage.getItem(STORAGE_KEY));
- expect(stored).toHaveLength(0);
- });
-
- it("updates position on move", () => {
- const wm = new WindowManager();
- wm.add({ title: "Win", url: "/a" });
- flushSave();
-
- lastWinBoxParams.onmove(150, 250);
- flushSave();
-
- const stored = JSON.parse(localStorage.getItem(STORAGE_KEY));
- expect(stored[0].x).toBe(150);
- expect(stored[0].y).toBe(250);
- });
-
- it("updates size on resize", () => {
- const wm = new WindowManager();
- wm.add({ title: "Win", url: "/a" });
- flushSave();
-
- lastWinBoxParams.onresize(600, 500);
- flushSave();
-
- const stored = JSON.parse(localStorage.getItem(STORAGE_KEY));
- expect(stored[0].width).toBe(600);
- expect(stored[0].height).toBe(500);
- });
-
- it("restores windows from localStorage", () => {
- const saved = [
- { id: "abc", title: "Chat", url: "/chat", x: 100, y: 50, width: 500, height: 400 },
- { id: "def", title: "Help", url: "/help", x: 200, y: 80, width: 300, height: 250 },
- ];
- localStorage.setItem(STORAGE_KEY, JSON.stringify(saved));
-
- const wm = new WindowManager();
- wm.restore();
-
- expect(wm.active).toBe(true);
- expect(wm.counter).toBe(2);
- expect(wm.windows.size).toBe(2);
- });
-
- it("passes saved position/size through to add on restore", () => {
- const saved = [{ id: "abc", title: "Chat", url: "/chat", x: 100, y: 50, width: 500, height: 400 }];
- localStorage.setItem(STORAGE_KEY, JSON.stringify(saved));
-
- const wm = new WindowManager();
- wm.restore();
-
- expect(lastWinBoxParams.x).toBe(100);
- expect(lastWinBoxParams.y).toBe(50);
- expect(lastWinBoxParams.width).toBe(500);
- expect(lastWinBoxParams.height).toBe(400);
- });
-
- it("does not set active when nothing to restore", () => {
- const wm = new WindowManager();
- wm.restore();
-
- expect(wm.active).toBe(false);
- expect(wm.counter).toBe(0);
- });
-
- it("handles corrupt localStorage gracefully", () => {
- localStorage.setItem(STORAGE_KEY, "not-valid-json{{{");
-
- const wm = new WindowManager();
- wm.restore();
-
- expect(wm.active).toBe(false);
- expect(wm.counter).toBe(0);
- });
-
- it("tracks multiple windows independently", () => {
- const wm = new WindowManager();
- wm.add({ title: "Win1", url: "/a" });
- const firstClose = lastWinBoxParams.onclose;
-
- wm.add({ title: "Win2", url: "/b" });
- flushSave();
-
- let stored = JSON.parse(localStorage.getItem(STORAGE_KEY));
- expect(stored).toHaveLength(2);
-
- // Close first window
- firstClose();
- flushSave();
-
- stored = JSON.parse(localStorage.getItem(STORAGE_KEY));
- expect(stored).toHaveLength(1);
- expect(stored[0].title).toBe("Win2");
- });
-
- it("debounces rapid saves", () => {
- const wm = new WindowManager();
- wm.add({ title: "Win", url: "/a" });
-
- // Multiple moves without flushing
- lastWinBoxParams.onmove(10, 10);
- lastWinBoxParams.onmove(20, 20);
- lastWinBoxParams.onmove(30, 30);
-
- // Nothing written yet
- expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
-
- flushSave();
-
- // Only final state persisted
- const stored = JSON.parse(localStorage.getItem(STORAGE_KEY));
- expect(stored[0].x).toBe(30);
- expect(stored[0].y).toBe(30);
- });
-});
diff --git a/client/src/stores/activityStoreTypes.ts b/client/src/stores/activityStoreTypes.ts
index cdc6ebfd90f6..36b3d985d512 100644
--- a/client/src/stores/activityStoreTypes.ts
+++ b/client/src/stores/activityStoreTypes.ts
@@ -32,6 +32,6 @@ export interface Activity {
// if activity should cause a click event
click?: true;
variant?: ActivityVariant;
- // optional title for opening in scratchbook window manager
+ // optional title for opening in window manager
windowTitle?: string;
}
diff --git a/client/src/stores/windowManagerStore.test.ts b/client/src/stores/windowManagerStore.test.ts
new file mode 100644
index 000000000000..7b0028c901b6
--- /dev/null
+++ b/client/src/stores/windowManagerStore.test.ts
@@ -0,0 +1,151 @@
+import { createPinia, setActivePinia } from "pinia";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { useWindowManagerStore } from "./windowManagerStore";
+
+describe("windowManagerStore", () => {
+ beforeEach(() => {
+ setActivePinia(createPinia());
+ localStorage.clear();
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("toggles active state", () => {
+ const store = useWindowManagerStore();
+ expect(store.active).toBe(false);
+ store.toggle();
+ expect(store.active).toBe(true);
+ store.toggle();
+ expect(store.active).toBe(false);
+ });
+
+ it("adds a window and focuses it", () => {
+ const store = useWindowManagerStore();
+ store.add({ title: "One", url: "/foo" });
+ expect(store.windows).toHaveLength(1);
+ const win = store.windows[0]!;
+ expect(win.title).toBe("One");
+ expect(win.url).toBe("/foo");
+ expect(win.width).toBe(600);
+ expect(win.height).toBe(400);
+ expect(win.minimized).toBe(false);
+ expect(win.maximized).toBe(false);
+ expect(store.focusedId).toBe(win.id);
+ });
+
+ it("removes a window and refocuses the last remaining one", () => {
+ const store = useWindowManagerStore();
+ store.add({ title: "A", url: "/a" });
+ store.add({ title: "B", url: "/b" });
+ const a = store.windows[0]!;
+ const b = store.windows[1]!;
+ store.remove(b.id);
+ expect(store.windows).toHaveLength(1);
+ expect(store.focusedId).toBe(a.id);
+ store.remove(a.id);
+ expect(store.windows).toHaveLength(0);
+ expect(store.focusedId).toBeNull();
+ });
+
+ it("raises zIndex when focusing a different window", () => {
+ const store = useWindowManagerStore();
+ store.add({ url: "/a" });
+ store.add({ url: "/b" });
+ const a = store.windows[0]!;
+ const b = store.windows[1]!;
+ expect(b.zIndex).toBeGreaterThan(a.zIndex);
+ store.focus(a.id);
+ expect(store.focusedId).toBe(a.id);
+ expect(a.zIndex).toBeGreaterThan(b.zIndex);
+ });
+
+ it("updates position and size", () => {
+ const store = useWindowManagerStore();
+ store.add({ url: "/a" });
+ const win = store.windows[0]!;
+ store.updatePosition(win.id, 123, 456);
+ expect(win.x).toBe(123);
+ expect(win.y).toBe(456);
+ store.updateSize(win.id, 800, 500);
+ expect(win.width).toBe(800);
+ expect(win.height).toBe(500);
+ });
+
+ it("toggles minimize and moves focus to another open window", () => {
+ const store = useWindowManagerStore();
+ store.add({ url: "/a" });
+ store.add({ url: "/b" });
+ const a = store.windows[0]!;
+ const b = store.windows[1]!;
+ store.focus(b.id);
+ store.toggleMinimize(b.id);
+ expect(b.minimized).toBe(true);
+ expect(store.focusedId).toBe(a.id);
+ store.toggleMinimize(b.id);
+ expect(b.minimized).toBe(false);
+ expect(store.focusedId).toBe(b.id);
+ });
+
+ it("un-minimizes when toggling maximize on a minimized window", () => {
+ const store = useWindowManagerStore();
+ store.add({ url: "/a" });
+ const win = store.windows[0]!;
+ store.toggleMinimize(win.id);
+ expect(win.minimized).toBe(true);
+ store.toggleMaximize(win.id);
+ expect(win.maximized).toBe(true);
+ expect(win.minimized).toBe(false);
+ });
+
+ it("beforeUnload reflects whether any windows are open", () => {
+ const store = useWindowManagerStore();
+ expect(store.beforeUnload()).toBe(false);
+ store.add({ url: "/a" });
+ expect(store.beforeUnload()).toBe(true);
+ });
+
+ it("persists to localStorage and restores on demand", () => {
+ const store = useWindowManagerStore();
+ store.add({ title: "Saved", url: "/saved", x: 50, y: 60, width: 700, height: 450 });
+ vi.runAllTimers();
+ const raw = localStorage.getItem("galaxy-window-manager-windows");
+ expect(raw).not.toBeNull();
+
+ // Fresh store should start empty, then restore from the same localStorage.
+ setActivePinia(createPinia());
+ const fresh = useWindowManagerStore();
+ expect(fresh.windows).toHaveLength(0);
+ fresh.restore();
+ expect(fresh.active).toBe(true);
+ expect(fresh.windows).toHaveLength(1);
+ expect(fresh.windows[0]).toMatchObject({
+ title: "Saved",
+ url: "/saved",
+ x: 50,
+ y: 60,
+ width: 700,
+ height: 450,
+ });
+ });
+
+ it("buildUrl appends hide_panels and hide_masthead query params", () => {
+ const store = useWindowManagerStore();
+ const url = store.buildUrl("/tool/runner?tool_id=foo");
+ expect(url).toContain("tool_id=foo");
+ expect(url).toContain("hide_panels=true");
+ expect(url).toContain("hide_masthead=true");
+ });
+
+ it("getTab exposes a masthead entry that toggles the window manager", () => {
+ const store = useWindowManagerStore();
+ const tab = store.getTab();
+ expect(tab.id).toBe("enable-window-manager");
+ expect(tab.visible).toBe(true);
+ tab.onclick();
+ expect(store.active).toBe(true);
+ });
+});
diff --git a/client/src/stores/windowManagerStore.ts b/client/src/stores/windowManagerStore.ts
new file mode 100644
index 000000000000..11c302983177
--- /dev/null
+++ b/client/src/stores/windowManagerStore.ts
@@ -0,0 +1,204 @@
+import { faTh } from "@fortawesome/free-solid-svg-icons";
+import { defineStore } from "pinia";
+import { ref } from "vue";
+
+import _l from "@/utils/localization";
+import { withPrefix } from "@/utils/redirect";
+import { addSearchParams } from "@/utils/url";
+
+const STORAGE_KEY = "galaxy-window-manager-windows";
+
+export interface WindowState {
+ id: string;
+ title: string;
+ url: string;
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ zIndex: number;
+ minimized: boolean;
+ maximized: boolean;
+}
+
+export const useWindowManagerStore = defineStore("windowManager", () => {
+ const active = ref(false);
+ const windows = ref([]);
+ const focusedId = ref(null);
+ let nextZIndex = 850;
+ let saveTimeout: ReturnType | null = null;
+
+ function toggle() {
+ active.value = !active.value;
+ }
+
+ function getTab() {
+ return {
+ id: "enable-window-manager",
+ icon: faTh,
+ tooltip: _l("Enable/Disable Window Manager"),
+ visible: true,
+ onclick: () => toggle(),
+ };
+ }
+
+ function buildUrl(url: string): string {
+ return addSearchParams(withPrefix(url), { hide_panels: "true", hide_masthead: "true" });
+ }
+
+ function add(options: { title?: string; url: string; x?: number; y?: number; width?: number; height?: number }) {
+ const id = crypto.randomUUID();
+ const count = windows.value.length;
+ const margin = 20;
+ const layout = 10;
+ const x = options.x ?? count * margin;
+ const y = options.y ?? (count % layout) * margin;
+
+ windows.value.push({
+ id,
+ title: options.title || "Window",
+ url: options.url,
+ x,
+ y,
+ width: options.width ?? 600,
+ height: options.height ?? 400,
+ zIndex: nextZIndex++,
+ minimized: false,
+ maximized: false,
+ });
+ focusedId.value = id;
+ _saveWindows();
+ }
+
+ function remove(id: string) {
+ windows.value = windows.value.filter((w) => w.id !== id);
+ if (focusedId.value === id) {
+ const last = windows.value[windows.value.length - 1];
+ focusedId.value = last?.id ?? null;
+ }
+ _saveWindows();
+ }
+
+ function focus(id: string) {
+ if (focusedId.value === id) {
+ return;
+ }
+ focusedId.value = id;
+ const win = windows.value.find((w) => w.id === id);
+ if (win) {
+ win.zIndex = nextZIndex++;
+ }
+ }
+
+ function updatePosition(id: string, x: number, y: number) {
+ const win = windows.value.find((w) => w.id === id);
+ if (win) {
+ win.x = x;
+ win.y = y;
+ _saveWindows();
+ }
+ }
+
+ function updateSize(id: string, width: number, height: number) {
+ const win = windows.value.find((w) => w.id === id);
+ if (win) {
+ win.width = width;
+ win.height = height;
+ _saveWindows();
+ }
+ }
+
+ function toggleMinimize(id: string) {
+ const win = windows.value.find((w) => w.id === id);
+ if (win) {
+ win.minimized = !win.minimized;
+ if (win.minimized && focusedId.value === id) {
+ const next = windows.value.find((w) => w.id !== id && !w.minimized);
+ focusedId.value = next?.id ?? null;
+ }
+ if (!win.minimized) {
+ focus(id);
+ }
+ }
+ }
+
+ function toggleMaximize(id: string) {
+ const win = windows.value.find((w) => w.id === id);
+ if (win) {
+ win.maximized = !win.maximized;
+ if (win.maximized) {
+ win.minimized = false;
+ }
+ }
+ }
+
+ function beforeUnload(): boolean {
+ return windows.value.length > 0;
+ }
+
+ function _saveWindows() {
+ if (saveTimeout) {
+ clearTimeout(saveTimeout);
+ }
+ saveTimeout = setTimeout(() => {
+ try {
+ const data = windows.value.map((w) => ({
+ id: w.id,
+ title: w.title,
+ url: w.url,
+ x: w.x,
+ y: w.y,
+ width: w.width,
+ height: w.height,
+ }));
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
+ } catch {
+ // localStorage full or unavailable
+ }
+ }, 200);
+ }
+
+ function restore() {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (!raw) {
+ return;
+ }
+ const saved = JSON.parse(raw);
+ if (!Array.isArray(saved) || saved.length === 0) {
+ return;
+ }
+ active.value = true;
+ for (const entry of saved) {
+ add({
+ title: entry.title,
+ url: entry.url,
+ x: entry.x,
+ y: entry.y,
+ width: entry.width,
+ height: entry.height,
+ });
+ }
+ } catch {
+ // corrupt localStorage
+ }
+ }
+
+ return {
+ active,
+ windows,
+ focusedId,
+ toggle,
+ getTab,
+ buildUrl,
+ add,
+ remove,
+ focus,
+ updatePosition,
+ updateSize,
+ toggleMinimize,
+ toggleMaximize,
+ beforeUnload,
+ restore,
+ };
+});
diff --git a/client/src/style/scss/base.scss b/client/src/style/scss/base.scss
index ef17f43cfaf3..23ec3db85ed1 100644
--- a/client/src/style/scss/base.scss
+++ b/client/src/style/scss/base.scss
@@ -24,7 +24,6 @@ $fa-font-path: "../../../node_modules/@fortawesome/fontawesome-free/webfonts/";
// galaxy sub-components
@import "reports";
-@import "windows.scss";
@import "upload.scss";
@import "ui.scss";
@import "library.scss";
diff --git a/client/src/style/scss/windows.scss b/client/src/style/scss/windows.scss
deleted file mode 100644
index d0269dee0640..000000000000
--- a/client/src/style/scss/windows.scss
+++ /dev/null
@@ -1,63 +0,0 @@
-.winbox {
- border-radius: $border-radius-base;
- margin-left: 1px;
- margin-top: calc($masthead-height + 1px);
- box-shadow: 0 0.5rem 1rem rgba($black, 0.15);
- border: $border-default;
- font-family: $font-family-sans-serif;
-}
-
-.winbox.max {
- border-radius: 0;
- margin: 0;
- box-shadow: none;
- border: none;
- .wb-header {
- border-radius: 0;
- }
-}
-
-.winbox.min {
- @extend .m-0;
-}
-
-.wb-header {
- background: $brand-primary;
- border-top-left-radius: $border-radius-base;
- border-top-right-radius: $border-radius-base;
-}
-
-.winbox .wb-title {
- font-family: $font-family-sans-serif;
- font-size: $font-size-base;
- font-weight: bold;
-}
-
-.wb-body {
- background: $body-bg;
-}
-
-.iframe-focus-overlay {
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- z-index: 1;
-}
-
-.winbox.focus .iframe-focus-overlay {
- pointer-events: none;
-}
-
-.wb-icon * {
- opacity: 0.65;
-}
-
-.wb-icon *:hover {
- opacity: 1;
-}
-
-.wb-full {
- display: none !important;
-}
diff --git a/client/src/utils/navigation/navigation.yml b/client/src/utils/navigation/navigation.yml
index a61d1dbb4eb4..631d879ec8ed 100644
--- a/client/src/utils/navigation/navigation.yml
+++ b/client/src/utils/navigation/navigation.yml
@@ -640,15 +640,15 @@ registration:
window_manager:
selectors:
- _: '.winbox'
- title: '.winbox .wb-title'
- close_button: '.winbox .wb-close'
- header: '.winbox .wb-header'
- body: '.winbox .wb-body'
- iframe: '.winbox iframe'
- focused: '.winbox.focus'
- focused_title: '.winbox.focus .wb-title'
- focus_overlay: '.winbox .iframe-focus-overlay'
+ _: '.window-manager-window'
+ title: '.window-manager-window .window-manager-window-title'
+ close_button: '.window-manager-window .window-manager-window-close'
+ header: '.window-manager-window .window-manager-window-header'
+ body: '.window-manager-window .window-manager-window-body'
+ iframe: '.window-manager-window iframe'
+ focused: '.window-manager-window.focused'
+ focused_title: '.window-manager-window.focused .window-manager-window-title'
+ focus_overlay: '.window-manager-window .iframe-focus-overlay'
tool_form:
selectors:
diff --git a/client/vitest.config.mts b/client/vitest.config.mts
index d28f526ecc78..d7afa21dda68 100644
--- a/client/vitest.config.mts
+++ b/client/vitest.config.mts
@@ -1,8 +1,9 @@
///
-import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue2";
import path from "path";
import { fileURLToPath } from "url";
+import { defineConfig } from "vite";
+
import { i18nPlugin } from "./tests/vitest/test-plugin";
import { yamlPlugin } from "./tests/vitest/yaml-plugin";
@@ -14,7 +15,6 @@ const modulesToTransform = [
"bootstrap-vue",
"rxjs",
"@hirez_io",
- "winbox",
"pretty-bytes",
"@fortawesome",
"ro-crate-zip-explorer",
diff --git a/config/plugins/tours/core.windows.yaml b/config/plugins/tours/core.windows.yaml
index 0fc674d7103c..0dc82c7ec7e6 100644
--- a/config/plugins/tours/core.windows.yaml
+++ b/config/plugins/tours/core.windows.yaml
@@ -80,7 +80,7 @@ steps:
intro: "Clicking the eye-icon usually displays a dataset in the center panel."
postclick: true
- - element: "#winbox-1"
+ - element: ".window-manager-window"
intro: "However while using the Window Manager, the dataset will be shown as resizable window."
- title: "Done."
diff --git a/lib/galaxy/selenium/navigates_galaxy.py b/lib/galaxy/selenium/navigates_galaxy.py
index 9d9950a592b4..f098aa7cb316 100644
--- a/lib/galaxy/selenium/navigates_galaxy.py
+++ b/lib/galaxy/selenium/navigates_galaxy.py
@@ -2307,28 +2307,28 @@ def window_manager_is_active(self) -> bool:
return self.components.masthead.window_manager.has_class("toggle")
def window_manager_window_count(self) -> int:
- """Return number of open WinBox windows."""
- return len(self.find_elements_by_selector(".winbox"))
+ """Return number of open window manager windows."""
+ return len(self.find_elements_by_selector(".window-manager-window"))
def window_manager_wait_for_window_count(self, expected_count: int):
- """Wait until the expected number of .winbox elements exist."""
+ """Wait until the expected number of window manager windows exist."""
def check_count(driver=None):
- count = len(self.find_elements_by_selector(".winbox"))
+ count = len(self.find_elements_by_selector(".window-manager-window"))
return count == expected_count
self._wait_on(check_count, f"window count to be {expected_count}")
@contextlib.contextmanager
- def winbox_frame(self, index=0):
- """Context manager to switch into a WinBox iframe by index.
+ def window_manager_frame(self, index=0):
+ """Context manager to switch into a window manager iframe by index.
Usage:
- with self.winbox_frame(0):
+ with self.window_manager_frame(0):
self.wait_for_selector_visible(".dataset-view")
"""
- iframes = self.find_elements_by_selector(".winbox iframe")
- assert len(iframes) > index, f"Expected at least {index + 1} WinBox iframes, found {len(iframes)}"
+ iframes = self.find_elements_by_selector(".window-manager-window iframe")
+ assert len(iframes) > index, f"Expected at least {index + 1} window manager iframes, found {len(iframes)}"
try:
self.switch_to_frame(iframes[index])
yield
@@ -2336,26 +2336,22 @@ def winbox_frame(self, index=0):
self.switch_to_default_content()
def window_manager_get_titles(self) -> list:
- """Return list of window titles from all open WinBox windows."""
+ """Return list of window titles from all open window manager windows."""
elements = self.components.window_manager.title.all()
return [el.text for el in elements]
def window_manager_close_window(self, index=0):
- """Close a specific WinBox window by index.
-
- Uses JS click because the .wb-close button is obscured by WinBox's
- .wb-n resize handle overlay.
- """
+ """Close a specific window manager window by index."""
close_buttons = self.components.window_manager.close_button.all()
assert len(close_buttons) > index, f"Expected at least {index + 1} close buttons, found {len(close_buttons)}"
- self.execute_script_click(close_buttons[index])
+ close_buttons[index].click()
def window_manager_get_focused_title(self) -> str:
- """Return the title text of the currently focused WinBox window."""
+ """Return the title text of the currently focused window manager window."""
return self.components.window_manager.focused_title.wait_for_text()
def window_manager_click_focus_overlay(self, index=0):
- """Click the focus overlay of a WinBox window to switch focus.
+ """Click the focus overlay of a window manager window to switch focus.
Uses fire_mousedown to match the event the overlay actually listens for.
"""
@@ -2364,13 +2360,13 @@ def window_manager_click_focus_overlay(self, index=0):
self.fire_mousedown(overlays[index])
def window_manager_get_iframe_src(self, index=0) -> str:
- """Return the src attribute of a WinBox iframe by index."""
+ """Return the src attribute of a window manager iframe by index."""
iframes = self.components.window_manager.iframe.all()
assert len(iframes) > index, f"Expected at least {index + 1} iframes, found {len(iframes)}"
return iframes[index].get_attribute("src") or ""
def window_manager_focused_count(self) -> int:
- """Return the number of WinBox windows with focus class."""
+ """Return the number of focused window manager windows."""
return len(self.components.window_manager.focused.all())
# avoids problematic ID and classes on markup
diff --git a/lib/galaxy_test/selenium/test_window_manager.py b/lib/galaxy_test/selenium/test_window_manager.py
index d66bab8610e5..77d4c87f6b76 100644
--- a/lib/galaxy_test/selenium/test_window_manager.py
+++ b/lib/galaxy_test/selenium/test_window_manager.py
@@ -1,4 +1,4 @@
-"""E2E tests for Galaxy's Window Manager (WinBox floating windows)."""
+"""E2E tests for Galaxy's window manager (floating windows)."""
from .framework import (
managed_history,
@@ -32,7 +32,7 @@ def test_toggle_window_manager(self):
@selenium_test
@managed_history
def test_open_dataset_in_window(self):
- """Display a dataset with WM active — a WinBox window should appear."""
+ """Display a dataset with WM active — a window manager window should appear."""
self.perform_upload(self.get_filename("1.fasta"))
self.history_panel_wait_for_hid_ok(1)
@@ -43,7 +43,7 @@ def test_open_dataset_in_window(self):
item = self.history_panel_item_component(hid=1)
item.display_button.wait_for_and_click()
- # A WinBox window should appear
+ # A window manager window should appear
self.components.window_manager._.wait_for_visible()
assert self.window_manager_window_count() == 1
self.screenshot("window_manager_dataset_opened")
@@ -56,7 +56,7 @@ def test_open_dataset_in_window(self):
@selenium_test
@managed_history
def test_window_content_loads(self):
- """Content inside the WinBox iframe should render the dataset view."""
+ """Content inside the window manager iframe should render the dataset view."""
self.perform_upload(self.get_filename("1.fasta"))
self.history_panel_wait_for_hid_ok(1)
@@ -66,14 +66,14 @@ def test_window_content_loads(self):
self.components.window_manager._.wait_for_visible()
# Switch into the iframe and verify dataset view rendered
- with self.winbox_frame(0):
+ with self.window_manager_frame(0):
self.wait_for_selector_visible(".dataset-view")
self.screenshot("window_manager_iframe_content")
@selenium_test
@managed_history
def test_multiple_windows(self):
- """Opening multiple datasets creates multiple WinBox windows with correct focus."""
+ """Opening multiple datasets creates multiple window manager windows with correct focus."""
for _i in range(3):
self.perform_upload(self.get_filename("1.fasta"))
self.history_panel_wait_for_hid_ok(3)
@@ -102,7 +102,7 @@ def test_multiple_windows(self):
@selenium_test
@managed_history
def test_close_window(self):
- """Closing a WinBox window removes it from DOM and decrements counter."""
+ """Closing a window manager window removes it from DOM."""
self.perform_upload(self.get_filename("1.fasta"))
self.perform_upload(self.get_filename("1.bed"))
self.history_panel_wait_for_hid_ok(2)
diff --git a/lib/galaxy_test/selenium/test_window_manager_persistence.py b/lib/galaxy_test/selenium/test_window_manager_persistence.py
index e55629440171..8547353a2e1d 100644
--- a/lib/galaxy_test/selenium/test_window_manager_persistence.py
+++ b/lib/galaxy_test/selenium/test_window_manager_persistence.py
@@ -13,7 +13,7 @@ class TestWindowManagerPersistence(SeleniumTestCase):
def setUp(self):
super().setUp()
- self.execute_script("localStorage.removeItem('galaxy-scratchbook-windows');")
+ self.execute_script("localStorage.removeItem('galaxy-window-manager-windows');")
@selenium_test
@managed_history