@matdata/yasgui 5.17.0 → 5.18.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 (59) hide show
  1. package/build/ts/src/Tab.js +6 -0
  2. package/build/ts/src/Tab.js.map +1 -1
  3. package/build/ts/src/TabContextMenu.d.ts +1 -0
  4. package/build/ts/src/TabContextMenu.js +42 -0
  5. package/build/ts/src/TabContextMenu.js.map +1 -1
  6. package/build/ts/src/TabSettingsModal.d.ts +0 -1
  7. package/build/ts/src/TabSettingsModal.js +22 -25
  8. package/build/ts/src/TabSettingsModal.js.map +1 -1
  9. package/build/ts/src/index.d.ts +6 -0
  10. package/build/ts/src/index.js +42 -0
  11. package/build/ts/src/index.js.map +1 -1
  12. package/build/ts/src/prefixUtils.d.ts +1 -0
  13. package/build/ts/src/prefixUtils.js +23 -0
  14. package/build/ts/src/prefixUtils.js.map +1 -0
  15. package/build/ts/src/queryManagement/QueryBrowser.d.ts +8 -0
  16. package/build/ts/src/queryManagement/QueryBrowser.js +283 -116
  17. package/build/ts/src/queryManagement/QueryBrowser.js.map +1 -1
  18. package/build/ts/src/queryManagement/QueryHistoryModal.d.ts +35 -0
  19. package/build/ts/src/queryManagement/QueryHistoryModal.js +266 -0
  20. package/build/ts/src/queryManagement/QueryHistoryModal.js.map +1 -0
  21. package/build/ts/src/queryManagement/WorkspaceSettingsForm.js +8 -5
  22. package/build/ts/src/queryManagement/WorkspaceSettingsForm.js.map +1 -1
  23. package/build/ts/src/queryManagement/backends/GitWorkspaceBackend.d.ts +1 -0
  24. package/build/ts/src/queryManagement/backends/GitWorkspaceBackend.js +33 -0
  25. package/build/ts/src/queryManagement/backends/GitWorkspaceBackend.js.map +1 -1
  26. package/build/ts/src/queryManagement/backends/SparqlWorkspaceBackend.d.ts +1 -0
  27. package/build/ts/src/queryManagement/backends/SparqlWorkspaceBackend.js +3 -0
  28. package/build/ts/src/queryManagement/backends/SparqlWorkspaceBackend.js.map +1 -1
  29. package/build/ts/src/queryManagement/backends/WorkspaceBackend.d.ts +1 -0
  30. package/build/ts/src/queryManagement/countManagedQueries.d.ts +2 -0
  31. package/build/ts/src/queryManagement/countManagedQueries.js +35 -0
  32. package/build/ts/src/queryManagement/countManagedQueries.js.map +1 -0
  33. package/build/ts/src/tabNavigationHistory.d.ts +3 -0
  34. package/build/ts/src/tabNavigationHistory.js +13 -0
  35. package/build/ts/src/tabNavigationHistory.js.map +1 -0
  36. package/build/ts/src/version.d.ts +1 -1
  37. package/build/ts/src/version.js +1 -1
  38. package/build/yasgui.min.css +1 -1
  39. package/build/yasgui.min.css.map +3 -3
  40. package/build/yasgui.min.js +169 -169
  41. package/build/yasgui.min.js.map +4 -4
  42. package/package.json +1 -1
  43. package/src/Tab.ts +9 -0
  44. package/src/TabContextMenu.ts +42 -0
  45. package/src/TabSettingsModal.ts +22 -29
  46. package/src/index.ts +50 -0
  47. package/src/prefixUtils.ts +26 -0
  48. package/src/queryManagement/QueryBrowser.scss +85 -0
  49. package/src/queryManagement/QueryBrowser.ts +429 -213
  50. package/src/queryManagement/QueryHistoryModal.scss +239 -0
  51. package/src/queryManagement/QueryHistoryModal.ts +346 -0
  52. package/src/queryManagement/WorkspaceSettingsForm.ts +8 -5
  53. package/src/queryManagement/backends/GitWorkspaceBackend.ts +39 -0
  54. package/src/queryManagement/backends/SparqlWorkspaceBackend.ts +5 -0
  55. package/src/queryManagement/backends/WorkspaceBackend.ts +8 -0
  56. package/src/queryManagement/countManagedQueries.ts +28 -0
  57. package/src/tabNavigationHistory.ts +17 -0
  58. package/src/themes.scss +2 -0
  59. package/src/version.ts +1 -1
@@ -9,6 +9,7 @@ import { hashQueryText } from "./textHash";
9
9
  import type { BackendType, VersionRef, ManagedTabMetadata } from "./types";
10
10
  import { normalizeQueryFilename } from "./normalizeQueryFilename";
11
11
  import SaveManagedQueryModal from "./SaveManagedQueryModal";
12
+ import QueryHistoryModal from "./QueryHistoryModal";
12
13
 
13
14
  import "./QueryBrowser.scss";
14
15
 
@@ -50,6 +51,11 @@ export default class QueryBrowser {
50
51
  private lastPointerPos: { x: number; y: number } | undefined;
51
52
  private folderPickerModal?: SaveManagedQueryModal;
52
53
 
54
+ private queryContextMenuEl?: HTMLElement;
55
+ private queryContextMenuCleanup?: () => void;
56
+
57
+ private queryHistoryModal?: QueryHistoryModal;
58
+
53
59
  private entrySignature(entry: FolderEntry): string {
54
60
  const parent = entry.parentId || "";
55
61
  // Include label + parent so renames/moves force a re-render.
@@ -235,6 +241,7 @@ export default class QueryBrowser {
235
241
  removeClass(this.rootEl, "open");
236
242
  this.rootEl.setAttribute("aria-hidden", "true");
237
243
  this.rootEl.style.display = "none";
244
+ this.closeQueryContextMenu();
238
245
 
239
246
  if (this.openerEl) {
240
247
  this.openerEl.focus();
@@ -571,258 +578,467 @@ export default class QueryBrowser {
571
578
  tab.setManagedQueryMetadata(managedMetadata);
572
579
  }
573
580
 
574
- private addQueryRowActions(row: HTMLElement, backend: ReturnType<typeof getWorkspaceBackend>, entry: FolderEntry) {
575
- const actions = document.createElement("span");
576
- addClass(actions, "yasgui-query-browser__actions");
581
+ private closeQueryContextMenu() {
582
+ if (this.queryContextMenuEl) {
583
+ this.queryContextMenuEl.remove();
584
+ this.queryContextMenuEl = undefined;
585
+ }
586
+ if (this.queryContextMenuCleanup) {
587
+ this.queryContextMenuCleanup();
588
+ this.queryContextMenuCleanup = undefined;
589
+ }
590
+ }
577
591
 
578
- if (entry.kind === "folder") {
579
- if (backend.renameFolder) {
580
- const renameBtn = document.createElement("button");
581
- renameBtn.type = "button";
582
- addClass(renameBtn, "yasgui-query-browser__action");
583
- renameBtn.textContent = "Rename";
584
- renameBtn.setAttribute("aria-label", `Rename folder ${entry.label}`);
585
- renameBtn.addEventListener("click", async (e) => {
592
+ private startInlineRename(row: HTMLElement, currentLabel: string): Promise<string | null> {
593
+ return new Promise((resolve) => {
594
+ const labelEl = row.querySelector<HTMLElement>(".yasgui-query-browser__tree-label");
595
+ if (!labelEl) {
596
+ resolve(null);
597
+ return;
598
+ }
599
+
600
+ const input = document.createElement("input");
601
+ input.type = "text";
602
+ input.value = currentLabel;
603
+ addClass(input, "yasgui-query-browser__rename-input");
604
+
605
+ let settled = false;
606
+ const finish = (value: string | null) => {
607
+ if (settled) return;
608
+ settled = true;
609
+ input.remove();
610
+ labelEl.style.display = "";
611
+ resolve(value);
612
+ };
613
+
614
+ input.addEventListener("click", (e) => e.stopPropagation());
615
+ input.addEventListener("keydown", (e) => {
616
+ e.stopPropagation();
617
+ if (e.key === "Enter") {
618
+ e.preventDefault();
619
+ finish(input.value.trim() || null);
620
+ } else if (e.key === "Escape") {
586
621
  e.preventDefault();
587
- e.stopPropagation();
622
+ finish(null);
623
+ }
624
+ });
625
+ input.addEventListener("blur", () => finish(null));
626
+
627
+ labelEl.style.display = "none";
628
+ labelEl.insertAdjacentElement("afterend", input);
629
+ input.focus();
630
+ input.select();
631
+ });
632
+ }
633
+
634
+ private showInlineConfirm(row: HTMLElement, message: string): Promise<boolean> {
635
+ return new Promise((resolve) => {
636
+ const savedChildren = Array.from(row.childNodes);
637
+ while (row.firstChild) row.removeChild(row.firstChild);
638
+
639
+ const confirmEl = document.createElement("span");
640
+ addClass(confirmEl, "yasgui-query-browser__inline-confirm");
641
+ confirmEl.textContent = message;
642
+ confirmEl.addEventListener("click", (e) => e.stopPropagation());
643
+
644
+ const yes = document.createElement("button");
645
+ yes.type = "button";
646
+ yes.textContent = "Yes";
647
+ addClass(yes, "yasgui-query-browser__inline-confirm-btn");
648
+ addClass(yes, "yasgui-query-browser__inline-confirm-btn--danger");
649
+
650
+ const no = document.createElement("button");
651
+ no.type = "button";
652
+ no.textContent = "No";
653
+ addClass(no, "yasgui-query-browser__inline-confirm-btn");
654
+
655
+ let settled = false;
656
+ const finish = (value: boolean) => {
657
+ if (settled) return;
658
+ settled = true;
659
+ while (row.firstChild) row.removeChild(row.firstChild);
660
+ for (const child of savedChildren) row.appendChild(child);
661
+ resolve(value);
662
+ };
663
+
664
+ yes.addEventListener("click", (e) => {
665
+ e.stopPropagation();
666
+ finish(true);
667
+ });
668
+ no.addEventListener("click", (e) => {
669
+ e.stopPropagation();
670
+ finish(false);
671
+ });
672
+
673
+ row.appendChild(confirmEl);
674
+ row.appendChild(document.createTextNode(" "));
675
+ row.appendChild(yes);
676
+ row.appendChild(document.createTextNode(" "));
677
+ row.appendChild(no);
678
+ no.focus();
679
+ });
680
+ }
681
+
682
+ private openQueryContextMenu(
683
+ event: MouseEvent,
684
+ backend: ReturnType<typeof getWorkspaceBackend>,
685
+ entry: FolderEntry,
686
+ row: HTMLElement,
687
+ ) {
688
+ event.preventDefault();
689
+ event.stopPropagation();
690
+
691
+ this.closeQueryContextMenu();
692
+
693
+ const menu = document.createElement("ul");
694
+ addClass(menu, "yasgui-query-browser__context-menu");
695
+
696
+ const makeItem = (label: string, handler: () => void | Promise<void>, isDanger = false) => {
697
+ const li = document.createElement("li");
698
+ addClass(li, "yasgui-query-browser__context-menu-item");
699
+ if (isDanger) addClass(li, "yasgui-query-browser__context-menu-item--danger");
700
+ li.textContent = label;
701
+ li.addEventListener("click", async (e) => {
702
+ e.stopPropagation();
703
+ this.closeQueryContextMenu();
704
+ await handler();
705
+ });
706
+ return li;
707
+ };
588
708
 
589
- const next = window.prompt("Rename folder", entry.label);
709
+ // Copy URI
710
+ if (backend.getQueryUri) {
711
+ const uri = backend.getQueryUri(entry.id);
712
+ if (uri) {
713
+ menu.appendChild(
714
+ makeItem("Copy URI", async () => {
715
+ try {
716
+ await navigator.clipboard.writeText(uri);
717
+ } catch {
718
+ window.prompt("Copy this URI:", uri);
719
+ }
720
+ }),
721
+ );
722
+ }
723
+ }
724
+
725
+ // History
726
+ menu.appendChild(
727
+ makeItem("History", async () => {
728
+ if (!this.queryHistoryModal) this.queryHistoryModal = new QueryHistoryModal(this.yasgui);
729
+ await this.queryHistoryModal.show(this.selectedWorkspaceId!, backend, backend.type, entry.id, entry.label);
730
+ }),
731
+ );
732
+
733
+ // Rename
734
+ if (backend.renameQuery) {
735
+ const renameQuery = backend.renameQuery.bind(backend);
736
+ menu.appendChild(
737
+ makeItem("Rename", async () => {
738
+ const next = await this.startInlineRename(row, entry.label);
590
739
  if (!next) return;
591
740
  const trimmed = next.trim();
592
741
  if (!trimmed || trimmed === entry.label) return;
593
742
 
743
+ const gitRenameInfo = (() => {
744
+ if (backend.type !== "git") return undefined;
745
+ const parts = entry.id.split("/").filter(Boolean);
746
+ parts.pop();
747
+ const folderPrefix = parts.join("/");
748
+ const safe = trimmed.replace(/[\\/]/g, "-");
749
+ const newFilename = normalizeQueryFilename(safe);
750
+ const newPath = folderPrefix ? `${folderPrefix}/${newFilename}` : newFilename;
751
+ return { oldPath: entry.id, newPath };
752
+ })();
753
+
594
754
  try {
595
- await backend.renameFolder!(entry.id, trimmed);
755
+ await renameQuery(entry.id, trimmed);
756
+
757
+ if (gitRenameInfo && gitRenameInfo.newPath && gitRenameInfo.oldPath) {
758
+ for (const tab of Object.values(this.yasgui._tabs)) {
759
+ const meta = (tab as any).getManagedQueryMetadata?.() as ManagedTabMetadata | undefined;
760
+ if (!meta) continue;
761
+ if (meta.backendType !== "git") continue;
762
+ if (meta.workspaceId !== this.selectedWorkspaceId) continue;
763
+ const currentPath = (meta.queryRef as any)?.path as string | undefined;
764
+ if (currentPath !== gitRenameInfo.oldPath) continue;
765
+
766
+ try {
767
+ const read = await backend.readQuery(gitRenameInfo.newPath);
768
+ const lastSavedTextHash = hashQueryText(read.queryText);
769
+ const lastSavedVersionRef = this.versionRefFromVersionTag("git", read.versionTag);
770
+
771
+ (tab as any).setManagedQueryMetadata?.({
772
+ ...meta,
773
+ queryRef: { ...(meta.queryRef as any), path: gitRenameInfo.newPath },
774
+ lastSavedTextHash,
775
+ lastSavedVersionRef,
776
+ });
777
+ (tab as any).setName?.(trimmed);
778
+ } catch {
779
+ // Best-effort: if refreshing metadata fails, the Query Browser still reflects the rename.
780
+ }
781
+ }
782
+ }
783
+
784
+ this.queryPreviewById.delete(entry.id);
596
785
  this.folderEntriesById.clear();
597
786
  this.invalidateRenderCache();
598
787
  await this.refresh();
599
788
  } catch (err) {
600
- window.alert(asWorkspaceBackendError(err).message);
789
+ this.setStatus(asWorkspaceBackendError(err).message);
601
790
  }
602
- });
603
- actions.appendChild(renameBtn);
604
- }
605
-
606
- if (backend.deleteFolder) {
607
- const deleteBtn = document.createElement("button");
608
- deleteBtn.type = "button";
609
- addClass(deleteBtn, "yasgui-query-browser__action");
610
- addClass(deleteBtn, "yasgui-query-browser__action--danger");
611
- deleteBtn.textContent = "Delete";
612
- deleteBtn.setAttribute("aria-label", `Delete folder ${entry.label}`);
613
- deleteBtn.addEventListener("click", async (e) => {
614
- e.preventDefault();
615
- e.stopPropagation();
791
+ }),
792
+ );
793
+ }
616
794
 
617
- const ok = window.confirm(`Delete folder '${entry.label}' and everything inside it? This cannot be undone.`);
618
- if (!ok) return;
795
+ // Move
796
+ if (backend.moveQuery) {
797
+ const moveQuery = backend.moveQuery.bind(backend);
798
+ menu.appendChild(
799
+ makeItem("Move", async () => {
800
+ const currentFolderPath = entry.parentId || "";
801
+ if (!this.folderPickerModal) this.folderPickerModal = new SaveManagedQueryModal(this.yasgui);
802
+ const newFolderPath = await this.folderPickerModal.showFolderPickerOnly(
803
+ this.selectedWorkspaceId!,
804
+ currentFolderPath,
805
+ );
806
+ if (newFolderPath === undefined) return;
807
+ if (newFolderPath === currentFolderPath) return;
619
808
 
620
809
  try {
621
- await backend.deleteFolder!(entry.id);
810
+ const newQueryId = await moveQuery(entry.id, newFolderPath);
811
+
812
+ if (backend.type === "git" && newQueryId !== entry.id) {
813
+ for (const tab of Object.values(this.yasgui._tabs)) {
814
+ const meta = (tab as any).getManagedQueryMetadata?.() as ManagedTabMetadata | undefined;
815
+ if (!meta) continue;
816
+ if (meta.backendType !== "git") continue;
817
+ if (meta.workspaceId !== this.selectedWorkspaceId) continue;
818
+ const currentPath = (meta.queryRef as any)?.path as string | undefined;
819
+ if (currentPath !== entry.id) continue;
820
+
821
+ try {
822
+ const read = await backend.readQuery(newQueryId);
823
+ const lastSavedTextHash = hashQueryText(read.queryText);
824
+ const lastSavedVersionRef = this.versionRefFromVersionTag("git", read.versionTag);
825
+
826
+ (tab as any).setManagedQueryMetadata?.({
827
+ ...meta,
828
+ queryRef: { ...(meta.queryRef as any), path: newQueryId },
829
+ lastSavedTextHash,
830
+ lastSavedVersionRef,
831
+ });
832
+ } catch {
833
+ // Best-effort: if refreshing metadata fails, the Query Browser still reflects the move.
834
+ }
835
+ }
836
+ }
837
+
838
+ this.queryPreviewById.delete(entry.id);
622
839
  this.folderEntriesById.clear();
623
840
  this.invalidateRenderCache();
624
841
  await this.refresh();
625
842
  } catch (err) {
626
- window.alert(asWorkspaceBackendError(err).message);
843
+ this.setStatus(asWorkspaceBackendError(err).message);
627
844
  }
628
- });
629
- actions.appendChild(deleteBtn);
630
- }
845
+ }),
846
+ );
847
+ }
631
848
 
632
- if (actions.childElementCount > 0) row.appendChild(actions);
633
- return;
849
+ // Delete
850
+ if (backend.deleteQuery) {
851
+ const deleteQuery = backend.deleteQuery.bind(backend);
852
+ menu.appendChild(
853
+ makeItem(
854
+ "Delete",
855
+ async () => {
856
+ const ok = await this.showInlineConfirm(row, `Delete '${entry.label}'?`);
857
+ if (!ok) return;
858
+
859
+ try {
860
+ await deleteQuery(entry.id);
861
+ this.queryPreviewById.delete(entry.id);
862
+ this.folderEntriesById.clear();
863
+ this.invalidateRenderCache();
864
+ await this.refresh();
865
+ } catch (err) {
866
+ this.setStatus(asWorkspaceBackendError(err).message);
867
+ }
868
+ },
869
+ true,
870
+ ),
871
+ );
634
872
  }
635
873
 
636
- if (entry.kind !== "query") return;
874
+ if (!menu.childElementCount) return;
875
+
876
+ this.rootEl.appendChild(menu);
877
+ this.queryContextMenuEl = menu;
878
+
879
+ // Position near the cursor, clamping to the viewport.
880
+ const VIEWPORT_PADDING = 4;
881
+ menu.style.position = "fixed";
882
+ menu.style.zIndex = "10001";
883
+ menu.style.left = `${event.clientX}px`;
884
+ menu.style.top = `${event.clientY}px`;
885
+ requestAnimationFrame(() => {
886
+ const rect = menu.getBoundingClientRect();
887
+ if (rect.right > window.innerWidth - VIEWPORT_PADDING) {
888
+ menu.style.left = `${Math.max(VIEWPORT_PADDING, event.clientX - rect.width)}px`;
889
+ }
890
+ if (rect.bottom > window.innerHeight - VIEWPORT_PADDING) {
891
+ menu.style.top = `${Math.max(VIEWPORT_PADDING, event.clientY - rect.height)}px`;
892
+ }
893
+ });
637
894
 
638
- if (backend.renameQuery) {
639
- const renameBtn = document.createElement("button");
640
- renameBtn.type = "button";
641
- addClass(renameBtn, "yasgui-query-browser__action");
642
- renameBtn.textContent = "Rename";
643
- renameBtn.setAttribute("aria-label", `Rename ${entry.label}`);
644
- renameBtn.addEventListener("click", async (e) => {
645
- e.preventDefault();
895
+ const onOutsideClick = (e: MouseEvent) => {
896
+ if (!this.queryContextMenuEl) return;
897
+ if (!menu.contains(e.target as Node)) this.closeQueryContextMenu();
898
+ };
899
+ const onEsc = (e: KeyboardEvent) => {
900
+ if (e.key === "Escape") {
646
901
  e.stopPropagation();
902
+ this.closeQueryContextMenu();
903
+ }
904
+ };
647
905
 
648
- const next = window.prompt("Rename query", entry.label);
649
- if (!next) return;
650
- const trimmed = next.trim();
651
- if (!trimmed || trimmed === entry.label) return;
652
-
653
- // For git workspaces we can deterministically compute the new path, so we can
654
- // also update any already-open managed tabs that reference this query.
655
- const gitRenameInfo = (() => {
656
- if (backend.type !== "git") return undefined;
657
- const parts = entry.id.split("/").filter(Boolean);
658
- parts.pop();
659
- const folderPrefix = parts.join("/");
660
- const safe = trimmed.replace(/[\\/]/g, "-");
661
- const newFilename = normalizeQueryFilename(safe);
662
- const newPath = folderPrefix ? `${folderPrefix}/${newFilename}` : newFilename;
663
- return { oldPath: entry.id, newPath };
664
- })();
665
-
666
- // Show loading state
667
- const originalText = renameBtn.textContent;
668
- renameBtn.disabled = true;
669
- renameBtn.textContent = "Renaming…";
670
- addClass(renameBtn, "loading");
671
-
672
- try {
673
- await backend.renameQuery!(entry.id, trimmed);
674
-
675
- if (gitRenameInfo && gitRenameInfo.newPath && gitRenameInfo.oldPath) {
676
- for (const tab of Object.values(this.yasgui._tabs)) {
677
- const meta = (tab as any).getManagedQueryMetadata?.() as ManagedTabMetadata | undefined;
678
- if (!meta) continue;
679
- if (meta.backendType !== "git") continue;
680
- if (meta.workspaceId !== this.selectedWorkspaceId) continue;
681
- const currentPath = (meta.queryRef as any)?.path as string | undefined;
682
- if (currentPath !== gitRenameInfo.oldPath) continue;
683
-
684
- try {
685
- const read = await backend.readQuery(gitRenameInfo.newPath);
686
- const lastSavedTextHash = hashQueryText(read.queryText);
687
- const lastSavedVersionRef = this.versionRefFromVersionTag("git", read.versionTag);
688
-
689
- (tab as any).setManagedQueryMetadata?.({
690
- ...meta,
691
- queryRef: { ...(meta.queryRef as any), path: gitRenameInfo.newPath },
692
- lastSavedTextHash,
693
- lastSavedVersionRef,
694
- });
695
- (tab as any).setName?.(trimmed);
696
- } catch {
697
- // Best-effort: if refreshing metadata fails, the Query Browser still reflects the rename.
698
- }
699
- }
700
- }
906
+ // Use requestAnimationFrame to defer listener registration past the current event cycle,
907
+ // ensuring the contextmenu event that opened the menu is not immediately caught.
908
+ requestAnimationFrame(() => {
909
+ document.addEventListener("click", onOutsideClick);
910
+ document.addEventListener("keydown", onEsc);
911
+ });
701
912
 
702
- this.queryPreviewById.delete(entry.id);
703
- this.folderEntriesById.clear();
704
- this.invalidateRenderCache();
705
- await this.refresh();
706
- } catch (err) {
707
- // Restore button state on error
708
- renameBtn.disabled = false;
709
- renameBtn.textContent = originalText || "Rename";
710
- removeClass(renameBtn, "loading");
711
- window.alert(asWorkspaceBackendError(err).message);
712
- }
713
- });
714
- actions.appendChild(renameBtn);
715
- }
913
+ this.queryContextMenuCleanup = () => {
914
+ document.removeEventListener("click", onOutsideClick);
915
+ document.removeEventListener("keydown", onEsc);
916
+ };
917
+ }
716
918
 
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();
919
+ private openFolderContextMenu(
920
+ event: MouseEvent,
921
+ backend: ReturnType<typeof getWorkspaceBackend>,
922
+ entry: FolderEntry,
923
+ row: HTMLElement,
924
+ ) {
925
+ event.preventDefault();
926
+ event.stopPropagation();
927
+
928
+ this.closeQueryContextMenu();
929
+
930
+ const menu = document.createElement("ul");
931
+ addClass(menu, "yasgui-query-browser__context-menu");
932
+
933
+ const makeItem = (label: string, handler: () => void | Promise<void>, isDanger = false) => {
934
+ const li = document.createElement("li");
935
+ addClass(li, "yasgui-query-browser__context-menu-item");
936
+ if (isDanger) addClass(li, "yasgui-query-browser__context-menu-item--danger");
937
+ li.textContent = label;
938
+ li.addEventListener("click", async (e) => {
725
939
  e.stopPropagation();
940
+ this.closeQueryContextMenu();
941
+ await handler();
942
+ });
943
+ return li;
944
+ };
726
945
 
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
- }
946
+ if (backend.renameFolder) {
947
+ menu.appendChild(
948
+ makeItem("Rename", async () => {
949
+ const next = await this.startInlineRename(row, entry.label);
950
+ if (!next) return;
951
+ const trimmed = next.trim();
952
+ if (!trimmed || trimmed === entry.label) return;
953
+
954
+ try {
955
+ await backend.renameFolder!(entry.id, trimmed);
956
+ this.folderEntriesById.clear();
957
+ this.invalidateRenderCache();
958
+ await this.refresh();
959
+ } catch (err) {
960
+ this.setStatus(asWorkspaceBackendError(err).message);
770
961
  }
962
+ }),
963
+ );
964
+ }
771
965
 
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);
966
+ if (backend.deleteFolder) {
967
+ menu.appendChild(
968
+ makeItem(
969
+ "Delete",
970
+ async () => {
971
+ const ok = await this.showInlineConfirm(row, `Delete folder '${entry.label}' and everything inside it?`);
972
+ if (!ok) return;
973
+
974
+ try {
975
+ await backend.deleteFolder!(entry.id);
976
+ this.folderEntriesById.clear();
977
+ this.invalidateRenderCache();
978
+ await this.refresh();
979
+ } catch (err) {
980
+ this.setStatus(asWorkspaceBackendError(err).message);
981
+ }
982
+ },
983
+ true,
984
+ ),
985
+ );
785
986
  }
786
987
 
787
- if (backend.deleteQuery) {
788
- const deleteBtn = document.createElement("button");
789
- deleteBtn.type = "button";
790
- addClass(deleteBtn, "yasgui-query-browser__action");
791
- addClass(deleteBtn, "yasgui-query-browser__action--danger");
792
- deleteBtn.textContent = "Delete";
793
- deleteBtn.setAttribute("aria-label", `Delete ${entry.label}`);
794
- deleteBtn.addEventListener("click", async (e) => {
795
- e.preventDefault();
988
+ if (!menu.childElementCount) return;
989
+
990
+ this.rootEl.appendChild(menu);
991
+ this.queryContextMenuEl = menu;
992
+
993
+ const VIEWPORT_PADDING = 4;
994
+ menu.style.position = "fixed";
995
+ menu.style.zIndex = "10001";
996
+ menu.style.left = `${event.clientX}px`;
997
+ menu.style.top = `${event.clientY}px`;
998
+ requestAnimationFrame(() => {
999
+ const rect = menu.getBoundingClientRect();
1000
+ if (rect.right > window.innerWidth - VIEWPORT_PADDING) {
1001
+ menu.style.left = `${Math.max(VIEWPORT_PADDING, event.clientX - rect.width)}px`;
1002
+ }
1003
+ if (rect.bottom > window.innerHeight - VIEWPORT_PADDING) {
1004
+ menu.style.top = `${Math.max(VIEWPORT_PADDING, event.clientY - rect.height)}px`;
1005
+ }
1006
+ });
1007
+
1008
+ const onOutsideClick = (e: MouseEvent) => {
1009
+ if (!this.queryContextMenuEl) return;
1010
+ if (!menu.contains(e.target as Node)) this.closeQueryContextMenu();
1011
+ };
1012
+ const onEsc = (e: KeyboardEvent) => {
1013
+ if (e.key === "Escape") {
796
1014
  e.stopPropagation();
797
- const ok = window.confirm(`Delete '${entry.label}'? This cannot be undone.`);
798
- if (!ok) return;
799
-
800
- // Show loading state
801
- const originalText = deleteBtn.textContent;
802
- deleteBtn.disabled = true;
803
- deleteBtn.textContent = "Deleting…";
804
- addClass(deleteBtn, "loading");
805
-
806
- try {
807
- await backend.deleteQuery!(entry.id);
808
- this.queryPreviewById.delete(entry.id);
809
- this.folderEntriesById.clear();
810
- this.invalidateRenderCache();
811
- await this.refresh();
812
- } catch (err) {
813
- // Restore button state on error
814
- deleteBtn.disabled = false;
815
- deleteBtn.textContent = originalText || "Delete";
816
- removeClass(deleteBtn, "loading");
817
- window.alert(asWorkspaceBackendError(err).message);
818
- }
1015
+ this.closeQueryContextMenu();
1016
+ }
1017
+ };
1018
+
1019
+ requestAnimationFrame(() => {
1020
+ document.addEventListener("click", onOutsideClick);
1021
+ document.addEventListener("keydown", onEsc);
1022
+ });
1023
+
1024
+ this.queryContextMenuCleanup = () => {
1025
+ document.removeEventListener("click", onOutsideClick);
1026
+ document.removeEventListener("keydown", onEsc);
1027
+ };
1028
+ }
1029
+
1030
+ private addQueryRowActions(row: HTMLElement, backend: ReturnType<typeof getWorkspaceBackend>, entry: FolderEntry) {
1031
+ if (entry.kind === "folder") {
1032
+ row.addEventListener("contextmenu", (e) => this.openFolderContextMenu(e, backend, entry, row), {
1033
+ passive: false,
819
1034
  });
820
- actions.appendChild(deleteBtn);
1035
+ return;
821
1036
  }
822
1037
 
823
- if (actions.childElementCount > 0) {
824
- row.appendChild(actions);
825
- }
1038
+ if (entry.kind !== "query") return;
1039
+
1040
+ // Query actions are shown in a right-click context menu to preserve space for the query name.
1041
+ row.addEventListener("contextmenu", (e) => this.openQueryContextMenu(e, backend, entry, row), { passive: false });
826
1042
  }
827
1043
 
828
1044
  private renderFlatEntries(backend: ReturnType<typeof getWorkspaceBackend>, entries: FolderEntry[]) {