@matdata/yasgui 5.15.0 → 5.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/build/ts/src/Tab.d.ts +2 -0
  2. package/build/ts/src/Tab.js +46 -1
  3. package/build/ts/src/Tab.js.map +1 -1
  4. package/build/ts/src/TabContextMenu.d.ts +2 -1
  5. package/build/ts/src/TabContextMenu.js +11 -3
  6. package/build/ts/src/TabContextMenu.js.map +1 -1
  7. package/build/ts/src/TabElements.js +13 -3
  8. package/build/ts/src/TabElements.js.map +1 -1
  9. package/build/ts/src/TabSettingsModal.d.ts +8 -0
  10. package/build/ts/src/TabSettingsModal.js +89 -3
  11. package/build/ts/src/TabSettingsModal.js.map +1 -1
  12. package/build/ts/src/endpointSelect.js +1 -1
  13. package/build/ts/src/queryManagement/QueryBrowser.d.ts +1 -0
  14. package/build/ts/src/queryManagement/QueryBrowser.js +62 -0
  15. package/build/ts/src/queryManagement/QueryBrowser.js.map +1 -1
  16. package/build/ts/src/queryManagement/SaveManagedQueryModal.d.ts +6 -0
  17. package/build/ts/src/queryManagement/SaveManagedQueryModal.js +67 -3
  18. package/build/ts/src/queryManagement/SaveManagedQueryModal.js.map +1 -1
  19. package/build/ts/src/queryManagement/backends/GitWorkspaceBackend.d.ts +1 -0
  20. package/build/ts/src/queryManagement/backends/GitWorkspaceBackend.js +18 -0
  21. package/build/ts/src/queryManagement/backends/GitWorkspaceBackend.js.map +1 -1
  22. package/build/ts/src/queryManagement/backends/InMemoryWorkspaceBackend.d.ts +1 -0
  23. package/build/ts/src/queryManagement/backends/InMemoryWorkspaceBackend.js +17 -0
  24. package/build/ts/src/queryManagement/backends/InMemoryWorkspaceBackend.js.map +1 -1
  25. package/build/ts/src/queryManagement/backends/SparqlWorkspaceBackend.d.ts +1 -0
  26. package/build/ts/src/queryManagement/backends/SparqlWorkspaceBackend.js +61 -0
  27. package/build/ts/src/queryManagement/backends/SparqlWorkspaceBackend.js.map +1 -1
  28. package/build/ts/src/queryManagement/backends/WorkspaceBackend.d.ts +1 -0
  29. package/build/ts/src/version.d.ts +1 -1
  30. package/build/ts/src/version.js +1 -1
  31. package/build/yasgui.min.css +1 -1
  32. package/build/yasgui.min.css.map +3 -3
  33. package/build/yasgui.min.js +236 -210
  34. package/build/yasgui.min.js.map +4 -4
  35. package/package.json +1 -1
  36. package/src/Tab.ts +53 -1
  37. package/src/TabContextMenu.ts +15 -5
  38. package/src/TabElements.scss +17 -0
  39. package/src/TabElements.ts +17 -3
  40. package/src/TabSettingsModal.scss +73 -0
  41. package/src/TabSettingsModal.ts +117 -3
  42. package/src/endpointSelect.scss +0 -22
  43. package/src/endpointSelect.ts +1 -1
  44. package/src/queryManagement/QueryBrowser.ts +72 -0
  45. package/src/queryManagement/SaveManagedQueryModal.ts +82 -3
  46. package/src/queryManagement/backends/GitWorkspaceBackend.ts +19 -0
  47. package/src/queryManagement/backends/InMemoryWorkspaceBackend.ts +17 -0
  48. package/src/queryManagement/backends/SparqlWorkspaceBackend.ts +69 -0
  49. package/src/queryManagement/backends/WorkspaceBackend.ts +6 -0
  50. package/src/tab.scss +1 -0
  51. package/src/themes.scss +0 -10
  52. package/src/version.ts +1 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@matdata/yasgui",
3
3
  "description": "Yet Another SPARQL GUI",
4
- "version": "5.15.0",
4
+ "version": "5.16.0",
5
5
  "main": "build/yasgui.min.js",
6
6
  "types": "build/ts/src/index.d.ts",
7
7
  "license": "MIT",
package/src/Tab.ts CHANGED
@@ -175,6 +175,42 @@ export class Tab extends EventEmitter {
175
175
  return this.persistentJson.yasqe.value;
176
176
  }
177
177
 
178
+ public async downloadAsRqFile() {
179
+ const query = this.getQueryTextForSave();
180
+ const tabName = this.name() || "query";
181
+ const filename = tabName.endsWith(".rq") || tabName.endsWith(".sparql") ? tabName : `${tabName}.rq`;
182
+ const blob = new Blob([query], { type: "application/sparql-query" });
183
+
184
+ // Use File System Access API if available so the user can choose the save location
185
+ if ("showSaveFilePicker" in window) {
186
+ try {
187
+ const showSaveFilePicker = (
188
+ window as Window & { showSaveFilePicker: (...args: unknown[]) => Promise<FileSystemFileHandle> }
189
+ ).showSaveFilePicker;
190
+ const fileHandle = await showSaveFilePicker({
191
+ suggestedName: filename,
192
+ types: [{ description: "SPARQL Query", accept: { "application/sparql-query": [".rq", ".sparql"] } }],
193
+ });
194
+ const writable = await fileHandle.createWritable();
195
+ await writable.write(blob);
196
+ await writable.close();
197
+ return;
198
+ } catch (e: any) {
199
+ // User cancelled the picker – abort silently
200
+ if (e?.name === "AbortError") return;
201
+ // Other errors fall through to the legacy download below
202
+ }
203
+ }
204
+
205
+ // Fallback for browsers without File System Access API
206
+ const url = URL.createObjectURL(blob);
207
+ const a = document.createElement("a");
208
+ a.href = url;
209
+ a.download = filename;
210
+ a.click();
211
+ URL.revokeObjectURL(url);
212
+ }
213
+
178
214
  public async saveManagedQueryOrSaveAsManagedQuery(): Promise<void> {
179
215
  const meta = this.getManagedQueryMetadata();
180
216
  if (!meta) {
@@ -535,7 +571,7 @@ export class Tab extends EventEmitter {
535
571
  if (!this.controlBarEl) return;
536
572
 
537
573
  this.orientationToggleButton = document.createElement("button");
538
- this.orientationToggleButton.className = "tabContextButton orientationToggle";
574
+ this.orientationToggleButton.className = "tabContextButton orientationToggle desktopOnly";
539
575
  this.orientationToggleButton.setAttribute("aria-label", "Toggle layout orientation");
540
576
  this.orientationToggleButton.title = "Toggle layout orientation";
541
577
 
@@ -615,6 +651,10 @@ export class Tab extends EventEmitter {
615
651
  return this.yasr;
616
652
  }
617
653
 
654
+ public getCurrentOrientation() {
655
+ return this.currentOrientation;
656
+ }
657
+
618
658
  private initTabSettingsMenu() {
619
659
  if (!this.controlBarEl) throw new Error("Need to initialize wrapper elements before drawing tab settings");
620
660
  this.settingsModal = new TabSettingsModal(this, this.controlBarEl);
@@ -1302,6 +1342,11 @@ export class Tab extends EventEmitter {
1302
1342
  void this.saveManagedQueryOrSaveAsManagedQuery();
1303
1343
  });
1304
1344
 
1345
+ // Hook up download as .rq file
1346
+ this.yasqe.on("downloadRqFile", () => {
1347
+ void this.downloadAsRqFile();
1348
+ });
1349
+
1305
1350
  // Show/hide save button based on workspace configuration
1306
1351
  this.updateSaveButtonVisibility();
1307
1352
 
@@ -1627,6 +1672,13 @@ WHERE {
1627
1672
  // Add default renderers to the end, to give our custom ones priority.
1628
1673
  ...(Yasr.defaults.errorRenderers || []),
1629
1674
  ],
1675
+ executeQuery: async (query: string, options?: { acceptHeader?: string }) => {
1676
+ if (!this.yasqe) throw new Error("No YASQE instance available");
1677
+ return Yasqe.Sparql.executeQuery(this.yasqe, undefined, {
1678
+ customQuery: query,
1679
+ customAccept: options?.acceptHeader,
1680
+ });
1681
+ },
1630
1682
  };
1631
1683
  // Allow getDownloadFilName to be overwritten by the global config
1632
1684
  if (yasrConf.getDownloadFileName === undefined) {
@@ -14,8 +14,9 @@ export default class TabContextMenu {
14
14
  private contextEl!: HTMLElement;
15
15
  private newTabEl!: HTMLElement;
16
16
  private renameTabEl!: HTMLElement;
17
- private copyTabEl!: HTMLElement;
17
+ private duplicateTabEl!: HTMLElement;
18
18
  private saveManagedQueryEl!: HTMLElement;
19
+ private saveAsRqFileEl!: HTMLElement;
19
20
  private closeTabEl!: HTMLElement;
20
21
  private closeOtherTabsEl!: HTMLElement;
21
22
  private reOpenOldTab!: HTMLElement;
@@ -48,10 +49,12 @@ export default class TabContextMenu {
48
49
 
49
50
  this.renameTabEl = this.getMenuItemEl("Rename Tab");
50
51
 
51
- this.copyTabEl = this.getMenuItemEl("Copy Tab");
52
+ this.duplicateTabEl = this.getMenuItemEl("Duplicate Tab");
52
53
 
53
54
  this.saveManagedQueryEl = this.getMenuItemEl("Save as managed query");
54
55
 
56
+ this.saveAsRqFileEl = this.getMenuItemEl("Save as .rq file");
57
+
55
58
  this.closeTabEl = this.getMenuItemEl("Close Tab");
56
59
 
57
60
  this.closeOtherTabsEl = this.getMenuItemEl("Close other tabs");
@@ -61,8 +64,9 @@ export default class TabContextMenu {
61
64
  // Add items to list
62
65
  dropDownList.appendChild(this.newTabEl);
63
66
  dropDownList.appendChild(this.renameTabEl);
64
- dropDownList.appendChild(this.copyTabEl);
67
+ dropDownList.appendChild(this.duplicateTabEl);
65
68
  dropDownList.appendChild(this.saveManagedQueryEl);
69
+ dropDownList.appendChild(this.saveAsRqFileEl);
66
70
  // Add divider
67
71
  dropDownList.appendChild(document.createElement("hr"));
68
72
  dropDownList.appendChild(this.closeTabEl);
@@ -101,8 +105,8 @@ export default class TabContextMenu {
101
105
  // Set rename functionality
102
106
  this.renameTabEl.onclick = () => currentTabEl.startRename();
103
107
 
104
- // Copy tab functionality`
105
- this.copyTabEl.onclick = () => {
108
+ // Duplicate tab functionality
109
+ this.duplicateTabEl.onclick = () => {
106
110
  if (!tab) return;
107
111
  const config = cloneDeep(tab.getPersistedJson());
108
112
  config.id = getRandomId();
@@ -115,6 +119,12 @@ export default class TabContextMenu {
115
119
  this.closeConfigMenu();
116
120
  };
117
121
 
122
+ this.saveAsRqFileEl.onclick = () => {
123
+ if (!tab) return;
124
+ tab.downloadAsRqFile();
125
+ this.closeConfigMenu();
126
+ };
127
+
118
128
  // Close tab functionality
119
129
  this.closeTabEl.onclick = () => tab?.close();
120
130
 
@@ -94,6 +94,10 @@ $minTabHeight: 35px;
94
94
  position: relative;
95
95
  $activeColor: #337ab7;
96
96
  $hoverColor: color.adjust($activeColor, $lightness: 30%);
97
+ user-select: none;
98
+ -webkit-user-select: none;
99
+ -moz-user-select: none;
100
+ -ms-user-select: none;
97
101
 
98
102
  // Distinguish managed-query tabs from legacy tabs via background.
99
103
  // Legacy tabs keep their current styling.
@@ -190,6 +194,19 @@ $minTabHeight: 35px;
190
194
  padding: 0px 24px 0px 30px;
191
195
  white-space: nowrap;
192
196
  overflow: hidden;
197
+ user-select: none;
198
+ -webkit-user-select: none;
199
+ -moz-user-select: none;
200
+ -ms-user-select: none;
201
+ -webkit-user-drag: none;
202
+
203
+ span {
204
+ user-select: none;
205
+ -webkit-user-select: none;
206
+ -moz-user-select: none;
207
+ -ms-user-select: none;
208
+ pointer-events: none;
209
+ }
193
210
 
194
211
  &:hover {
195
212
  border-bottom-color: $hoverColor;
@@ -107,7 +107,7 @@ export class TabListEl {
107
107
  tabLinkEl.href = "#" + this.tabId;
108
108
  tabLinkEl.id = "tab-" + this.tabId; // use the id for the tabpanel which is tabId to set the actual tab id
109
109
  tabLinkEl.setAttribute("aria-controls", this.tabId); // respective tabPanel id
110
- tabLinkEl.draggable = false; // Prevent default link dragging that interferes with text selection
110
+ // Note: draggable attribute removed - CSS user-select:none prevents text selection during drag
111
111
  tabLinkEl.addEventListener("blur", () => {
112
112
  if (!this.tabEl) return;
113
113
  if (this.tabEl.classList.contains("active")) {
@@ -133,6 +133,17 @@ export class TabListEl {
133
133
  this.yasgui.selectTabId(this.tabId);
134
134
  });
135
135
 
136
+ // Prevent anchor default drag behavior that interferes with SortableJS in some browsers
137
+ tabLinkEl.addEventListener("dragstart", (e) => {
138
+ e.preventDefault();
139
+ });
140
+ tabLinkEl.addEventListener("mousedown", (e) => {
141
+ // Only prevent default on middle/right click to avoid navigation
142
+ if (e.button !== 0) {
143
+ e.preventDefault();
144
+ }
145
+ });
146
+
136
147
  //tab name
137
148
  this.nameEl = document.createElement("span");
138
149
  this.nameEl.textContent = name;
@@ -315,7 +326,7 @@ export class TabList {
315
326
  queryBrowserButton.className = "queryBrowserToggle";
316
327
  queryBrowserButton.setAttribute("aria-label", "Open query browser");
317
328
  queryBrowserButton.title = "Open query browser";
318
- queryBrowserButton.innerHTML = '<i class="fas fa-bars"></i>';
329
+ queryBrowserButton.innerHTML = '<i class="fa-regular fa-file-lines"></i>';
319
330
  queryBrowserButton.addEventListener("click", () => {
320
331
  this.yasgui.queryBrowser.toggle(queryBrowserButton);
321
332
  });
@@ -326,12 +337,15 @@ export class TabList {
326
337
  sortablejs.create(this._tabsListEl, {
327
338
  group: "tabList",
328
339
  animation: 100,
340
+ draggable: ".tab",
341
+ forceFallback: true,
342
+ fallbackTolerance: 3,
329
343
  onUpdate: (_ev: any) => {
330
344
  const tabs = this.deriveTabOrderFromEls();
331
345
  this.yasgui.emit("tabOrderChanged", this.yasgui, tabs);
332
346
  this.yasgui.persistentConfig.setTabOrder(tabs);
333
347
  },
334
- filter: ".queryBrowserToggle, .addTab, input, .renaming",
348
+ filter: ".queryBrowserToggle, .addTab, input, .renaming, .closeTab",
335
349
  preventOnFilter: false,
336
350
  onMove: (ev: any, _origEv: any) => {
337
351
  return hasClass(ev.related, "tab");
@@ -1251,3 +1251,76 @@
1251
1251
  transform: translateY(1px);
1252
1252
  }
1253
1253
  }
1254
+
1255
+ // Hamburger menu styles
1256
+ .hamburgerContainer {
1257
+ position: relative;
1258
+ display: none; // Hidden by default (desktop)
1259
+
1260
+ @media (max-width: 768px) {
1261
+ display: flex; // Show on mobile
1262
+ }
1263
+ }
1264
+
1265
+ .hamburgerMenuButton {
1266
+ &.active {
1267
+ color: var(--yasgui-accent-color, #337ab7);
1268
+ }
1269
+ }
1270
+
1271
+ .hamburgerDropdown {
1272
+ position: absolute;
1273
+ top: 100%;
1274
+ left: 0;
1275
+ background: var(--yasgui-bg-primary, white);
1276
+ border: 1px solid var(--yasgui-border-color, #e0e0e0);
1277
+ border-radius: 4px;
1278
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
1279
+ min-width: 200px;
1280
+ z-index: 1000;
1281
+ margin-top: 5px;
1282
+ overflow: hidden;
1283
+ }
1284
+
1285
+ .hamburgerMenuItem {
1286
+ display: flex;
1287
+ align-items: center;
1288
+ gap: 12px;
1289
+ width: 100%;
1290
+ padding: 12px 16px;
1291
+ background: none;
1292
+ border: none;
1293
+ border-bottom: 1px solid var(--yasgui-border-color, #f0f0f0);
1294
+ text-align: left;
1295
+ cursor: pointer;
1296
+ color: var(--yasgui-text-primary, #333);
1297
+ transition: background-color 0.2s;
1298
+
1299
+ &:last-child {
1300
+ border-bottom: none;
1301
+ }
1302
+
1303
+ &:hover {
1304
+ background: var(--yasgui-bg-secondary, #f7f7f7);
1305
+ }
1306
+
1307
+ i {
1308
+ font-size: 16px;
1309
+ width: 20px;
1310
+ text-align: center;
1311
+ color: var(--yasgui-button-text, #505050);
1312
+ }
1313
+
1314
+ span {
1315
+ flex: 1;
1316
+ font-size: 14px;
1317
+ font-weight: 500;
1318
+ }
1319
+ }
1320
+
1321
+ // Responsive visibility classes
1322
+ .desktopOnly {
1323
+ @media (max-width: 768px) {
1324
+ display: none !important;
1325
+ }
1326
+ }
@@ -39,6 +39,9 @@ export default class TabSettingsModal {
39
39
  private prefixButton!: HTMLButtonElement;
40
40
  private prefixTextarea!: HTMLTextAreaElement;
41
41
  private autoCaptureCheckbox!: HTMLInputElement;
42
+ private hamburgerMenuButton!: HTMLButtonElement;
43
+ private hamburgerDropdown!: HTMLElement;
44
+ private isHamburgerOpen = false;
42
45
  private mouseDownOnOverlay = false;
43
46
  private handleKeyDown = (e: KeyboardEvent) => {
44
47
  if (e.key !== "Escape") return;
@@ -55,7 +58,7 @@ export default class TabSettingsModal {
55
58
  private init(controlBarEl: HTMLElement) {
56
59
  // Settings button
57
60
  this.settingsButton = document.createElement("button");
58
- addClass(this.settingsButton, "tabContextButton");
61
+ addClass(this.settingsButton, "tabContextButton", "desktopOnly");
59
62
  this.settingsButton.setAttribute("aria-label", "Settings");
60
63
  this.settingsButton.title = "Settings";
61
64
  this.settingsButton.innerHTML = '<i class="fas fa-cog"></i>';
@@ -65,7 +68,7 @@ export default class TabSettingsModal {
65
68
  // Theme toggle button (if enabled)
66
69
  if (this.tab.yasgui.config.showThemeToggle) {
67
70
  this.themeToggleButton = document.createElement("button");
68
- addClass(this.themeToggleButton, "tabContextButton", "themeToggle");
71
+ addClass(this.themeToggleButton, "tabContextButton", "themeToggle", "desktopOnly");
69
72
  this.themeToggleButton.setAttribute("aria-label", "Toggle between light and dark theme");
70
73
  this.themeToggleButton.title = "Toggle theme";
71
74
  this.themeToggleButton.innerHTML = this.getThemeToggleIcon();
@@ -81,13 +84,124 @@ export default class TabSettingsModal {
81
84
  this.prefixButton.setAttribute("aria-label", "Insert Prefixes");
82
85
  this.prefixButton.title = "Insert saved prefixes into query";
83
86
  this.prefixButton.innerHTML = '<i class="fas fa-arrow-up-right-from-square"></i>';
84
- addClass(this.prefixButton, "tabContextButton", "prefixButton");
87
+ addClass(this.prefixButton, "tabContextButton", "prefixButton", "desktopOnly");
85
88
  controlBarEl.appendChild(this.prefixButton);
86
89
  this.prefixButton.onclick = () => this.insertPrefixesIntoQuery();
87
90
 
91
+ // Hamburger menu button and dropdown (mobile only)
92
+ const hamburgerContainer = document.createElement("div");
93
+ addClass(hamburgerContainer, "hamburgerContainer", "mobileOnly");
94
+
95
+ this.hamburgerMenuButton = document.createElement("button");
96
+ addClass(this.hamburgerMenuButton, "tabContextButton", "hamburgerMenuButton");
97
+ this.hamburgerMenuButton.setAttribute("aria-label", "Menu");
98
+ this.hamburgerMenuButton.title = "Open menu";
99
+ this.hamburgerMenuButton.innerHTML = '<i class="fas fa-bars"></i>';
100
+ this.hamburgerMenuButton.onclick = (e) => {
101
+ e.stopPropagation();
102
+ this.toggleHamburgerMenu();
103
+ };
104
+ hamburgerContainer.appendChild(this.hamburgerMenuButton);
105
+
106
+ // Create hamburger dropdown menu
107
+ this.createHamburgerDropdown(hamburgerContainer);
108
+
109
+ controlBarEl.appendChild(hamburgerContainer);
110
+
111
+ // Close hamburger menu when clicking outside
112
+ document.addEventListener("click", (e) => {
113
+ if (
114
+ this.isHamburgerOpen &&
115
+ !this.hamburgerDropdown.contains(e.target as Node) &&
116
+ e.target !== this.hamburgerMenuButton
117
+ ) {
118
+ this.closeHamburgerMenu();
119
+ }
120
+ });
121
+
88
122
  this.createModal();
89
123
  }
90
124
 
125
+ private createHamburgerDropdown(hamburgerContainer: HTMLElement) {
126
+ this.hamburgerDropdown = document.createElement("div");
127
+ addClass(this.hamburgerDropdown, "hamburgerDropdown");
128
+ this.hamburgerDropdown.style.display = "none";
129
+
130
+ // Settings menu item
131
+ const settingsItem = document.createElement("button");
132
+ addClass(settingsItem, "hamburgerMenuItem");
133
+ settingsItem.innerHTML = '<i class="fas fa-cog"></i><span>Settings</span>';
134
+ settingsItem.onclick = () => {
135
+ this.open();
136
+ this.closeHamburgerMenu();
137
+ };
138
+ this.hamburgerDropdown.appendChild(settingsItem);
139
+
140
+ // Prefix menu item
141
+ const prefixItem = document.createElement("button");
142
+ addClass(prefixItem, "hamburgerMenuItem");
143
+ prefixItem.innerHTML = '<i class="fas fa-arrow-up-right-from-square"></i><span>Insert Prefixes</span>';
144
+ prefixItem.onclick = () => {
145
+ this.insertPrefixesIntoQuery();
146
+ this.closeHamburgerMenu();
147
+ };
148
+ this.hamburgerDropdown.appendChild(prefixItem);
149
+
150
+ // Theme toggle menu item (if enabled)
151
+ if (this.tab.yasgui.config.showThemeToggle) {
152
+ const themeItem = document.createElement("button");
153
+ addClass(themeItem, "hamburgerMenuItem", "themeMenuItem");
154
+ themeItem.innerHTML = this.getThemeToggleIcon() + "<span>Toggle Theme</span>";
155
+ themeItem.onclick = () => {
156
+ this.tab.yasgui.toggleTheme();
157
+ themeItem.innerHTML = this.getThemeToggleIcon() + "<span>Toggle Theme</span>";
158
+ // Keep menu open for theme toggle so user can see the change
159
+ };
160
+ this.hamburgerDropdown.appendChild(themeItem);
161
+ }
162
+
163
+ // Layout orientation menu item
164
+ const layoutItem = document.createElement("button");
165
+ addClass(layoutItem, "hamburgerMenuItem", "layoutMenuItem");
166
+ this.updateLayoutMenuItemContent(layoutItem);
167
+ layoutItem.onclick = () => {
168
+ this.tab.toggleOrientation();
169
+ this.updateLayoutMenuItemContent(layoutItem);
170
+ // Keep menu open for layout toggle so user can see the change
171
+ };
172
+ this.hamburgerDropdown.appendChild(layoutItem);
173
+
174
+ hamburgerContainer.appendChild(this.hamburgerDropdown);
175
+ }
176
+
177
+ private updateLayoutMenuItemContent(layoutItem: HTMLButtonElement) {
178
+ const orientation = this.tab.getCurrentOrientation();
179
+ const icon =
180
+ orientation === "vertical" ? '<i class="fas fa-grip-lines-vertical"></i>' : '<i class="fas fa-grip-lines"></i>';
181
+ const label = orientation === "vertical" ? "Horizontal Layout" : "Vertical Layout";
182
+ layoutItem.innerHTML = icon + "<span>" + label + "</span>";
183
+ }
184
+
185
+ private toggleHamburgerMenu() {
186
+ if (this.isHamburgerOpen) {
187
+ this.closeHamburgerMenu();
188
+ } else {
189
+ this.openHamburgerMenu();
190
+ }
191
+ }
192
+
193
+ private openHamburgerMenu() {
194
+ this.isHamburgerOpen = true;
195
+ this.hamburgerDropdown.style.display = "block";
196
+ addClass(this.hamburgerMenuButton, "active");
197
+ }
198
+
199
+ private closeHamburgerMenu() {
200
+ this.isHamburgerOpen = false;
201
+ this.hamburgerDropdown.style.display = "none";
202
+ removeClass(this.hamburgerMenuButton, "active");
203
+ }
204
+
91
205
  private createModal() {
92
206
  // Modal overlay
93
207
  this.modalOverlay = document.createElement("div");
@@ -99,28 +99,6 @@
99
99
  }
100
100
  }
101
101
 
102
- .clearEndpointBtn {
103
- border: 1px solid #d1d1d1;
104
- background-color: #d1d1d1;
105
- color: #505050;
106
- border-radius: 3px;
107
- cursor: pointer;
108
-
109
- padding: 4px 8px;
110
- margin: 4px;
111
-
112
- display: flex;
113
- align-items: center;
114
- justify-content: center;
115
-
116
- opacity: 1;
117
- transition: opacity ease-in 200ms;
118
-
119
- &:hover {
120
- opacity: 0.8;
121
- }
122
- }
123
-
124
102
  .endpointButtonsContainer {
125
103
  display: flex;
126
104
  align-items: center;
@@ -92,7 +92,7 @@ export class EndpointSelect extends EventEmitter {
92
92
  // Create clear button
93
93
  const clearBtn = document.createElement("button");
94
94
  clearBtn.title = "Clear endpoint";
95
- addClass(clearBtn, "clearEndpointBtn");
95
+ addClass(clearBtn, "tabContextButton");
96
96
  clearBtn.innerText = "✖";
97
97
  clearBtn.addEventListener("click", () => {
98
98
  this.inputField.value = "";
@@ -8,6 +8,7 @@ import { getEndpointToAutoSwitch } from "./openManagedQuery";
8
8
  import { hashQueryText } from "./textHash";
9
9
  import type { BackendType, VersionRef, ManagedTabMetadata } from "./types";
10
10
  import { normalizeQueryFilename } from "./normalizeQueryFilename";
11
+ import SaveManagedQueryModal from "./SaveManagedQueryModal";
11
12
 
12
13
  import "./QueryBrowser.scss";
13
14
 
@@ -47,6 +48,7 @@ export default class QueryBrowser {
47
48
  private lastRenderedSignature: string | undefined;
48
49
 
49
50
  private lastPointerPos: { x: number; y: number } | undefined;
51
+ private folderPickerModal?: SaveManagedQueryModal;
50
52
 
51
53
  private entrySignature(entry: FolderEntry): string {
52
54
  const parent = entry.parentId || "";
@@ -712,6 +714,76 @@ export default class QueryBrowser {
712
714
  actions.appendChild(renameBtn);
713
715
  }
714
716
 
717
+ if (backend.moveQuery) {
718
+ const moveBtn = document.createElement("button");
719
+ moveBtn.type = "button";
720
+ addClass(moveBtn, "yasgui-query-browser__action");
721
+ moveBtn.textContent = "Move";
722
+ moveBtn.setAttribute("aria-label", `Move ${entry.label} to a different folder`);
723
+ moveBtn.addEventListener("click", async (e) => {
724
+ e.preventDefault();
725
+ e.stopPropagation();
726
+
727
+ const currentFolderPath = entry.parentId || "";
728
+ if (!this.folderPickerModal) this.folderPickerModal = new SaveManagedQueryModal(this.yasgui);
729
+ const newFolderPath = await this.folderPickerModal.showFolderPickerOnly(
730
+ this.selectedWorkspaceId!,
731
+ currentFolderPath,
732
+ );
733
+ if (newFolderPath === undefined) return;
734
+ if (newFolderPath === currentFolderPath) return;
735
+
736
+ // Show loading state
737
+ const originalText = moveBtn.textContent;
738
+ moveBtn.disabled = true;
739
+ moveBtn.textContent = "Moving…";
740
+ addClass(moveBtn, "loading");
741
+
742
+ try {
743
+ const newQueryId = await backend.moveQuery!(entry.id, newFolderPath);
744
+
745
+ // For git workspaces: update any already-open managed tabs referencing the old path.
746
+ if (backend.type === "git" && newQueryId !== entry.id) {
747
+ for (const tab of Object.values(this.yasgui._tabs)) {
748
+ const meta = (tab as any).getManagedQueryMetadata?.() as ManagedTabMetadata | undefined;
749
+ if (!meta) continue;
750
+ if (meta.backendType !== "git") continue;
751
+ if (meta.workspaceId !== this.selectedWorkspaceId) continue;
752
+ const currentPath = (meta.queryRef as any)?.path as string | undefined;
753
+ if (currentPath !== entry.id) continue;
754
+
755
+ try {
756
+ const read = await backend.readQuery(newQueryId);
757
+ const lastSavedTextHash = hashQueryText(read.queryText);
758
+ const lastSavedVersionRef = this.versionRefFromVersionTag("git", read.versionTag);
759
+
760
+ (tab as any).setManagedQueryMetadata?.({
761
+ ...meta,
762
+ queryRef: { ...(meta.queryRef as any), path: newQueryId },
763
+ lastSavedTextHash,
764
+ lastSavedVersionRef,
765
+ });
766
+ } catch {
767
+ // Best-effort: if refreshing metadata fails, the Query Browser still reflects the move.
768
+ }
769
+ }
770
+ }
771
+
772
+ this.queryPreviewById.delete(entry.id);
773
+ this.folderEntriesById.clear();
774
+ this.invalidateRenderCache();
775
+ await this.refresh();
776
+ } catch (err) {
777
+ // Restore button state on error
778
+ moveBtn.disabled = false;
779
+ moveBtn.textContent = originalText || "Move";
780
+ removeClass(moveBtn, "loading");
781
+ window.alert(asWorkspaceBackendError(err).message);
782
+ }
783
+ });
784
+ actions.appendChild(moveBtn);
785
+ }
786
+
715
787
  if (backend.deleteQuery) {
716
788
  const deleteBtn = document.createElement("button");
717
789
  deleteBtn.type = "button";