@matdata/yasgui 5.15.0 → 5.17.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 +51 -2
  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 +254 -216
  34. package/build/yasgui.min.js.map +4 -4
  35. package/package.json +3 -3
  36. package/src/Tab.ts +64 -2
  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.17.0",
5
5
  "main": "build/yasgui.min.js",
6
6
  "types": "build/ts/src/index.d.ts",
7
7
  "license": "MIT",
@@ -33,8 +33,8 @@
33
33
  "jsuri": "^1.3.1",
34
34
  "lodash-es": "^4.17.15",
35
35
  "sortablejs": "^1.10.2",
36
- "@matdata/yasgui-graph-plugin": "^1.4.1",
37
- "@matdata/yasgui-table-plugin": "^1.1.0"
36
+ "@matdata/yasgui-graph-plugin": "^1.6.1",
37
+ "@matdata/yasgui-table-plugin": "^1.3.0"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@types/autosuggest-highlight": "^3.1.0",
package/src/Tab.ts CHANGED
@@ -3,7 +3,13 @@ import { addClass, removeClass, getAsValue } from "@matdata/yasgui-utils";
3
3
  import { TabListEl } from "./TabElements";
4
4
  import TabSettingsModal from "./TabSettingsModal";
5
5
  import { default as Yasqe, RequestConfig, PlainRequestConfig, PartialConfig as YasqeConfig } from "@matdata/yasqe";
6
- import { default as Yasr, Parser, Config as YasrConfig, PersistentConfig as YasrPersistentConfig } from "@matdata/yasr";
6
+ import {
7
+ default as Yasr,
8
+ Parser,
9
+ Config as YasrConfig,
10
+ PersistentConfig as YasrPersistentConfig,
11
+ PluginQueryOptions as YasrPluginQueryOptions,
12
+ } from "@matdata/yasr";
7
13
  import { mapValues, eq, mergeWith, words, deburr, invert } from "lodash-es";
8
14
  import * as shareLink from "./linkUtils";
9
15
  import EndpointSelect from "./endpointSelect";
@@ -175,6 +181,42 @@ export class Tab extends EventEmitter {
175
181
  return this.persistentJson.yasqe.value;
176
182
  }
177
183
 
184
+ public async downloadAsRqFile() {
185
+ const query = this.getQueryTextForSave();
186
+ const tabName = this.name() || "query";
187
+ const filename = tabName.endsWith(".rq") || tabName.endsWith(".sparql") ? tabName : `${tabName}.rq`;
188
+ const blob = new Blob([query], { type: "application/sparql-query" });
189
+
190
+ // Use File System Access API if available so the user can choose the save location
191
+ if ("showSaveFilePicker" in window) {
192
+ try {
193
+ const showSaveFilePicker = (
194
+ window as Window & { showSaveFilePicker: (...args: unknown[]) => Promise<FileSystemFileHandle> }
195
+ ).showSaveFilePicker;
196
+ const fileHandle = await showSaveFilePicker({
197
+ suggestedName: filename,
198
+ types: [{ description: "SPARQL Query", accept: { "application/sparql-query": [".rq", ".sparql"] } }],
199
+ });
200
+ const writable = await fileHandle.createWritable();
201
+ await writable.write(blob);
202
+ await writable.close();
203
+ return;
204
+ } catch (e: any) {
205
+ // User cancelled the picker – abort silently
206
+ if (e?.name === "AbortError") return;
207
+ // Other errors fall through to the legacy download below
208
+ }
209
+ }
210
+
211
+ // Fallback for browsers without File System Access API
212
+ const url = URL.createObjectURL(blob);
213
+ const a = document.createElement("a");
214
+ a.href = url;
215
+ a.download = filename;
216
+ a.click();
217
+ URL.revokeObjectURL(url);
218
+ }
219
+
178
220
  public async saveManagedQueryOrSaveAsManagedQuery(): Promise<void> {
179
221
  const meta = this.getManagedQueryMetadata();
180
222
  if (!meta) {
@@ -535,7 +577,7 @@ export class Tab extends EventEmitter {
535
577
  if (!this.controlBarEl) return;
536
578
 
537
579
  this.orientationToggleButton = document.createElement("button");
538
- this.orientationToggleButton.className = "tabContextButton orientationToggle";
580
+ this.orientationToggleButton.className = "tabContextButton orientationToggle desktopOnly";
539
581
  this.orientationToggleButton.setAttribute("aria-label", "Toggle layout orientation");
540
582
  this.orientationToggleButton.title = "Toggle layout orientation";
541
583
 
@@ -615,6 +657,10 @@ export class Tab extends EventEmitter {
615
657
  return this.yasr;
616
658
  }
617
659
 
660
+ public getCurrentOrientation() {
661
+ return this.currentOrientation;
662
+ }
663
+
618
664
  private initTabSettingsMenu() {
619
665
  if (!this.controlBarEl) throw new Error("Need to initialize wrapper elements before drawing tab settings");
620
666
  this.settingsModal = new TabSettingsModal(this, this.controlBarEl);
@@ -1302,6 +1348,11 @@ export class Tab extends EventEmitter {
1302
1348
  void this.saveManagedQueryOrSaveAsManagedQuery();
1303
1349
  });
1304
1350
 
1351
+ // Hook up download as .rq file
1352
+ this.yasqe.on("downloadRqFile", () => {
1353
+ void this.downloadAsRqFile();
1354
+ });
1355
+
1305
1356
  // Show/hide save button based on workspace configuration
1306
1357
  this.updateSaveButtonVisibility();
1307
1358
 
@@ -1539,6 +1590,7 @@ WHERE {
1539
1590
  {
1540
1591
  customQuery: query,
1541
1592
  customAccept: "text/turtle",
1593
+ silent: true,
1542
1594
  },
1543
1595
  );
1544
1596
 
@@ -1627,6 +1679,16 @@ WHERE {
1627
1679
  // Add default renderers to the end, to give our custom ones priority.
1628
1680
  ...(Yasr.defaults.errorRenderers || []),
1629
1681
  ],
1682
+ executeQuery: async (query: string, options?: YasrPluginQueryOptions) => {
1683
+ if (!this.yasqe) throw new Error("No YASQE instance available");
1684
+ const response = await Yasqe.Sparql.executeQuery(this.yasqe, undefined, {
1685
+ customQuery: query,
1686
+ customAccept: options?.acceptHeader,
1687
+ signal: options?.signal,
1688
+ silent: true,
1689
+ });
1690
+ return response;
1691
+ },
1630
1692
  };
1631
1693
  // Allow getDownloadFilName to be overwritten by the global config
1632
1694
  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";