@matdata/yasgui 5.10.0 → 5.11.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 (133) hide show
  1. package/README.md +6 -2
  2. package/build/ts/src/PersistentConfig.d.ts +10 -0
  3. package/build/ts/src/PersistentConfig.js +40 -0
  4. package/build/ts/src/PersistentConfig.js.map +1 -1
  5. package/build/ts/src/Tab.d.ts +17 -0
  6. package/build/ts/src/Tab.js +372 -15
  7. package/build/ts/src/Tab.js.map +1 -1
  8. package/build/ts/src/TabContextMenu.d.ts +1 -0
  9. package/build/ts/src/TabContextMenu.js +17 -0
  10. package/build/ts/src/TabContextMenu.js.map +1 -1
  11. package/build/ts/src/TabElements.d.ts +3 -0
  12. package/build/ts/src/TabElements.js +97 -28
  13. package/build/ts/src/TabElements.js.map +1 -1
  14. package/build/ts/src/TabSettingsModal.d.ts +2 -0
  15. package/build/ts/src/TabSettingsModal.js +44 -19
  16. package/build/ts/src/TabSettingsModal.js.map +1 -1
  17. package/build/ts/src/index.d.ts +3 -0
  18. package/build/ts/src/index.js +4 -0
  19. package/build/ts/src/index.js.map +1 -1
  20. package/build/ts/src/queryManagement/QueryBrowser.d.ts +64 -0
  21. package/build/ts/src/queryManagement/QueryBrowser.js +914 -0
  22. package/build/ts/src/queryManagement/QueryBrowser.js.map +1 -0
  23. package/build/ts/src/queryManagement/SaveManagedQueryModal.d.ts +55 -0
  24. package/build/ts/src/queryManagement/SaveManagedQueryModal.js +451 -0
  25. package/build/ts/src/queryManagement/SaveManagedQueryModal.js.map +1 -0
  26. package/build/ts/src/queryManagement/WorkspaceSettingsForm.d.ts +16 -0
  27. package/build/ts/src/queryManagement/WorkspaceSettingsForm.js +452 -0
  28. package/build/ts/src/queryManagement/WorkspaceSettingsForm.js.map +1 -0
  29. package/build/ts/src/queryManagement/backends/BaseGitProviderClient.d.ts +19 -0
  30. package/build/ts/src/queryManagement/backends/BaseGitProviderClient.js +77 -0
  31. package/build/ts/src/queryManagement/backends/BaseGitProviderClient.js.map +1 -0
  32. package/build/ts/src/queryManagement/backends/BitbucketProviderClient.d.ts +16 -0
  33. package/build/ts/src/queryManagement/backends/BitbucketProviderClient.js +269 -0
  34. package/build/ts/src/queryManagement/backends/BitbucketProviderClient.js.map +1 -0
  35. package/build/ts/src/queryManagement/backends/GitWorkspaceBackend.d.ts +26 -0
  36. package/build/ts/src/queryManagement/backends/GitWorkspaceBackend.js +93 -0
  37. package/build/ts/src/queryManagement/backends/GitWorkspaceBackend.js.map +1 -0
  38. package/build/ts/src/queryManagement/backends/GiteaProviderClient.d.ts +14 -0
  39. package/build/ts/src/queryManagement/backends/GiteaProviderClient.js +244 -0
  40. package/build/ts/src/queryManagement/backends/GiteaProviderClient.js.map +1 -0
  41. package/build/ts/src/queryManagement/backends/GithubProviderClient.d.ts +14 -0
  42. package/build/ts/src/queryManagement/backends/GithubProviderClient.js +252 -0
  43. package/build/ts/src/queryManagement/backends/GithubProviderClient.js.map +1 -0
  44. package/build/ts/src/queryManagement/backends/GitlabProviderClient.d.ts +16 -0
  45. package/build/ts/src/queryManagement/backends/GitlabProviderClient.js +246 -0
  46. package/build/ts/src/queryManagement/backends/GitlabProviderClient.js.map +1 -0
  47. package/build/ts/src/queryManagement/backends/InMemoryWorkspaceBackend.d.ts +21 -0
  48. package/build/ts/src/queryManagement/backends/InMemoryWorkspaceBackend.js +175 -0
  49. package/build/ts/src/queryManagement/backends/InMemoryWorkspaceBackend.js.map +1 -0
  50. package/build/ts/src/queryManagement/backends/SparqlWorkspaceBackend.d.ts +28 -0
  51. package/build/ts/src/queryManagement/backends/SparqlWorkspaceBackend.js +687 -0
  52. package/build/ts/src/queryManagement/backends/SparqlWorkspaceBackend.js.map +1 -0
  53. package/build/ts/src/queryManagement/backends/WorkspaceBackend.d.ts +15 -0
  54. package/build/ts/src/queryManagement/backends/WorkspaceBackend.js +2 -0
  55. package/build/ts/src/queryManagement/backends/WorkspaceBackend.js.map +1 -0
  56. package/build/ts/src/queryManagement/backends/errors.d.ts +7 -0
  57. package/build/ts/src/queryManagement/backends/errors.js +18 -0
  58. package/build/ts/src/queryManagement/backends/errors.js.map +1 -0
  59. package/build/ts/src/queryManagement/backends/getWorkspaceBackend.d.ts +8 -0
  60. package/build/ts/src/queryManagement/backends/getWorkspaceBackend.js +114 -0
  61. package/build/ts/src/queryManagement/backends/getWorkspaceBackend.js.map +1 -0
  62. package/build/ts/src/queryManagement/backends/gitRemote.d.ts +5 -0
  63. package/build/ts/src/queryManagement/backends/gitRemote.js +40 -0
  64. package/build/ts/src/queryManagement/backends/gitRemote.js.map +1 -0
  65. package/build/ts/src/queryManagement/browserFilter.d.ts +2 -0
  66. package/build/ts/src/queryManagement/browserFilter.js +7 -0
  67. package/build/ts/src/queryManagement/browserFilter.js.map +1 -0
  68. package/build/ts/src/queryManagement/index.d.ts +13 -0
  69. package/build/ts/src/queryManagement/index.js +14 -0
  70. package/build/ts/src/queryManagement/index.js.map +1 -0
  71. package/build/ts/src/queryManagement/normalizeQueryFilename.d.ts +1 -0
  72. package/build/ts/src/queryManagement/normalizeQueryFilename.js +10 -0
  73. package/build/ts/src/queryManagement/normalizeQueryFilename.js.map +1 -0
  74. package/build/ts/src/queryManagement/openManagedQuery.d.ts +15 -0
  75. package/build/ts/src/queryManagement/openManagedQuery.js +27 -0
  76. package/build/ts/src/queryManagement/openManagedQuery.js.map +1 -0
  77. package/build/ts/src/queryManagement/saveManagedQuery.d.ts +20 -0
  78. package/build/ts/src/queryManagement/saveManagedQuery.js +109 -0
  79. package/build/ts/src/queryManagement/saveManagedQuery.js.map +1 -0
  80. package/build/ts/src/queryManagement/textHash.d.ts +2 -0
  81. package/build/ts/src/queryManagement/textHash.js +13 -0
  82. package/build/ts/src/queryManagement/textHash.js.map +1 -0
  83. package/build/ts/src/queryManagement/types.d.ts +76 -0
  84. package/build/ts/src/queryManagement/types.js +2 -0
  85. package/build/ts/src/queryManagement/types.js.map +1 -0
  86. package/build/ts/src/queryManagement/validateWorkspaceConfig.d.ts +6 -0
  87. package/build/ts/src/queryManagement/validateWorkspaceConfig.js +33 -0
  88. package/build/ts/src/queryManagement/validateWorkspaceConfig.js.map +1 -0
  89. package/build/ts/src/version.d.ts +1 -1
  90. package/build/ts/src/version.js +1 -1
  91. package/build/yasgui.min.css +10 -1
  92. package/build/yasgui.min.css.map +3 -3
  93. package/build/yasgui.min.js +398 -172
  94. package/build/yasgui.min.js.map +4 -4
  95. package/package.json +1 -1
  96. package/src/PersistentConfig.ts +61 -0
  97. package/src/Tab.ts +431 -20
  98. package/src/TabContextMenu.ts +10 -0
  99. package/src/TabElements.scss +46 -7
  100. package/src/TabElements.ts +95 -27
  101. package/src/TabSettingsModal.scss +102 -5
  102. package/src/TabSettingsModal.ts +48 -25
  103. package/src/endpointSelect.scss +2 -3
  104. package/src/index.scss +4 -0
  105. package/src/index.ts +7 -0
  106. package/src/queryManagement/QueryBrowser.scss +418 -0
  107. package/src/queryManagement/QueryBrowser.ts +1079 -0
  108. package/src/queryManagement/SaveManagedQueryModal.scss +245 -0
  109. package/src/queryManagement/SaveManagedQueryModal.ts +554 -0
  110. package/src/queryManagement/WorkspaceSettingsForm.ts +546 -0
  111. package/src/queryManagement/backends/BaseGitProviderClient.ts +124 -0
  112. package/src/queryManagement/backends/BitbucketProviderClient.ts +403 -0
  113. package/src/queryManagement/backends/GitWorkspaceBackend.ts +96 -0
  114. package/src/queryManagement/backends/GiteaProviderClient.ts +316 -0
  115. package/src/queryManagement/backends/GithubProviderClient.ts +319 -0
  116. package/src/queryManagement/backends/GitlabProviderClient.ts +327 -0
  117. package/src/queryManagement/backends/InMemoryWorkspaceBackend.ts +175 -0
  118. package/src/queryManagement/backends/SparqlWorkspaceBackend.ts +729 -0
  119. package/src/queryManagement/backends/WorkspaceBackend.ts +41 -0
  120. package/src/queryManagement/backends/errors.ts +28 -0
  121. package/src/queryManagement/backends/getWorkspaceBackend.ts +132 -0
  122. package/src/queryManagement/backends/gitRemote.ts +54 -0
  123. package/src/queryManagement/browserFilter.ts +8 -0
  124. package/src/queryManagement/index.ts +15 -0
  125. package/src/queryManagement/normalizeQueryFilename.ts +8 -0
  126. package/src/queryManagement/openManagedQuery.ts +31 -0
  127. package/src/queryManagement/saveManagedQuery.ts +135 -0
  128. package/src/queryManagement/textHash.ts +15 -0
  129. package/src/queryManagement/types.ts +85 -0
  130. package/src/queryManagement/validateWorkspaceConfig.ts +40 -0
  131. package/src/tab.scss +4 -14
  132. package/src/themes.scss +14 -23
  133. 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.10.0",
4
+ "version": "5.11.0",
5
5
  "main": "build/yasgui.min.js",
6
6
  "types": "build/ts/src/index.d.ts",
7
7
  "license": "MIT",
@@ -1,6 +1,7 @@
1
1
  import { Storage as YStorage } from "@matdata/yasgui-utils";
2
2
  import Yasgui, { EndpointButton, EndpointConfig } from "./";
3
3
  import * as Tab from "./Tab";
4
+ import type { WorkspaceConfig } from "./queryManagement/types";
4
5
  export var storageNamespace = "triply";
5
6
  export interface PersistedJson {
6
7
  endpointHistory: string[];
@@ -12,6 +13,8 @@ export interface PersistedJson {
12
13
  autoCaptureEnabled?: boolean;
13
14
  customEndpointButtons?: EndpointButton[]; // Legacy, kept for backwards compatibility
14
15
  endpointConfigs?: EndpointConfig[]; // New endpoint-based storage with auth
16
+ workspaces?: WorkspaceConfig[];
17
+ activeWorkspaceId?: string;
15
18
  theme?: "light" | "dark";
16
19
  orientation?: "vertical" | "horizontal";
17
20
  showSnippetsBar?: boolean;
@@ -28,6 +31,8 @@ function getDefaults(): PersistedJson {
28
31
  autoCaptureEnabled: true,
29
32
  customEndpointButtons: [],
30
33
  endpointConfigs: [],
34
+ workspaces: [],
35
+ activeWorkspaceId: undefined,
31
36
  };
32
37
  }
33
38
 
@@ -216,6 +221,62 @@ export default class PersistentConfig {
216
221
  const filtered = configs.filter((c) => c.endpoint !== endpoint);
217
222
  this.setEndpointConfigs(filtered);
218
223
  }
224
+
225
+ public getWorkspaces(): WorkspaceConfig[] {
226
+ return this.persistedJson.workspaces || [];
227
+ }
228
+
229
+ public getWorkspace(workspaceId: string): WorkspaceConfig | undefined {
230
+ return this.getWorkspaces().find((w) => w.id === workspaceId);
231
+ }
232
+
233
+ public setWorkspaces(workspaces: WorkspaceConfig[]) {
234
+ this.persistedJson.workspaces = workspaces;
235
+ this.toStorage();
236
+ }
237
+
238
+ public addOrUpdateWorkspace(workspace: WorkspaceConfig) {
239
+ const workspaces = this.getWorkspaces();
240
+ const existingIndex = workspaces.findIndex((w) => w.id === workspace.id);
241
+ const now = new Date().toISOString();
242
+
243
+ if (existingIndex >= 0) {
244
+ const existing = workspaces[existingIndex];
245
+ workspaces[existingIndex] = {
246
+ ...existing,
247
+ ...workspace,
248
+ createdAt: existing.createdAt || workspace.createdAt || now,
249
+ updatedAt: now,
250
+ };
251
+ } else {
252
+ workspaces.push({
253
+ ...workspace,
254
+ createdAt: workspace.createdAt || now,
255
+ updatedAt: now,
256
+ });
257
+ }
258
+
259
+ this.setWorkspaces(workspaces);
260
+ }
261
+
262
+ public deleteWorkspace(workspaceId: string) {
263
+ const workspaces = this.getWorkspaces();
264
+ const filtered = workspaces.filter((w) => w.id !== workspaceId);
265
+ this.setWorkspaces(filtered);
266
+
267
+ if (this.getActiveWorkspaceId() === workspaceId) {
268
+ this.setActiveWorkspaceId(undefined);
269
+ }
270
+ }
271
+
272
+ public getActiveWorkspaceId(): string | undefined {
273
+ return this.persistedJson.activeWorkspaceId;
274
+ }
275
+
276
+ public setActiveWorkspaceId(workspaceId: string | undefined) {
277
+ this.persistedJson.activeWorkspaceId = workspaceId;
278
+ this.toStorage();
279
+ }
219
280
  public static clear() {
220
281
  const storage = new YStorage(storageNamespace);
221
282
  storage.removeNamespace();
package/src/Tab.ts CHANGED
@@ -10,24 +10,13 @@ import EndpointSelect from "./endpointSelect";
10
10
  import "./tab.scss";
11
11
  import { getRandomId, default as Yasgui, YasguiRequestConfig } from "./";
12
12
  import * as OAuth2Utils from "./OAuth2Utils";
13
-
14
- // Layout orientation toggle icons
15
- const HORIZONTAL_LAYOUT_ICON = `<svg viewBox="0 0 24 24">
16
- <rect x="2" y="4" width="9" height="16" stroke="currentColor" stroke-width="2" fill="none"/>
17
- <rect x="13" y="4" width="9" height="16" stroke="currentColor" stroke-width="2" fill="none"/>
18
- </svg>`;
19
-
20
- const VERTICAL_LAYOUT_ICON = `<svg viewBox="0 0 24 24">
21
- <rect x="2" y="2" width="20" height="8" stroke="currentColor" stroke-width="2" fill="none"/>
22
- <rect x="2" y="12" width="20" height="10" stroke="currentColor" stroke-width="2" fill="none"/>
23
- </svg>`;
24
-
25
- // Overflow dropdown icon (three horizontal dots / ellipsis menu)
26
- const OVERFLOW_ICON = `<svg viewBox="0 0 24 24" fill="currentColor">
27
- <circle cx="5" cy="12" r="2"/>
28
- <circle cx="12" cy="12" r="2"/>
29
- <circle cx="19" cy="12" r="2"/>
30
- </svg>`;
13
+ import type { ManagedTabMetadata } from "./queryManagement/types";
14
+ import { hashQueryText } from "./queryManagement/textHash";
15
+ import SaveManagedQueryModal from "./queryManagement/SaveManagedQueryModal";
16
+ import { saveManagedQuery } from "./queryManagement/saveManagedQuery";
17
+ import { getWorkspaceBackend } from "./queryManagement/backends/getWorkspaceBackend";
18
+ import { asWorkspaceBackendError } from "./queryManagement/backends/errors";
19
+ import { normalizeQueryFilename } from "./queryManagement/normalizeQueryFilename";
31
20
 
32
21
  export interface PersistedJsonYasr extends YasrPersistentConfig {
33
22
  responseSummary: Parser.ResponseSummary;
@@ -46,6 +35,7 @@ export interface PersistedJson {
46
35
  };
47
36
  requestConfig: YasguiRequestConfig;
48
37
  orientation?: "vertical" | "horizontal";
38
+ managedQuery?: ManagedTabMetadata;
49
39
  }
50
40
 
51
41
  export interface Tab {
@@ -112,6 +102,228 @@ export class Tab extends EventEmitter {
112
102
  return this.persistentJson.id;
113
103
  }
114
104
 
105
+ public getManagedQueryMetadata(): ManagedTabMetadata | undefined {
106
+ return this.persistentJson.managedQuery;
107
+ }
108
+
109
+ public setManagedQueryMetadata(metadata: ManagedTabMetadata | undefined) {
110
+ if (metadata) {
111
+ this.persistentJson.managedQuery = metadata;
112
+ } else {
113
+ delete this.persistentJson.managedQuery;
114
+ }
115
+ this.emit("change", this, this.persistentJson);
116
+ }
117
+
118
+ public isManagedQueryTab(): boolean {
119
+ return !!this.persistentJson.managedQuery;
120
+ }
121
+
122
+ public hasUnsavedManagedChanges(): boolean {
123
+ const meta = this.getManagedQueryMetadata();
124
+ if (!meta) return false;
125
+ if (!meta.lastSavedTextHash) return false;
126
+
127
+ try {
128
+ const current = this.yasqe ? this.yasqe.getValue() : this.persistentJson.yasqe.value;
129
+ const currentHash = hashQueryText(current);
130
+ return currentHash !== meta.lastSavedTextHash;
131
+ } catch {
132
+ return false;
133
+ }
134
+ }
135
+
136
+ private getDefaultSaveModalValues(): { workspaceId?: string; folderPath?: string; filename?: string; name?: string } {
137
+ const meta = this.getManagedQueryMetadata();
138
+ if (!meta) return { name: this.name() };
139
+ if (meta.backendType !== "git") return { workspaceId: meta.workspaceId };
140
+ const path = (meta.queryRef as any)?.path as string | undefined;
141
+ if (!path) return { workspaceId: meta.workspaceId };
142
+
143
+ const parts = path.split("/");
144
+ const filename = parts.pop();
145
+ const folderPath = parts.join("/");
146
+
147
+ return {
148
+ workspaceId: meta.workspaceId,
149
+ folderPath,
150
+ filename,
151
+ name: this.name(),
152
+ };
153
+ }
154
+
155
+ private getManagedQueryIdFromMetadata(meta: ManagedTabMetadata): string | undefined {
156
+ if (meta.backendType === "git") return (meta.queryRef as any)?.path as string | undefined;
157
+ return (meta.queryRef as any)?.managedQueryIri as string | undefined;
158
+ }
159
+
160
+ private versionRefFromVersionTag(backendType: "git" | "sparql", versionTag: string | undefined) {
161
+ if (!versionTag) return undefined;
162
+ if (backendType === "git") return { commitSha: versionTag };
163
+ return { managedQueryVersionIri: versionTag };
164
+ }
165
+
166
+ private getQueryTextForSave(): string {
167
+ // Saving can be triggered while the tab exists but the editor isn't initialized yet.
168
+ // In that case fall back to the persisted tab value.
169
+ try {
170
+ if (this.yasqe) return this.yasqe.getValue();
171
+ } catch {
172
+ // ignore
173
+ }
174
+ return this.persistentJson.yasqe.value;
175
+ }
176
+
177
+ public async saveManagedQueryOrSaveAsManagedQuery(): Promise<void> {
178
+ const meta = this.getManagedQueryMetadata();
179
+ if (!meta) {
180
+ await this.saveAsManagedQuery();
181
+ return;
182
+ }
183
+
184
+ const queryId = this.getManagedQueryIdFromMetadata(meta);
185
+ if (!queryId) {
186
+ await this.saveAsManagedQuery();
187
+ return;
188
+ }
189
+
190
+ const workspace = this.yasgui.persistentConfig.getWorkspace(meta.workspaceId);
191
+ if (!workspace) {
192
+ window.alert("Selected workspace no longer exists");
193
+ return;
194
+ }
195
+
196
+ const backend = getWorkspaceBackend(workspace, { persistentConfig: this.yasgui.persistentConfig });
197
+
198
+ const expectedVersionTag = (() => {
199
+ if (!meta?.lastSavedVersionRef) return undefined;
200
+ if (meta.backendType === "git") return (meta.lastSavedVersionRef as any)?.commitSha;
201
+ return (meta.lastSavedVersionRef as any)?.managedQueryVersionIri;
202
+ })();
203
+
204
+ try {
205
+ await backend.writeQuery(queryId, this.getQueryTextForSave(), { expectedVersionTag });
206
+ } catch (e) {
207
+ const err = asWorkspaceBackendError(e);
208
+ if (err.code === "CONFLICT") {
209
+ if (meta.backendType === "git") {
210
+ // Best-effort self-heal: some providers use a file sha for optimistic concurrency.
211
+ // If our stored version tag is stale/incorrect but the remote content is unchanged,
212
+ // refresh the tag and retry once.
213
+ try {
214
+ const latest = await backend.readQuery(queryId);
215
+ const latestHash = hashQueryText(latest.queryText);
216
+ if (meta.lastSavedTextHash && meta.lastSavedTextHash === latestHash && latest.versionTag) {
217
+ await backend.writeQuery(queryId, this.getQueryTextForSave(), { expectedVersionTag: latest.versionTag });
218
+ } else {
219
+ window.alert(
220
+ "Save conflict. Resolve the conflict externally (e.g., pull/rebase/merge) and then try saving again.",
221
+ );
222
+ return;
223
+ }
224
+ } catch {
225
+ window.alert(
226
+ "Save conflict. Resolve the conflict externally (e.g., pull/rebase/merge) and then try saving again.",
227
+ );
228
+ return;
229
+ }
230
+ } else {
231
+ window.alert("Save conflict. Refresh the query and try again.");
232
+ return;
233
+ }
234
+ }
235
+ window.alert(err.message);
236
+ return;
237
+ }
238
+
239
+ const read = await backend.readQuery(queryId);
240
+ const lastSavedTextHash = hashQueryText(read.queryText);
241
+ const lastSavedVersionRef = this.versionRefFromVersionTag(meta.backendType, read.versionTag);
242
+ this.setManagedQueryMetadata({
243
+ ...meta,
244
+ lastSavedTextHash,
245
+ lastSavedVersionRef,
246
+ });
247
+
248
+ // Ensure Query Browser reflects the updated version/metadata.
249
+ this.yasgui.queryBrowser.invalidateAndRefresh(meta.workspaceId);
250
+ }
251
+
252
+ public async saveAsManagedQuery(): Promise<void> {
253
+ const modal = new SaveManagedQueryModal(this.yasgui);
254
+
255
+ const defaults = this.getDefaultSaveModalValues();
256
+ let result:
257
+ | {
258
+ workspaceId: string;
259
+ folderPath: string;
260
+ name: string;
261
+ filename: string;
262
+ message?: string;
263
+ }
264
+ | undefined;
265
+
266
+ try {
267
+ const modalDefaults: any = {
268
+ workspaceId: defaults.workspaceId,
269
+ folderPath: defaults.folderPath || "",
270
+ name: defaults.name || this.name(),
271
+ };
272
+ // Only provide a filename default when we have one.
273
+ // Otherwise the modal will derive a suggested filename from the provided name.
274
+ if (defaults.filename) modalDefaults.filename = defaults.filename;
275
+
276
+ result = await modal.show(modalDefaults);
277
+ } catch {
278
+ return;
279
+ }
280
+
281
+ const workspace = this.yasgui.persistentConfig.getWorkspace(result.workspaceId);
282
+ if (!workspace) {
283
+ window.alert("Selected workspace no longer exists");
284
+ return;
285
+ }
286
+
287
+ const backend = getWorkspaceBackend(workspace, { persistentConfig: this.yasgui.persistentConfig });
288
+ const meta = this.getManagedQueryMetadata();
289
+
290
+ const expectedVersionTag = (() => {
291
+ if (!meta?.lastSavedVersionRef) return undefined;
292
+ if (meta.backendType === "git") return (meta.lastSavedVersionRef as any)?.commitSha;
293
+ return (meta.lastSavedVersionRef as any)?.managedQueryVersionIri;
294
+ })();
295
+
296
+ try {
297
+ modal.notifySaveInProgress();
298
+ const { managedMetadata } = await saveManagedQuery({
299
+ backend,
300
+ backendType: workspace.type,
301
+ workspaceId: workspace.id,
302
+ workspaceIri: workspace.type === "sparql" ? workspace.workspaceIri : undefined,
303
+ folderPath: result.folderPath,
304
+ name: result.name,
305
+ filename: result.filename,
306
+ queryText: this.getQueryTextForSave(),
307
+ associatedEndpoint: workspace.type === "sparql" ? this.getEndpoint() : undefined,
308
+ message: result.message,
309
+ expectedVersionTag,
310
+ });
311
+
312
+ modal.notifySaveComplete();
313
+ this.setManagedQueryMetadata(managedMetadata);
314
+ if (result.name && result.name.trim()) {
315
+ this.setName(result.name.trim());
316
+ }
317
+
318
+ // Ensure the saved query shows up immediately in the Query Browser.
319
+ this.yasgui.queryBrowser.invalidateAndRefresh(workspace.id);
320
+ } catch (e) {
321
+ modal.notifySaveComplete();
322
+ const err = asWorkspaceBackendError(e);
323
+ window.alert(err.message);
324
+ }
325
+ }
326
+
115
327
  private draw() {
116
328
  if (this.rootEl) return; //aready drawn
117
329
  this.rootEl = document.createElement("div");
@@ -179,6 +391,19 @@ export class Tab extends EventEmitter {
179
391
  }
180
392
 
181
393
  private handleKeyDown = (event: KeyboardEvent) => {
394
+ if (event.defaultPrevented) return;
395
+
396
+ const saveModalOpen = !!document.querySelector(".saveManagedQueryModalOverlay.open");
397
+ if (!saveModalOpen) {
398
+ const isSaveShortcut =
399
+ (event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey && event.key.toLowerCase() === "s";
400
+ if (isSaveShortcut) {
401
+ event.preventDefault();
402
+ void this.saveManagedQueryOrSaveAsManagedQuery();
403
+ return;
404
+ }
405
+ }
406
+
182
407
  // F11 - Toggle Yasqe fullscreen
183
408
  if (event.key === "F11") {
184
409
  event.preventDefault();
@@ -236,6 +461,26 @@ export class Tab extends EventEmitter {
236
461
  }
237
462
 
238
463
  public close() {
464
+ if (this.isManagedQueryTab() && this.hasUnsavedManagedChanges()) {
465
+ const wantsSave = window.confirm("This managed query has unsaved changes. Save before closing?");
466
+ if (wantsSave) {
467
+ void this.saveManagedQueryOrSaveAsManagedQuery().then(() => {
468
+ // Only close if we actually saved (metadata updated so no longer dirty)
469
+ if (!this.hasUnsavedManagedChanges()) {
470
+ this.closeNow();
471
+ }
472
+ });
473
+ return;
474
+ }
475
+
476
+ const discard = window.confirm("Discard changes and close the tab?");
477
+ if (!discard) return;
478
+ }
479
+
480
+ this.closeNow();
481
+ }
482
+
483
+ private closeNow() {
239
484
  this.detachKeyboardListeners();
240
485
  if (this.yasqe) this.yasqe.abortQuery();
241
486
  if (this.yasgui.getTab() === this) {
@@ -306,8 +551,11 @@ export class Tab extends EventEmitter {
306
551
  if (!this.orientationToggleButton) return;
307
552
 
308
553
  // Show the icon for the layout we'll switch TO (not the current layout)
554
+ // fa-columns for horizontal (side-by-side), fa-grip-lines for vertical (stacked)
309
555
  this.orientationToggleButton.innerHTML =
310
- this.currentOrientation === "vertical" ? HORIZONTAL_LAYOUT_ICON : VERTICAL_LAYOUT_ICON;
556
+ this.currentOrientation === "vertical"
557
+ ? '<i class="fas fa-grip-lines-vertical"></i>'
558
+ : '<i class="fas fa-grip-lines"></i>';
311
559
  this.orientationToggleButton.title =
312
560
  this.currentOrientation === "vertical" ? "Switch to horizontal layout" : "Switch to vertical layout";
313
561
  }
@@ -483,7 +731,7 @@ export class Tab extends EventEmitter {
483
731
  if (!this.endpointOverflowButton) {
484
732
  this.endpointOverflowButton = document.createElement("button");
485
733
  addClass(this.endpointOverflowButton, "endpointOverflowBtn");
486
- this.endpointOverflowButton.innerHTML = OVERFLOW_ICON;
734
+ this.endpointOverflowButton.innerHTML = '<i class="fas fa-ellipsis-vertical"></i>';
487
735
  this.endpointOverflowButton.title = "More endpoints";
488
736
  this.endpointOverflowButton.setAttribute("aria-label", "More endpoint options");
489
737
  this.endpointOverflowButton.setAttribute("aria-haspopup", "true");
@@ -693,6 +941,111 @@ export class Tab extends EventEmitter {
693
941
  return this;
694
942
  }
695
943
 
944
+ private suggestManagedFilenameFromName(name: string): string {
945
+ const trimmed = name.trim();
946
+ const safe = trimmed.replace(/[\\/]/g, "-");
947
+ return normalizeQueryFilename(safe);
948
+ }
949
+
950
+ /**
951
+ * User-triggered tab rename.
952
+ * For managed queries, also renames the managed query entry in the Query Browser.
953
+ */
954
+ public async renameTab(newName: string): Promise<void> {
955
+ const nextName = newName.trim();
956
+ if (!nextName) return;
957
+ if (nextName === this.name()) return;
958
+
959
+ const meta = this.getManagedQueryMetadata();
960
+ if (!meta) {
961
+ this.setName(nextName);
962
+ return;
963
+ }
964
+
965
+ const workspace = this.yasgui.persistentConfig.getWorkspace(meta.workspaceId);
966
+ if (!workspace) {
967
+ window.alert("Selected workspace no longer exists");
968
+ return;
969
+ }
970
+
971
+ const backend = getWorkspaceBackend(workspace, { persistentConfig: this.yasgui.persistentConfig });
972
+
973
+ if (meta.backendType === "sparql") {
974
+ const queryId = this.getManagedQueryIdFromMetadata(meta);
975
+ if (!queryId) return;
976
+ if (!backend.renameQuery) {
977
+ window.alert("This workspace does not support renaming queries");
978
+ return;
979
+ }
980
+
981
+ try {
982
+ this.getTabListEl().setAsRenaming(true);
983
+ await backend.renameQuery(queryId, nextName);
984
+ this.setName(nextName);
985
+ this.yasgui.queryBrowser.invalidateAndRefresh(meta.workspaceId);
986
+ } catch (e) {
987
+ const err = asWorkspaceBackendError(e);
988
+ window.alert(err.message);
989
+ } finally {
990
+ this.getTabListEl().setAsRenaming(false);
991
+ }
992
+
993
+ return;
994
+ }
995
+
996
+ // Git: rename underlying file path so the Query Browser label changes.
997
+ const oldPath = (meta.queryRef as any)?.path as string | undefined;
998
+ if (!oldPath) {
999
+ this.setName(nextName);
1000
+ return;
1001
+ }
1002
+
1003
+ const parts = oldPath.split("/").filter(Boolean);
1004
+ const oldFilename = parts.pop() || oldPath;
1005
+ const folderPrefix = parts.join("/");
1006
+
1007
+ const newFilename = this.suggestManagedFilenameFromName(nextName);
1008
+ const newPath = folderPrefix ? `${folderPrefix}/${newFilename}` : newFilename;
1009
+
1010
+ if (newPath === oldPath) {
1011
+ // Nothing to rename on the backend; still allow tab label change.
1012
+ this.setName(nextName);
1013
+ return;
1014
+ }
1015
+
1016
+ try {
1017
+ this.getTabListEl().setAsRenaming(true);
1018
+ await backend.writeQuery(newPath, this.getQueryTextForSave(), {
1019
+ message: `Rename ${oldFilename} to ${newFilename}`,
1020
+ });
1021
+
1022
+ if (!backend.deleteQuery) {
1023
+ window.alert("This workspace does not support deleting queries, so the old file could not be removed.");
1024
+ } else {
1025
+ await backend.deleteQuery(oldPath);
1026
+ }
1027
+
1028
+ const read = await backend.readQuery(newPath);
1029
+ const lastSavedTextHash = hashQueryText(read.queryText);
1030
+ const lastSavedVersionRef = this.versionRefFromVersionTag("git", read.versionTag);
1031
+
1032
+ this.setManagedQueryMetadata({
1033
+ ...meta,
1034
+ queryRef: { ...(meta.queryRef as any), path: newPath },
1035
+ lastSavedTextHash,
1036
+ lastSavedVersionRef,
1037
+ });
1038
+
1039
+ this.setName(nextName);
1040
+ this.yasgui.queryBrowser.invalidateAndRefresh(meta.workspaceId);
1041
+ } catch (e) {
1042
+ const err = asWorkspaceBackendError(e);
1043
+ window.alert(err.message);
1044
+ } finally {
1045
+ this.getTabListEl().setAsRenaming(false);
1046
+ }
1047
+ }
1048
+
696
1049
  public hasResults() {
697
1050
  return !!this.yasr?.results;
698
1051
  }
@@ -917,6 +1270,20 @@ export class Tab extends EventEmitter {
917
1270
  return processedReqConfig as PlainRequestConfig;
918
1271
  },
919
1272
  };
1273
+
1274
+ // Override editor-level save shortcut.
1275
+ // Yasqe binds Ctrl+S to `yasqe.saveQuery()` (local storage) by default, which can prevent our document-level handler.
1276
+ const existingExtraKeys = (yasqeConf as any).extraKeys;
1277
+ const mergedExtraKeys: Record<string, any> =
1278
+ existingExtraKeys && typeof existingExtraKeys === "object" ? { ...existingExtraKeys } : {};
1279
+ mergedExtraKeys["Ctrl-S"] = () => {
1280
+ const saveModalOpen = !!document.querySelector(".saveManagedQueryModalOverlay.open");
1281
+ if (saveModalOpen) return;
1282
+ void this.saveManagedQueryOrSaveAsManagedQuery();
1283
+ };
1284
+ mergedExtraKeys["Cmd-S"] = mergedExtraKeys["Ctrl-S"];
1285
+ (yasqeConf as any).extraKeys = mergedExtraKeys;
1286
+
920
1287
  if (!yasqeConf.hintConfig) {
921
1288
  yasqeConf.hintConfig = {};
922
1289
  }
@@ -928,6 +1295,14 @@ export class Tab extends EventEmitter {
928
1295
  }
929
1296
  this.yasqe = new Yasqe(this.yasqeWrapperEl, yasqeConf);
930
1297
 
1298
+ // Hook up the save button to managed query save
1299
+ this.yasqe.on("saveManagedQuery", () => {
1300
+ void this.saveManagedQueryOrSaveAsManagedQuery();
1301
+ });
1302
+
1303
+ // Show/hide save button based on workspace configuration
1304
+ this.updateSaveButtonVisibility();
1305
+
931
1306
  this.yasqe.on("blur", this.handleYasqeBlur);
932
1307
  this.yasqe.on("query", this.handleYasqeQuery);
933
1308
  this.yasqe.on("queryBefore", this.handleYasqeQueryBefore);
@@ -943,6 +1318,42 @@ export class Tab extends EventEmitter {
943
1318
  this.attachYasqeMouseHandler();
944
1319
  }
945
1320
 
1321
+ private updateSaveButtonVisibility() {
1322
+ if (!this.yasqe) return;
1323
+ const workspaces = this.yasgui.persistentConfig.getWorkspaces();
1324
+ const hasWorkspaces = workspaces && workspaces.length > 0;
1325
+ this.yasqe.setSaveButtonVisible(hasWorkspaces);
1326
+ }
1327
+
1328
+ private initSaveManagedQueryIcon() {
1329
+ if (!this.yasqe) return;
1330
+
1331
+ const wrapper = this.yasqe.getWrapperElement();
1332
+ const buttons = wrapper?.querySelector(".yasqe_buttons");
1333
+ if (!buttons) return;
1334
+
1335
+ // Avoid duplicates if Yasqe ever re-renders
1336
+ if (buttons.querySelector(".yasqe_saveManagedQueryButton")) return;
1337
+
1338
+ const queryBtn = buttons.querySelector(".yasqe_queryButton");
1339
+ if (!queryBtn) return;
1340
+
1341
+ const saveBtn = document.createElement("button");
1342
+ saveBtn.type = "button";
1343
+ saveBtn.className = "yasqe_saveManagedQueryButton";
1344
+ saveBtn.title = "Save managed query";
1345
+ saveBtn.setAttribute("aria-label", "Save managed query");
1346
+ saveBtn.innerHTML = '<i class="fas fa-save" aria-hidden="true"></i>';
1347
+
1348
+ saveBtn.addEventListener("click", (e) => {
1349
+ e.preventDefault();
1350
+ e.stopPropagation();
1351
+ void this.saveManagedQueryOrSaveAsManagedQuery();
1352
+ });
1353
+
1354
+ buttons.insertBefore(saveBtn, queryBtn);
1355
+ }
1356
+
946
1357
  private destroyYasqe() {
947
1358
  // As Yasqe extends of CM instead of eventEmitter, it doesn't expose the removeAllListeners function, so we should unregister all events manually
948
1359
  this.yasqe?.off("blur", this.handleYasqeBlur);
@@ -15,6 +15,7 @@ export default class TabContextMenu {
15
15
  private newTabEl!: HTMLElement;
16
16
  private renameTabEl!: HTMLElement;
17
17
  private copyTabEl!: HTMLElement;
18
+ private saveManagedQueryEl!: HTMLElement;
18
19
  private closeTabEl!: HTMLElement;
19
20
  private closeOtherTabsEl!: HTMLElement;
20
21
  private reOpenOldTab!: HTMLElement;
@@ -49,6 +50,8 @@ export default class TabContextMenu {
49
50
 
50
51
  this.copyTabEl = this.getMenuItemEl("Copy Tab");
51
52
 
53
+ this.saveManagedQueryEl = this.getMenuItemEl("Save as managed query");
54
+
52
55
  this.closeTabEl = this.getMenuItemEl("Close Tab");
53
56
 
54
57
  this.closeOtherTabsEl = this.getMenuItemEl("Close other tabs");
@@ -59,6 +62,7 @@ export default class TabContextMenu {
59
62
  dropDownList.appendChild(this.newTabEl);
60
63
  dropDownList.appendChild(this.renameTabEl);
61
64
  dropDownList.appendChild(this.copyTabEl);
65
+ dropDownList.appendChild(this.saveManagedQueryEl);
62
66
  // Add divider
63
67
  dropDownList.appendChild(document.createElement("hr"));
64
68
  dropDownList.appendChild(this.closeTabEl);
@@ -105,6 +109,12 @@ export default class TabContextMenu {
105
109
  this.yasgui.addTab(true, config);
106
110
  };
107
111
 
112
+ this.saveManagedQueryEl.onclick = async () => {
113
+ if (!tab) return;
114
+ await tab.saveManagedQueryOrSaveAsManagedQuery();
115
+ this.closeConfigMenu();
116
+ };
117
+
108
118
  // Close tab functionality
109
119
  this.closeTabEl.onclick = () => tab?.close();
110
120