@matdata/yasgui 5.14.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 (60) hide show
  1. package/build/ts/src/Tab.d.ts +2 -0
  2. package/build/ts/src/Tab.js +67 -5
  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 +9 -0
  10. package/build/ts/src/TabSettingsModal.js +114 -4
  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 +7 -0
  17. package/build/ts/src/queryManagement/SaveManagedQueryModal.js +82 -4
  18. package/build/ts/src/queryManagement/SaveManagedQueryModal.js.map +1 -1
  19. package/build/ts/src/queryManagement/WorkspaceSettingsForm.d.ts +1 -0
  20. package/build/ts/src/queryManagement/WorkspaceSettingsForm.js +185 -8
  21. package/build/ts/src/queryManagement/WorkspaceSettingsForm.js.map +1 -1
  22. package/build/ts/src/queryManagement/backends/GitWorkspaceBackend.d.ts +1 -0
  23. package/build/ts/src/queryManagement/backends/GitWorkspaceBackend.js +18 -0
  24. package/build/ts/src/queryManagement/backends/GitWorkspaceBackend.js.map +1 -1
  25. package/build/ts/src/queryManagement/backends/InMemoryWorkspaceBackend.d.ts +1 -0
  26. package/build/ts/src/queryManagement/backends/InMemoryWorkspaceBackend.js +17 -0
  27. package/build/ts/src/queryManagement/backends/InMemoryWorkspaceBackend.js.map +1 -1
  28. package/build/ts/src/queryManagement/backends/SparqlWorkspaceBackend.d.ts +1 -0
  29. package/build/ts/src/queryManagement/backends/SparqlWorkspaceBackend.js +61 -0
  30. package/build/ts/src/queryManagement/backends/SparqlWorkspaceBackend.js.map +1 -1
  31. package/build/ts/src/queryManagement/backends/WorkspaceBackend.d.ts +1 -0
  32. package/build/ts/src/urlUtils.d.ts +1 -0
  33. package/build/ts/src/urlUtils.js +21 -0
  34. package/build/ts/src/urlUtils.js.map +1 -0
  35. package/build/ts/src/version.d.ts +1 -1
  36. package/build/ts/src/version.js +1 -1
  37. package/build/yasgui.min.css +1 -1
  38. package/build/yasgui.min.css.map +3 -3
  39. package/build/yasgui.min.js +245 -213
  40. package/build/yasgui.min.js.map +4 -4
  41. package/package.json +1 -1
  42. package/src/Tab.ts +83 -5
  43. package/src/TabContextMenu.ts +15 -5
  44. package/src/TabElements.scss +17 -0
  45. package/src/TabElements.ts +17 -3
  46. package/src/TabSettingsModal.scss +73 -0
  47. package/src/TabSettingsModal.ts +152 -6
  48. package/src/endpointSelect.scss +0 -22
  49. package/src/endpointSelect.ts +1 -1
  50. package/src/queryManagement/QueryBrowser.ts +72 -0
  51. package/src/queryManagement/SaveManagedQueryModal.ts +102 -4
  52. package/src/queryManagement/WorkspaceSettingsForm.ts +215 -8
  53. package/src/queryManagement/backends/GitWorkspaceBackend.ts +19 -0
  54. package/src/queryManagement/backends/InMemoryWorkspaceBackend.ts +17 -0
  55. package/src/queryManagement/backends/SparqlWorkspaceBackend.ts +69 -0
  56. package/src/queryManagement/backends/WorkspaceBackend.ts +6 -0
  57. package/src/tab.scss +1 -0
  58. package/src/themes.scss +0 -10
  59. package/src/urlUtils.ts +40 -0
  60. package/src/version.ts +1 -1
@@ -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";
@@ -41,13 +41,18 @@ export default class SaveManagedQueryModal {
41
41
  private saveBtn!: HTMLButtonElement;
42
42
  private cancelBtn!: HTMLButtonElement;
43
43
  private saveBtnOriginalText = "Save";
44
+ private titleEl!: HTMLHeadingElement;
45
+ private workspaceRowEl!: HTMLDivElement;
44
46
 
45
47
  private filenameTouched = false;
46
48
  private folderPickerOpen = false;
47
49
  private folderBrowsePath = "";
50
+ private mouseDownOnOverlay = false;
48
51
 
49
52
  private resolve?: (value: SaveManagedQueryModalResult) => void;
50
53
  private reject?: (reason?: unknown) => void;
54
+ private moveResolve?: (value: string | undefined) => void;
55
+ private isMoveMode = false;
51
56
 
52
57
  constructor(yasgui: Yasgui) {
53
58
  this.yasgui = yasgui;
@@ -64,6 +69,7 @@ export default class SaveManagedQueryModal {
64
69
 
65
70
  const titleEl = document.createElement("h2");
66
71
  titleEl.textContent = "Save as managed query";
72
+ this.titleEl = titleEl;
67
73
 
68
74
  const closeBtn = document.createElement("button");
69
75
  closeBtn.type = "button";
@@ -202,6 +208,7 @@ export default class SaveManagedQueryModal {
202
208
  this.messageEl.setAttribute("aria-label", "Save message");
203
209
 
204
210
  const workspaceRow = this.row("Workspace", this.workspaceSelectEl);
211
+ this.workspaceRowEl = workspaceRow;
205
212
  const folderRow = this.folderRow();
206
213
  this.nameRowEl = this.row("Name", this.nameEl);
207
214
  this.filenameRowEl = this.row("Filename", this.filenameEl);
@@ -247,7 +254,22 @@ export default class SaveManagedQueryModal {
247
254
 
248
255
  this.overlayEl.appendChild(this.modalEl);
249
256
 
250
- this.overlayEl.addEventListener("click", () => this.cancel());
257
+ // Track mousedown on overlay to distinguish from text selection that moves outside
258
+ this.overlayEl.addEventListener("mousedown", (e) => {
259
+ // Only mark as overlay mousedown if the target is the overlay itself (not modal content)
260
+ if (e.target === this.overlayEl) {
261
+ this.mouseDownOnOverlay = true;
262
+ }
263
+ });
264
+
265
+ // Only close if mousedown also happened on overlay (not during text selection)
266
+ this.overlayEl.addEventListener("mouseup", (e) => {
267
+ if (e.target === this.overlayEl && this.mouseDownOnOverlay) {
268
+ this.cancel();
269
+ }
270
+ this.mouseDownOnOverlay = false;
271
+ });
272
+
251
273
  document.addEventListener("keydown", (e) => {
252
274
  if (!this.isOpen()) return;
253
275
  if (e.key === "Escape") {
@@ -324,11 +346,27 @@ export default class SaveManagedQueryModal {
324
346
  }
325
347
 
326
348
  private cancel() {
349
+ this.mouseDownOnOverlay = false;
327
350
  this.close();
328
351
  this.overlayEl.remove();
329
- this.reject?.(new Error("cancelled"));
330
- this.resolve = undefined;
331
- this.reject = undefined;
352
+ if (this.isMoveMode) {
353
+ this.resetMoveMode();
354
+ const resolve = this.moveResolve;
355
+ this.moveResolve = undefined;
356
+ resolve?.(undefined);
357
+ } else {
358
+ this.reject?.(new Error("cancelled"));
359
+ this.resolve = undefined;
360
+ this.reject = undefined;
361
+ }
362
+ }
363
+
364
+ private resetMoveMode() {
365
+ this.isMoveMode = false;
366
+ this.titleEl.textContent = "Save as managed query";
367
+ this.saveBtn.textContent = "Save";
368
+ this.saveBtnOriginalText = "Save";
369
+ this.workspaceRowEl.style.display = "";
332
370
  }
333
371
 
334
372
  private setLoading(isLoading: boolean) {
@@ -354,6 +392,18 @@ export default class SaveManagedQueryModal {
354
392
  }
355
393
 
356
394
  private submit() {
395
+ if (this.isMoveMode) {
396
+ const folderPath = this.folderPathEl.value.trim();
397
+ this.mouseDownOnOverlay = false;
398
+ this.close();
399
+ this.overlayEl.remove();
400
+ this.resetMoveMode();
401
+ const resolve = this.moveResolve;
402
+ this.moveResolve = undefined;
403
+ resolve?.(folderPath);
404
+ return;
405
+ }
406
+
357
407
  const workspaceId = this.workspaceSelectEl.value;
358
408
  const name = this.nameEl.value.trim();
359
409
  const filename = this.filenameEl.value.trim();
@@ -399,6 +449,7 @@ export default class SaveManagedQueryModal {
399
449
  }
400
450
  }
401
451
 
452
+ this.mouseDownOnOverlay = false;
402
453
  this.close();
403
454
  this.overlayEl.remove();
404
455
 
@@ -454,6 +505,7 @@ export default class SaveManagedQueryModal {
454
505
  this.folderBrowsePath = this.folderPathEl.value.trim();
455
506
  this.folderPickerErrorEl.textContent = "";
456
507
  this.folderPickerListEl.innerHTML = "";
508
+ this.mouseDownOnOverlay = false;
457
509
 
458
510
  document.body.appendChild(this.overlayEl);
459
511
  this.open();
@@ -551,4 +603,50 @@ export default class SaveManagedQueryModal {
551
603
  this.newFolderNameEl.value = "";
552
604
  void this.refreshFolderPicker();
553
605
  }
606
+
607
+ /**
608
+ * Show a simplified modal containing only the folder picker, for moving an existing query.
609
+ * Resolves with the selected folder path (empty string = root), or `undefined` if cancelled.
610
+ */
611
+ public async showFolderPickerOnly(workspaceId: string, currentFolderPath: string): Promise<string | undefined> {
612
+ const workspaces = this.yasgui.persistentConfig.getWorkspaces();
613
+
614
+ this.workspaceSelectEl.innerHTML = "";
615
+ for (const w of workspaces) {
616
+ const opt = document.createElement("option");
617
+ opt.value = w.id;
618
+ opt.textContent = w.label;
619
+ this.workspaceSelectEl.appendChild(opt);
620
+ }
621
+ this.workspaceSelectEl.value = workspaceId;
622
+
623
+ // Hide rows not needed for a move operation.
624
+ this.workspaceRowEl.style.display = "none";
625
+ this.nameRowEl.style.display = "none";
626
+ this.filenameRowEl.style.display = "none";
627
+ this.messageRowEl.style.display = "none";
628
+
629
+ this.folderPathEl.value = currentFolderPath;
630
+ this.filenameTouched = false;
631
+ this.folderPickerOpen = false;
632
+ removeClass(this.folderPickerEl, "open");
633
+ this.folderBrowsePath = currentFolderPath;
634
+ this.folderPickerErrorEl.textContent = "";
635
+ this.folderPickerListEl.innerHTML = "";
636
+ this.mouseDownOnOverlay = false;
637
+
638
+ this.titleEl.textContent = "Move to folder";
639
+ this.saveBtn.textContent = "Move";
640
+ this.saveBtnOriginalText = "Move";
641
+
642
+ this.isMoveMode = true;
643
+
644
+ document.body.appendChild(this.overlayEl);
645
+ this.open();
646
+ this.folderPickerToggleEl.focus();
647
+
648
+ return new Promise<string | undefined>((resolve) => {
649
+ this.moveResolve = resolve;
650
+ });
651
+ }
554
652
  }
@@ -27,6 +27,67 @@ export class WorkspaceSettingsForm {
27
27
  this.options = options;
28
28
  }
29
29
 
30
+ private async fetchExistingWorkspaces(endpoint: string): Promise<string[]> {
31
+ try {
32
+ const query = `
33
+ PREFIX yasgui: <https://matdata.eu/ns/yasgui#>
34
+
35
+ SELECT DISTINCT ?workspace WHERE {
36
+ ?workspace a yasgui:Workspace .
37
+ }
38
+ ORDER BY ?workspace`;
39
+
40
+ // Get authentication for this endpoint
41
+ const endpointConfig = this.options.persistentConfig.getEndpointConfig(endpoint);
42
+ const headers: Record<string, string> = {
43
+ "Content-Type": "application/x-www-form-urlencoded",
44
+ Accept: "application/sparql-results+json",
45
+ };
46
+
47
+ // Add authentication headers if configured
48
+ if (endpointConfig?.authentication) {
49
+ const auth = endpointConfig.authentication;
50
+ if (auth.type === "basic") {
51
+ const credentials = btoa(`${auth.username}:${auth.password}`);
52
+ headers["Authorization"] = `Basic ${credentials}`;
53
+ } else if (auth.type === "bearer") {
54
+ headers["Authorization"] = `Bearer ${auth.token}`;
55
+ } else if (auth.type === "apiKey") {
56
+ headers[auth.headerName] = auth.apiKey;
57
+ } else if (auth.type === "oauth2" && auth.accessToken) {
58
+ headers["Authorization"] = `Bearer ${auth.accessToken}`;
59
+ }
60
+ }
61
+
62
+ const response = await fetch(endpoint, {
63
+ method: "POST",
64
+ headers,
65
+ body: new URLSearchParams({ query }),
66
+ });
67
+
68
+ if (!response.ok) {
69
+ throw new Error(`HTTP ${response.status}`);
70
+ }
71
+
72
+ const data = await response.json();
73
+ const workspaces: string[] = [];
74
+
75
+ if (data.results?.bindings) {
76
+ for (const binding of data.results.bindings) {
77
+ const workspace = binding.workspace?.value;
78
+ if (workspace) {
79
+ workspaces.push(workspace);
80
+ }
81
+ }
82
+ }
83
+
84
+ return workspaces;
85
+ } catch (error) {
86
+ console.error("Failed to fetch existing workspaces:", error);
87
+ return [];
88
+ }
89
+ }
90
+
30
91
  public render() {
31
92
  this.container.innerHTML = "";
32
93
 
@@ -234,6 +295,9 @@ export class WorkspaceSettingsForm {
234
295
  }
235
296
 
236
297
  private openWorkspaceConfigModal(input: { mode: "add" } | { mode: "edit"; workspace: WorkspaceConfig }) {
298
+ // Track mousedown for proper modal close behavior
299
+ let mouseDownOnOverlay = false;
300
+
237
301
  const overlay = document.createElement("div");
238
302
  addClass(overlay, "tabSettingsModalOverlay", "workspaceConfigModalOverlay", "open");
239
303
 
@@ -242,6 +306,7 @@ export class WorkspaceSettingsForm {
242
306
  modal.onclick = (e) => e.stopPropagation();
243
307
 
244
308
  const close = () => {
309
+ mouseDownOnOverlay = false;
245
310
  overlay.remove();
246
311
  document.removeEventListener("keydown", onKeyDown);
247
312
  };
@@ -253,7 +318,20 @@ export class WorkspaceSettingsForm {
253
318
  };
254
319
  document.addEventListener("keydown", onKeyDown);
255
320
 
256
- overlay.onclick = () => close();
321
+ // Track mousedown on overlay to distinguish from text selection that moves outside
322
+ overlay.addEventListener("mousedown", (e) => {
323
+ if (e.target === overlay) {
324
+ mouseDownOnOverlay = true;
325
+ }
326
+ });
327
+
328
+ // Only close if mousedown also happened on overlay (not during text selection)
329
+ overlay.addEventListener("mouseup", (e) => {
330
+ if (e.target === overlay && mouseDownOnOverlay) {
331
+ close();
332
+ }
333
+ mouseDownOnOverlay = false;
334
+ });
257
335
 
258
336
  const header = document.createElement("div");
259
337
  addClass(header, "modalHeader");
@@ -440,11 +518,133 @@ export class WorkspaceSettingsForm {
440
518
  sparqlHelp.textContent =
441
519
  "Tip: you can reuse an existing Workspace IRI to point to an already-populated workspace, or choose a new IRI to start a fresh workspace.";
442
520
 
443
- const workspaceIriInput = document.createElement("input");
444
- workspaceIriInput.type = "url";
445
- workspaceIriInput.placeholder = "Workspace IRI";
446
- workspaceIriInput.value = existing && existing.type === "sparql" ? existing.workspaceIri : "";
447
- addClass(workspaceIriInput, "settingsInput");
521
+ // Workspace IRI selection (dropdown + custom input)
522
+ const workspaceIriSelect = document.createElement("select");
523
+ workspaceIriSelect.setAttribute("aria-label", "Workspace IRI");
524
+ addClass(workspaceIriSelect, "settingsSelect");
525
+
526
+ const loadingOption = document.createElement("option");
527
+ loadingOption.value = "";
528
+ loadingOption.textContent = "Select endpoint first";
529
+ workspaceIriSelect.appendChild(loadingOption);
530
+
531
+ const workspaceIriCustomInput = document.createElement("input");
532
+ workspaceIriCustomInput.type = "url";
533
+ workspaceIriCustomInput.placeholder =
534
+ "Enter new workspace IRI (e.g., https://example.org/workspace/my-workspace)";
535
+ workspaceIriCustomInput.style.display = "none";
536
+ workspaceIriCustomInput.style.marginTop = "5px";
537
+ addClass(workspaceIriCustomInput, "settingsInput");
538
+
539
+ if (existing && existing.type === "sparql") {
540
+ workspaceIriCustomInput.value = existing.workspaceIri;
541
+ }
542
+
543
+ // Function to populate workspace options
544
+ const populateWorkspaceOptions = async (endpoint: string) => {
545
+ // Clear existing options
546
+ workspaceIriSelect.innerHTML = "";
547
+
548
+ // Add loading option
549
+ const loading = document.createElement("option");
550
+ loading.value = "";
551
+ loading.textContent = "Loading workspaces...";
552
+ workspaceIriSelect.appendChild(loading);
553
+ workspaceIriSelect.disabled = true;
554
+
555
+ try {
556
+ const workspaces = await this.fetchExistingWorkspaces(endpoint);
557
+
558
+ workspaceIriSelect.innerHTML = "";
559
+ workspaceIriSelect.disabled = false;
560
+
561
+ if (workspaces.length > 0) {
562
+ const selectPlaceholder = document.createElement("option");
563
+ selectPlaceholder.value = "";
564
+ selectPlaceholder.textContent = "Select existing workspace";
565
+ workspaceIriSelect.appendChild(selectPlaceholder);
566
+
567
+ for (const workspace of workspaces) {
568
+ const option = document.createElement("option");
569
+ option.value = workspace;
570
+ option.textContent = workspace;
571
+ workspaceIriSelect.appendChild(option);
572
+ }
573
+ } else {
574
+ const noWorkspaces = document.createElement("option");
575
+ noWorkspaces.value = "";
576
+ noWorkspaces.textContent = "No existing workspaces found";
577
+ workspaceIriSelect.appendChild(noWorkspaces);
578
+ }
579
+
580
+ // Add custom option
581
+ const customOption = document.createElement("option");
582
+ customOption.value = "__custom__";
583
+ customOption.textContent = "➕ Enter new workspace IRI";
584
+ workspaceIriSelect.appendChild(customOption);
585
+
586
+ // Set current value if editing existing
587
+ if (existing && existing.type === "sparql") {
588
+ const existingIri = existing.workspaceIri;
589
+ const matchingOption = workspaces.find((w) => w === existingIri);
590
+ if (matchingOption) {
591
+ workspaceIriSelect.value = existingIri;
592
+ } else {
593
+ workspaceIriSelect.value = "__custom__";
594
+ workspaceIriCustomInput.style.display = "";
595
+ workspaceIriCustomInput.value = existingIri;
596
+ }
597
+ }
598
+ } catch (error) {
599
+ workspaceIriSelect.innerHTML = "";
600
+ workspaceIriSelect.disabled = false;
601
+
602
+ const errorOption = document.createElement("option");
603
+ errorOption.value = "";
604
+ errorOption.textContent = "Failed to load workspaces";
605
+ workspaceIriSelect.appendChild(errorOption);
606
+
607
+ const customOption = document.createElement("option");
608
+ customOption.value = "__custom__";
609
+ customOption.textContent = "➕ Enter new workspace IRI";
610
+ workspaceIriSelect.appendChild(customOption);
611
+ }
612
+ };
613
+
614
+ // Handle workspace selection change
615
+ workspaceIriSelect.onchange = () => {
616
+ if (workspaceIriSelect.value === "__custom__") {
617
+ workspaceIriCustomInput.style.display = "";
618
+ workspaceIriCustomInput.focus();
619
+ } else {
620
+ workspaceIriCustomInput.style.display = "none";
621
+ }
622
+ };
623
+
624
+ // Handle endpoint change
625
+ endpointSelect.onchange = () => {
626
+ const selectedEndpoint = endpointSelect.value.trim();
627
+ if (selectedEndpoint) {
628
+ void populateWorkspaceOptions(selectedEndpoint);
629
+ } else {
630
+ workspaceIriSelect.innerHTML = "";
631
+ const placeholder = document.createElement("option");
632
+ placeholder.value = "";
633
+ placeholder.textContent = "Select endpoint first";
634
+ workspaceIriSelect.appendChild(placeholder);
635
+ }
636
+ };
637
+
638
+ // Initial load if endpoint is already selected
639
+ const initialEndpoint = endpointSelect.value.trim();
640
+ if (initialEndpoint) {
641
+ void populateWorkspaceOptions(initialEndpoint);
642
+ }
643
+
644
+ // Wrapper for workspace IRI selection
645
+ const workspaceIriWrapper = document.createElement("div");
646
+ workspaceIriWrapper.appendChild(workspaceIriSelect);
647
+ workspaceIriWrapper.appendChild(workspaceIriCustomInput);
448
648
 
449
649
  const defaultGraphInput = document.createElement("input");
450
650
  defaultGraphInput.type = "url";
@@ -454,17 +654,24 @@ export class WorkspaceSettingsForm {
454
654
 
455
655
  dynamic.appendChild(this.wrapField("SPARQL endpoint", endpointSelect));
456
656
  dynamic.appendChild(sparqlHelp);
457
- dynamic.appendChild(this.wrapField("Workspace IRI", workspaceIriInput));
657
+ dynamic.appendChild(this.wrapField("Workspace IRI", workspaceIriWrapper));
458
658
  dynamic.appendChild(this.wrapField("Default graph", defaultGraphInput));
459
659
 
460
660
  (dynamic as any).__getConfig = (): WorkspaceConfig => {
661
+ let workspaceIri = "";
662
+ if (workspaceIriSelect.value === "__custom__") {
663
+ workspaceIri = workspaceIriCustomInput.value.trim();
664
+ } else {
665
+ workspaceIri = workspaceIriSelect.value.trim();
666
+ }
667
+
461
668
  return {
462
669
  id: existing?.id || newWorkspaceId(),
463
670
  type: "sparql",
464
671
  label: labelInput.value.trim(),
465
672
  description: descriptionInput.value.trim() || undefined,
466
673
  endpoint: endpointSelect.value.trim(),
467
- workspaceIri: workspaceIriInput.value.trim(),
674
+ workspaceIri,
468
675
  defaultGraph: defaultGraphInput.value.trim() || undefined,
469
676
  };
470
677
  };
@@ -69,6 +69,25 @@ export default class GitWorkspaceBackend implements WorkspaceBackend {
69
69
  return this.client.deleteQuery(this.config, _queryId);
70
70
  }
71
71
 
72
+ async moveQuery(queryId: string, newFolderId: string): Promise<string> {
73
+ if (!this.client) throw this.missingClientError();
74
+
75
+ const parts = queryId.split("/").filter(Boolean);
76
+ const filename = parts[parts.length - 1] || queryId;
77
+ const folder = newFolderId.replace(/^\/+|\/+$/g, "");
78
+
79
+ const newPath = folder ? `${folder}/${filename}` : filename;
80
+ if (newPath === queryId) return queryId;
81
+
82
+ const read = await this.client.readQuery(this.config, queryId);
83
+ await this.client.writeQuery(this.config, newPath, read.queryText, {
84
+ message: `Move ${filename} to ${folder || "(root)"}`,
85
+ });
86
+ await this.client.deleteQuery(this.config, queryId);
87
+
88
+ return newPath;
89
+ }
90
+
72
91
  async renameQuery(queryId: string, newLabel: string): Promise<void> {
73
92
  if (!this.client) throw this.missingClientError();
74
93
 
@@ -152,6 +152,23 @@ export default class InMemoryWorkspaceBackend implements WorkspaceBackend {
152
152
  return { queryText: found.queryText, versionTag: found.id };
153
153
  }
154
154
 
155
+ async moveQuery(queryId: string, newFolderId: string): Promise<string> {
156
+ const versions = this.versionsByQueryId.get(queryId);
157
+ if (!versions || versions.length === 0) throw new WorkspaceBackendError("NOT_FOUND", "Query not found");
158
+
159
+ const filename = basename(queryId);
160
+ const folder = normalizeFolderId(newFolderId);
161
+ const newId = folder ? `${folder}/${filename}` : filename;
162
+ if (newId === queryId) return queryId;
163
+
164
+ if (this.versionsByQueryId.has(newId))
165
+ throw new WorkspaceBackendError("CONFLICT", "A query already exists at this path");
166
+
167
+ this.versionsByQueryId.delete(queryId);
168
+ this.versionsByQueryId.set(newId, versions);
169
+ return newId;
170
+ }
171
+
155
172
  async renameQuery(queryId: string, newLabel: string): Promise<void> {
156
173
  const trimmed = newLabel.trim();
157
174
  if (!trimmed) throw new WorkspaceBackendError("UNKNOWN", "New name is required");
@@ -422,6 +422,75 @@ WHERE { OPTIONAL { ${iri(mqIri)} rdfs:label ?oldLabel . } }
422
422
  await this.sparqlUpdate(update);
423
423
  }
424
424
 
425
+ async moveQuery(queryId: string, newFolderId: string): Promise<string> {
426
+ const mqIriValue = this.resolveManagedQueryIri(queryId);
427
+ const workspaceIri = this.config.workspaceIri;
428
+ const folder = (newFolderId || "").replace(/^\/+|\/+$/g, "");
429
+
430
+ const newContainerIriValue = folder ? mintFolderIri(workspaceIri, folder) : workspaceIri;
431
+
432
+ // Fetch the current container to detect no-ops.
433
+ const currentContainerIriValue = await (async () => {
434
+ const q = `
435
+ PREFIX yasgui: <https://matdata.eu/ns/yasgui#>
436
+ PREFIX dcterms: <http://purl.org/dc/terms/>
437
+
438
+ SELECT ?container WHERE {
439
+ ${iri(mqIriValue)} a yasgui:ManagedQuery ;
440
+ dcterms:isPartOf ?container .
441
+ }
442
+ LIMIT 1`;
443
+ const res = await this.sparqlQuery<SparqlJsonResults>(q);
444
+ const row = this.getBindings(res)[0];
445
+ const container = row?.container?.value;
446
+ if (!container) throw new WorkspaceBackendError("NOT_FOUND", "Query not found");
447
+ return container;
448
+ })();
449
+
450
+ if (currentContainerIriValue === newContainerIriValue) return queryId;
451
+
452
+ // Ensure each ancestor folder exists in the store.
453
+ if (folder) {
454
+ const parts = splitPath(folder);
455
+ const folderTriples: string[] = [];
456
+ for (let i = 0; i < parts.length; i++) {
457
+ const subPath = parts.slice(0, i + 1).join("/");
458
+ const folderIriValue = mintFolderIri(workspaceIri, subPath);
459
+ const folderLabel = parts[i];
460
+ folderTriples.push(`${iri(folderIriValue)} a <https://matdata.eu/ns/yasgui#WorkspaceFolder> ;`);
461
+ folderTriples.push(` <http://www.w3.org/2004/02/skos/core#inScheme> ${iri(workspaceIri)} ;`);
462
+ folderTriples.push(` <http://www.w3.org/2000/01/rdf-schema#label> ${sparqlStringLiteral(folderLabel)} .`);
463
+
464
+ if (i > 0) {
465
+ const parentPath = parts.slice(0, i).join("/");
466
+ const parentIri = mintFolderIri(workspaceIri, parentPath);
467
+ folderTriples.push(
468
+ `${iri(folderIriValue)} <http://www.w3.org/2004/02/skos/core#broader> ${iri(parentIri)} .`,
469
+ );
470
+ }
471
+ }
472
+ await this.sparqlUpdate(`
473
+ PREFIX yasgui: <https://matdata.eu/ns/yasgui#>
474
+
475
+ INSERT DATA {
476
+ ${iri(workspaceIri)} a yasgui:Workspace .
477
+ ${folderTriples.join("\n ")}
478
+ }
479
+ `);
480
+ }
481
+
482
+ // Update dcterms:isPartOf to point to the new container.
483
+ await this.sparqlUpdate(`
484
+ PREFIX dcterms: <http://purl.org/dc/terms/>
485
+
486
+ DELETE { ${iri(mqIriValue)} dcterms:isPartOf ${iri(currentContainerIriValue)} . }
487
+ INSERT { ${iri(mqIriValue)} dcterms:isPartOf ${iri(newContainerIriValue)} . }
488
+ WHERE { ${iri(mqIriValue)} dcterms:isPartOf ${iri(currentContainerIriValue)} . }
489
+ `);
490
+
491
+ return queryId;
492
+ }
493
+
425
494
  async deleteQuery(queryId: string): Promise<void> {
426
495
  const mqIri = this.resolveManagedQueryIri(queryId);
427
496
  const update = `
@@ -23,6 +23,12 @@ export interface WorkspaceBackend {
23
23
  */
24
24
  renameQuery?(queryId: string, newLabel: string): Promise<void>;
25
25
 
26
+ /**
27
+ * Optional: Move a query to a different folder.
28
+ * Returns the new query ID (may differ from the original for Git backends where the ID encodes the path).
29
+ */
30
+ moveQuery?(queryId: string, newFolderId: string): Promise<string>;
31
+
26
32
  /**
27
33
  * Optional: Delete a query and its version history.
28
34
  * Implementations may not support this (e.g., some Git provider clients).
package/src/tab.scss CHANGED
@@ -169,5 +169,6 @@
169
169
  flex-shrink: 0;
170
170
  max-height: 35px;
171
171
  gap: 2px;
172
+ position: relative; // For hamburger dropdown positioning
172
173
  }
173
174
  }