diff --git a/docs/user-guide.md b/docs/user-guide.md index 3e5a71bb..608585cf 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -1179,6 +1179,13 @@ The token must have access to the specific repository you're configuring as a wo - Use the tab context menu action **Save as managed query** - Saving the same query text does not create a new version; changing text creates a new version +**Copy URI of a managed query:** + +- In the **Query Browser**: use the **Copy URI** action button next to any query to copy its URI to the clipboard +- In the **tab context menu** (right-click a tab): use **Copy URI** when the tab is linked to a managed query +- For SPARQL workspaces the URI is the managed-query IRI stored in the triplestore +- For Git workspaces the URI is the browser-accessible link to the file in the repository (GitHub/GitLab/Bitbucket/Gitea) + **Credentials:** - Tokens are stored locally, not saved together with the managed query and are not re-displayed after entry diff --git a/packages/yasgui/src/TabContextMenu.ts b/packages/yasgui/src/TabContextMenu.ts index cd52e449..432bcd2f 100644 --- a/packages/yasgui/src/TabContextMenu.ts +++ b/packages/yasgui/src/TabContextMenu.ts @@ -3,6 +3,8 @@ import { default as Yasgui, getRandomId } from "./"; import Tab from "./Tab"; import { TabListEl } from "./TabElements"; import { cloneDeep } from "lodash-es"; +import { getWorkspaceBackend } from "./queryManagement/backends/getWorkspaceBackend"; +import type { GitQueryRef, SparqlQueryRef } from "./queryManagement/types"; import "./TabContextMenu.scss"; export interface TabContextConfig { name: string; @@ -17,6 +19,7 @@ export default class TabContextMenu { private duplicateTabEl!: HTMLElement; private saveManagedQueryEl!: HTMLElement; private saveAsRqFileEl!: HTMLElement; + private copyManagedQueryUriEl!: HTMLElement; private closeTabEl!: HTMLElement; private closeOtherTabsEl!: HTMLElement; private reOpenOldTab!: HTMLElement; @@ -55,6 +58,8 @@ export default class TabContextMenu { this.saveAsRqFileEl = this.getMenuItemEl("Save as .rq file"); + this.copyManagedQueryUriEl = this.getMenuItemEl("Copy URI"); + this.closeTabEl = this.getMenuItemEl("Close Tab"); this.closeOtherTabsEl = this.getMenuItemEl("Close other tabs"); @@ -67,6 +72,7 @@ export default class TabContextMenu { dropDownList.appendChild(this.duplicateTabEl); dropDownList.appendChild(this.saveManagedQueryEl); dropDownList.appendChild(this.saveAsRqFileEl); + dropDownList.appendChild(this.copyManagedQueryUriEl); // Add divider dropDownList.appendChild(document.createElement("hr")); dropDownList.appendChild(this.closeTabEl); @@ -125,6 +131,42 @@ export default class TabContextMenu { this.closeConfigMenu(); }; + // Copy URI for managed query tabs + const meta = tab?.getManagedQueryMetadata(); + if (meta) { + const workspaceConfig = this.yasgui.persistentConfig?.getWorkspaces().find((w) => w.id === meta.workspaceId); + if (workspaceConfig) { + const queryId = + meta.backendType === "git" + ? (meta.queryRef as GitQueryRef).path + : (meta.queryRef as SparqlQueryRef).managedQueryIri; + if (queryId) { + const backend = getWorkspaceBackend(workspaceConfig, { + persistentConfig: this.yasgui.persistentConfig, + }); + const uri = backend.getQueryUri?.(queryId); + if (uri) { + this.copyManagedQueryUriEl.onclick = async () => { + try { + await navigator.clipboard.writeText(uri); + } catch { + window.prompt("Copy this URI:", uri); + } + this.closeConfigMenu(); + }; + } else { + addClass(this.copyManagedQueryUriEl, "disabled"); + } + } else { + addClass(this.copyManagedQueryUriEl, "disabled"); + } + } else { + addClass(this.copyManagedQueryUriEl, "disabled"); + } + } else { + addClass(this.copyManagedQueryUriEl, "disabled"); + } + // Close tab functionality this.closeTabEl.onclick = () => tab?.close(); diff --git a/packages/yasgui/src/queryManagement/QueryBrowser.scss b/packages/yasgui/src/queryManagement/QueryBrowser.scss index 34f85fec..50b1cc43 100644 --- a/packages/yasgui/src/queryManagement/QueryBrowser.scss +++ b/packages/yasgui/src/queryManagement/QueryBrowser.scss @@ -325,6 +325,91 @@ white-space: nowrap; } + &__context-menu { + position: fixed; + z-index: 10001; + list-style: none; + padding: 4px 0; + margin: 0; + min-width: 140px; + background: var(--yasgui-bg-primary, white); + border: 1px solid var(--yasgui-border-color, #e0e0e0); + border-radius: 4px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + } + + &__context-menu-item { + padding: 7px 16px; + cursor: pointer; + white-space: nowrap; + color: var(--yasgui-text-primary, #000); + font-size: 13px; + line-height: 1.4; + + &:hover { + background: var(--yasgui-bg-secondary, #f5f5f5); + color: var(--yasgui-button-hover, #000); + } + + &--danger { + color: var(--yasgui-error-color, #c00); + + &:hover { + background: var(--yasgui-bg-secondary, #f5f5f5); + color: var(--yasgui-error-color, #c00); + } + } + } + + &__rename-input { + flex: 1; + min-width: 0; + font: inherit; + font-size: 13px; + color: var(--yasgui-text-primary, #000); + background: var(--yasgui-bg-primary, white); + border: 1px solid var(--yasgui-accent-color, #337ab7); + border-radius: 3px; + padding: 1px 4px; + outline: none; + box-shadow: 0 0 0 2px rgba(51, 122, 183, 0.25); + } + + &__inline-confirm { + flex: 1; + font-size: 13px; + color: var(--yasgui-text-primary, #000); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &__inline-confirm-btn { + appearance: none; + background: transparent; + border: 1px solid var(--yasgui-border-color, #e0e0e0); + border-radius: 3px; + padding: 1px 8px; + font: inherit; + font-size: 12px; + cursor: pointer; + color: var(--yasgui-text-primary, #000); + flex-shrink: 0; + + &:hover { + background: var(--yasgui-bg-secondary, #f5f5f5); + } + + &--danger { + color: var(--yasgui-error-color, #c00); + border-color: var(--yasgui-error-color, #c00); + + &:hover { + background: rgba(204, 0, 0, 0.08); + } + } + } + &__tree-meta { padding: 4px 8px; font-size: 13px; diff --git a/packages/yasgui/src/queryManagement/QueryBrowser.ts b/packages/yasgui/src/queryManagement/QueryBrowser.ts index 6997137c..7fa9c8b8 100644 --- a/packages/yasgui/src/queryManagement/QueryBrowser.ts +++ b/packages/yasgui/src/queryManagement/QueryBrowser.ts @@ -50,6 +50,9 @@ export default class QueryBrowser { private lastPointerPos: { x: number; y: number } | undefined; private folderPickerModal?: SaveManagedQueryModal; + private queryContextMenuEl?: HTMLElement; + private queryContextMenuCleanup?: () => void; + private entrySignature(entry: FolderEntry): string { const parent = entry.parentId || ""; // Include label + parent so renames/moves force a re-render. @@ -235,6 +238,7 @@ export default class QueryBrowser { removeClass(this.rootEl, "open"); this.rootEl.setAttribute("aria-hidden", "true"); this.rootEl.style.display = "none"; + this.closeQueryContextMenu(); if (this.openerEl) { this.openerEl.focus(); @@ -571,258 +575,459 @@ export default class QueryBrowser { tab.setManagedQueryMetadata(managedMetadata); } - private addQueryRowActions(row: HTMLElement, backend: ReturnType, entry: FolderEntry) { - const actions = document.createElement("span"); - addClass(actions, "yasgui-query-browser__actions"); + private closeQueryContextMenu() { + if (this.queryContextMenuEl) { + this.queryContextMenuEl.remove(); + this.queryContextMenuEl = undefined; + } + if (this.queryContextMenuCleanup) { + this.queryContextMenuCleanup(); + this.queryContextMenuCleanup = undefined; + } + } - if (entry.kind === "folder") { - if (backend.renameFolder) { - const renameBtn = document.createElement("button"); - renameBtn.type = "button"; - addClass(renameBtn, "yasgui-query-browser__action"); - renameBtn.textContent = "Rename"; - renameBtn.setAttribute("aria-label", `Rename folder ${entry.label}`); - renameBtn.addEventListener("click", async (e) => { + private startInlineRename(row: HTMLElement, currentLabel: string): Promise { + return new Promise((resolve) => { + const labelEl = row.querySelector(".yasgui-query-browser__tree-label"); + if (!labelEl) { + resolve(null); + return; + } + + const input = document.createElement("input"); + input.type = "text"; + input.value = currentLabel; + addClass(input, "yasgui-query-browser__rename-input"); + + let settled = false; + const finish = (value: string | null) => { + if (settled) return; + settled = true; + input.remove(); + labelEl.style.display = ""; + resolve(value); + }; + + input.addEventListener("click", (e) => e.stopPropagation()); + input.addEventListener("keydown", (e) => { + e.stopPropagation(); + if (e.key === "Enter") { e.preventDefault(); - e.stopPropagation(); + finish(input.value.trim() || null); + } else if (e.key === "Escape") { + e.preventDefault(); + finish(null); + } + }); + input.addEventListener("blur", () => finish(null)); + + labelEl.style.display = "none"; + labelEl.insertAdjacentElement("afterend", input); + input.focus(); + input.select(); + }); + } - const next = window.prompt("Rename folder", entry.label); + private showInlineConfirm(row: HTMLElement, message: string): Promise { + return new Promise((resolve) => { + const savedChildren = Array.from(row.childNodes); + while (row.firstChild) row.removeChild(row.firstChild); + + const confirmEl = document.createElement("span"); + addClass(confirmEl, "yasgui-query-browser__inline-confirm"); + confirmEl.textContent = message; + confirmEl.addEventListener("click", (e) => e.stopPropagation()); + + const yes = document.createElement("button"); + yes.type = "button"; + yes.textContent = "Yes"; + addClass(yes, "yasgui-query-browser__inline-confirm-btn"); + addClass(yes, "yasgui-query-browser__inline-confirm-btn--danger"); + + const no = document.createElement("button"); + no.type = "button"; + no.textContent = "No"; + addClass(no, "yasgui-query-browser__inline-confirm-btn"); + + let settled = false; + const finish = (value: boolean) => { + if (settled) return; + settled = true; + while (row.firstChild) row.removeChild(row.firstChild); + for (const child of savedChildren) row.appendChild(child); + resolve(value); + }; + + yes.addEventListener("click", (e) => { + e.stopPropagation(); + finish(true); + }); + no.addEventListener("click", (e) => { + e.stopPropagation(); + finish(false); + }); + + row.appendChild(confirmEl); + row.appendChild(document.createTextNode(" ")); + row.appendChild(yes); + row.appendChild(document.createTextNode(" ")); + row.appendChild(no); + no.focus(); + }); + } + + private openQueryContextMenu( + event: MouseEvent, + backend: ReturnType, + entry: FolderEntry, + row: HTMLElement, + ) { + event.preventDefault(); + event.stopPropagation(); + + this.closeQueryContextMenu(); + + const menu = document.createElement("ul"); + addClass(menu, "yasgui-query-browser__context-menu"); + + const makeItem = (label: string, handler: () => void | Promise, isDanger = false) => { + const li = document.createElement("li"); + addClass(li, "yasgui-query-browser__context-menu-item"); + if (isDanger) addClass(li, "yasgui-query-browser__context-menu-item--danger"); + li.textContent = label; + li.addEventListener("click", async (e) => { + e.stopPropagation(); + this.closeQueryContextMenu(); + await handler(); + }); + return li; + }; + + // Copy URI + if (backend.getQueryUri) { + const uri = backend.getQueryUri(entry.id); + if (uri) { + menu.appendChild( + makeItem("Copy URI", async () => { + try { + await navigator.clipboard.writeText(uri); + } catch { + window.prompt("Copy this URI:", uri); + } + }), + ); + } + } + + // Rename + if (backend.renameQuery) { + const renameQuery = backend.renameQuery.bind(backend); + menu.appendChild( + makeItem("Rename", async () => { + const next = await this.startInlineRename(row, entry.label); if (!next) return; const trimmed = next.trim(); if (!trimmed || trimmed === entry.label) return; + const gitRenameInfo = (() => { + if (backend.type !== "git") return undefined; + const parts = entry.id.split("/").filter(Boolean); + parts.pop(); + const folderPrefix = parts.join("/"); + const safe = trimmed.replace(/[\\/]/g, "-"); + const newFilename = normalizeQueryFilename(safe); + const newPath = folderPrefix ? `${folderPrefix}/${newFilename}` : newFilename; + return { oldPath: entry.id, newPath }; + })(); + try { - await backend.renameFolder!(entry.id, trimmed); + await renameQuery(entry.id, trimmed); + + if (gitRenameInfo && gitRenameInfo.newPath && gitRenameInfo.oldPath) { + for (const tab of Object.values(this.yasgui._tabs)) { + const meta = (tab as any).getManagedQueryMetadata?.() as ManagedTabMetadata | undefined; + if (!meta) continue; + if (meta.backendType !== "git") continue; + if (meta.workspaceId !== this.selectedWorkspaceId) continue; + const currentPath = (meta.queryRef as any)?.path as string | undefined; + if (currentPath !== gitRenameInfo.oldPath) continue; + + try { + const read = await backend.readQuery(gitRenameInfo.newPath); + const lastSavedTextHash = hashQueryText(read.queryText); + const lastSavedVersionRef = this.versionRefFromVersionTag("git", read.versionTag); + + (tab as any).setManagedQueryMetadata?.({ + ...meta, + queryRef: { ...(meta.queryRef as any), path: gitRenameInfo.newPath }, + lastSavedTextHash, + lastSavedVersionRef, + }); + (tab as any).setName?.(trimmed); + } catch { + // Best-effort: if refreshing metadata fails, the Query Browser still reflects the rename. + } + } + } + + this.queryPreviewById.delete(entry.id); this.folderEntriesById.clear(); this.invalidateRenderCache(); await this.refresh(); } catch (err) { - window.alert(asWorkspaceBackendError(err).message); + this.setStatus(asWorkspaceBackendError(err).message); } - }); - actions.appendChild(renameBtn); - } - - if (backend.deleteFolder) { - const deleteBtn = document.createElement("button"); - deleteBtn.type = "button"; - addClass(deleteBtn, "yasgui-query-browser__action"); - addClass(deleteBtn, "yasgui-query-browser__action--danger"); - deleteBtn.textContent = "Delete"; - deleteBtn.setAttribute("aria-label", `Delete folder ${entry.label}`); - deleteBtn.addEventListener("click", async (e) => { - e.preventDefault(); - e.stopPropagation(); + }), + ); + } - const ok = window.confirm(`Delete folder '${entry.label}' and everything inside it? This cannot be undone.`); - if (!ok) return; + // Move + if (backend.moveQuery) { + const moveQuery = backend.moveQuery.bind(backend); + menu.appendChild( + makeItem("Move", async () => { + const currentFolderPath = entry.parentId || ""; + if (!this.folderPickerModal) this.folderPickerModal = new SaveManagedQueryModal(this.yasgui); + const newFolderPath = await this.folderPickerModal.showFolderPickerOnly( + this.selectedWorkspaceId!, + currentFolderPath, + ); + if (newFolderPath === undefined) return; + if (newFolderPath === currentFolderPath) return; try { - await backend.deleteFolder!(entry.id); + const newQueryId = await moveQuery(entry.id, newFolderPath); + + if (backend.type === "git" && newQueryId !== entry.id) { + for (const tab of Object.values(this.yasgui._tabs)) { + const meta = (tab as any).getManagedQueryMetadata?.() as ManagedTabMetadata | undefined; + if (!meta) continue; + if (meta.backendType !== "git") continue; + if (meta.workspaceId !== this.selectedWorkspaceId) continue; + const currentPath = (meta.queryRef as any)?.path as string | undefined; + if (currentPath !== entry.id) continue; + + try { + const read = await backend.readQuery(newQueryId); + const lastSavedTextHash = hashQueryText(read.queryText); + const lastSavedVersionRef = this.versionRefFromVersionTag("git", read.versionTag); + + (tab as any).setManagedQueryMetadata?.({ + ...meta, + queryRef: { ...(meta.queryRef as any), path: newQueryId }, + lastSavedTextHash, + lastSavedVersionRef, + }); + } catch { + // Best-effort: if refreshing metadata fails, the Query Browser still reflects the move. + } + } + } + + this.queryPreviewById.delete(entry.id); this.folderEntriesById.clear(); this.invalidateRenderCache(); await this.refresh(); } catch (err) { - window.alert(asWorkspaceBackendError(err).message); + this.setStatus(asWorkspaceBackendError(err).message); } - }); - actions.appendChild(deleteBtn); - } + }), + ); + } - if (actions.childElementCount > 0) row.appendChild(actions); - return; + // Delete + if (backend.deleteQuery) { + const deleteQuery = backend.deleteQuery.bind(backend); + menu.appendChild( + makeItem( + "Delete", + async () => { + const ok = await this.showInlineConfirm(row, `Delete '${entry.label}'?`); + if (!ok) return; + + try { + await deleteQuery(entry.id); + this.queryPreviewById.delete(entry.id); + this.folderEntriesById.clear(); + this.invalidateRenderCache(); + await this.refresh(); + } catch (err) { + this.setStatus(asWorkspaceBackendError(err).message); + } + }, + true, + ), + ); } - if (entry.kind !== "query") return; + if (!menu.childElementCount) return; + + this.rootEl.appendChild(menu); + this.queryContextMenuEl = menu; + + // Position near the cursor, clamping to the viewport. + const VIEWPORT_PADDING = 4; + menu.style.position = "fixed"; + menu.style.zIndex = "10001"; + menu.style.left = `${event.clientX}px`; + menu.style.top = `${event.clientY}px`; + requestAnimationFrame(() => { + const rect = menu.getBoundingClientRect(); + if (rect.right > window.innerWidth - VIEWPORT_PADDING) { + menu.style.left = `${Math.max(VIEWPORT_PADDING, event.clientX - rect.width)}px`; + } + if (rect.bottom > window.innerHeight - VIEWPORT_PADDING) { + menu.style.top = `${Math.max(VIEWPORT_PADDING, event.clientY - rect.height)}px`; + } + }); - if (backend.renameQuery) { - const renameBtn = document.createElement("button"); - renameBtn.type = "button"; - addClass(renameBtn, "yasgui-query-browser__action"); - renameBtn.textContent = "Rename"; - renameBtn.setAttribute("aria-label", `Rename ${entry.label}`); - renameBtn.addEventListener("click", async (e) => { - e.preventDefault(); + const onOutsideClick = (e: MouseEvent) => { + if (!this.queryContextMenuEl) return; + if (!menu.contains(e.target as Node)) this.closeQueryContextMenu(); + }; + const onEsc = (e: KeyboardEvent) => { + if (e.key === "Escape") { e.stopPropagation(); + this.closeQueryContextMenu(); + } + }; - const next = window.prompt("Rename query", entry.label); - if (!next) return; - const trimmed = next.trim(); - if (!trimmed || trimmed === entry.label) return; - - // For git workspaces we can deterministically compute the new path, so we can - // also update any already-open managed tabs that reference this query. - const gitRenameInfo = (() => { - if (backend.type !== "git") return undefined; - const parts = entry.id.split("/").filter(Boolean); - parts.pop(); - const folderPrefix = parts.join("/"); - const safe = trimmed.replace(/[\\/]/g, "-"); - const newFilename = normalizeQueryFilename(safe); - const newPath = folderPrefix ? `${folderPrefix}/${newFilename}` : newFilename; - return { oldPath: entry.id, newPath }; - })(); - - // Show loading state - const originalText = renameBtn.textContent; - renameBtn.disabled = true; - renameBtn.textContent = "Renaming…"; - addClass(renameBtn, "loading"); - - try { - await backend.renameQuery!(entry.id, trimmed); - - if (gitRenameInfo && gitRenameInfo.newPath && gitRenameInfo.oldPath) { - for (const tab of Object.values(this.yasgui._tabs)) { - const meta = (tab as any).getManagedQueryMetadata?.() as ManagedTabMetadata | undefined; - if (!meta) continue; - if (meta.backendType !== "git") continue; - if (meta.workspaceId !== this.selectedWorkspaceId) continue; - const currentPath = (meta.queryRef as any)?.path as string | undefined; - if (currentPath !== gitRenameInfo.oldPath) continue; - - try { - const read = await backend.readQuery(gitRenameInfo.newPath); - const lastSavedTextHash = hashQueryText(read.queryText); - const lastSavedVersionRef = this.versionRefFromVersionTag("git", read.versionTag); - - (tab as any).setManagedQueryMetadata?.({ - ...meta, - queryRef: { ...(meta.queryRef as any), path: gitRenameInfo.newPath }, - lastSavedTextHash, - lastSavedVersionRef, - }); - (tab as any).setName?.(trimmed); - } catch { - // Best-effort: if refreshing metadata fails, the Query Browser still reflects the rename. - } - } - } + // Use requestAnimationFrame to defer listener registration past the current event cycle, + // ensuring the contextmenu event that opened the menu is not immediately caught. + requestAnimationFrame(() => { + document.addEventListener("click", onOutsideClick); + document.addEventListener("keydown", onEsc); + }); - this.queryPreviewById.delete(entry.id); - this.folderEntriesById.clear(); - this.invalidateRenderCache(); - await this.refresh(); - } catch (err) { - // Restore button state on error - renameBtn.disabled = false; - renameBtn.textContent = originalText || "Rename"; - removeClass(renameBtn, "loading"); - window.alert(asWorkspaceBackendError(err).message); - } - }); - actions.appendChild(renameBtn); - } + this.queryContextMenuCleanup = () => { + document.removeEventListener("click", onOutsideClick); + document.removeEventListener("keydown", onEsc); + }; + } - if (backend.moveQuery) { - const moveBtn = document.createElement("button"); - moveBtn.type = "button"; - addClass(moveBtn, "yasgui-query-browser__action"); - moveBtn.textContent = "Move"; - moveBtn.setAttribute("aria-label", `Move ${entry.label} to a different folder`); - moveBtn.addEventListener("click", async (e) => { - e.preventDefault(); + private openFolderContextMenu( + event: MouseEvent, + backend: ReturnType, + entry: FolderEntry, + row: HTMLElement, + ) { + event.preventDefault(); + event.stopPropagation(); + + this.closeQueryContextMenu(); + + const menu = document.createElement("ul"); + addClass(menu, "yasgui-query-browser__context-menu"); + + const makeItem = (label: string, handler: () => void | Promise, isDanger = false) => { + const li = document.createElement("li"); + addClass(li, "yasgui-query-browser__context-menu-item"); + if (isDanger) addClass(li, "yasgui-query-browser__context-menu-item--danger"); + li.textContent = label; + li.addEventListener("click", async (e) => { e.stopPropagation(); + this.closeQueryContextMenu(); + await handler(); + }); + return li; + }; - const currentFolderPath = entry.parentId || ""; - if (!this.folderPickerModal) this.folderPickerModal = new SaveManagedQueryModal(this.yasgui); - const newFolderPath = await this.folderPickerModal.showFolderPickerOnly( - this.selectedWorkspaceId!, - currentFolderPath, - ); - if (newFolderPath === undefined) return; - if (newFolderPath === currentFolderPath) return; - - // Show loading state - const originalText = moveBtn.textContent; - moveBtn.disabled = true; - moveBtn.textContent = "Moving…"; - addClass(moveBtn, "loading"); - - try { - const newQueryId = await backend.moveQuery!(entry.id, newFolderPath); - - // For git workspaces: update any already-open managed tabs referencing the old path. - if (backend.type === "git" && newQueryId !== entry.id) { - for (const tab of Object.values(this.yasgui._tabs)) { - const meta = (tab as any).getManagedQueryMetadata?.() as ManagedTabMetadata | undefined; - if (!meta) continue; - if (meta.backendType !== "git") continue; - if (meta.workspaceId !== this.selectedWorkspaceId) continue; - const currentPath = (meta.queryRef as any)?.path as string | undefined; - if (currentPath !== entry.id) continue; - - try { - const read = await backend.readQuery(newQueryId); - const lastSavedTextHash = hashQueryText(read.queryText); - const lastSavedVersionRef = this.versionRefFromVersionTag("git", read.versionTag); - - (tab as any).setManagedQueryMetadata?.({ - ...meta, - queryRef: { ...(meta.queryRef as any), path: newQueryId }, - lastSavedTextHash, - lastSavedVersionRef, - }); - } catch { - // Best-effort: if refreshing metadata fails, the Query Browser still reflects the move. - } - } + if (backend.renameFolder) { + menu.appendChild( + makeItem("Rename", async () => { + const next = await this.startInlineRename(row, entry.label); + if (!next) return; + const trimmed = next.trim(); + if (!trimmed || trimmed === entry.label) return; + + try { + await backend.renameFolder!(entry.id, trimmed); + this.folderEntriesById.clear(); + this.invalidateRenderCache(); + await this.refresh(); + } catch (err) { + this.setStatus(asWorkspaceBackendError(err).message); } + }), + ); + } - this.queryPreviewById.delete(entry.id); - this.folderEntriesById.clear(); - this.invalidateRenderCache(); - await this.refresh(); - } catch (err) { - // Restore button state on error - moveBtn.disabled = false; - moveBtn.textContent = originalText || "Move"; - removeClass(moveBtn, "loading"); - window.alert(asWorkspaceBackendError(err).message); - } - }); - actions.appendChild(moveBtn); + if (backend.deleteFolder) { + menu.appendChild( + makeItem( + "Delete", + async () => { + const ok = await this.showInlineConfirm(row, `Delete folder '${entry.label}' and everything inside it?`); + if (!ok) return; + + try { + await backend.deleteFolder!(entry.id); + this.folderEntriesById.clear(); + this.invalidateRenderCache(); + await this.refresh(); + } catch (err) { + this.setStatus(asWorkspaceBackendError(err).message); + } + }, + true, + ), + ); } - if (backend.deleteQuery) { - const deleteBtn = document.createElement("button"); - deleteBtn.type = "button"; - addClass(deleteBtn, "yasgui-query-browser__action"); - addClass(deleteBtn, "yasgui-query-browser__action--danger"); - deleteBtn.textContent = "Delete"; - deleteBtn.setAttribute("aria-label", `Delete ${entry.label}`); - deleteBtn.addEventListener("click", async (e) => { - e.preventDefault(); + if (!menu.childElementCount) return; + + this.rootEl.appendChild(menu); + this.queryContextMenuEl = menu; + + const VIEWPORT_PADDING = 4; + menu.style.position = "fixed"; + menu.style.zIndex = "10001"; + menu.style.left = `${event.clientX}px`; + menu.style.top = `${event.clientY}px`; + requestAnimationFrame(() => { + const rect = menu.getBoundingClientRect(); + if (rect.right > window.innerWidth - VIEWPORT_PADDING) { + menu.style.left = `${Math.max(VIEWPORT_PADDING, event.clientX - rect.width)}px`; + } + if (rect.bottom > window.innerHeight - VIEWPORT_PADDING) { + menu.style.top = `${Math.max(VIEWPORT_PADDING, event.clientY - rect.height)}px`; + } + }); + + const onOutsideClick = (e: MouseEvent) => { + if (!this.queryContextMenuEl) return; + if (!menu.contains(e.target as Node)) this.closeQueryContextMenu(); + }; + const onEsc = (e: KeyboardEvent) => { + if (e.key === "Escape") { e.stopPropagation(); - const ok = window.confirm(`Delete '${entry.label}'? This cannot be undone.`); - if (!ok) return; - - // Show loading state - const originalText = deleteBtn.textContent; - deleteBtn.disabled = true; - deleteBtn.textContent = "Deleting…"; - addClass(deleteBtn, "loading"); - - try { - await backend.deleteQuery!(entry.id); - this.queryPreviewById.delete(entry.id); - this.folderEntriesById.clear(); - this.invalidateRenderCache(); - await this.refresh(); - } catch (err) { - // Restore button state on error - deleteBtn.disabled = false; - deleteBtn.textContent = originalText || "Delete"; - removeClass(deleteBtn, "loading"); - window.alert(asWorkspaceBackendError(err).message); - } + this.closeQueryContextMenu(); + } + }; + + requestAnimationFrame(() => { + document.addEventListener("click", onOutsideClick); + document.addEventListener("keydown", onEsc); + }); + + this.queryContextMenuCleanup = () => { + document.removeEventListener("click", onOutsideClick); + document.removeEventListener("keydown", onEsc); + }; + } + + private addQueryRowActions(row: HTMLElement, backend: ReturnType, entry: FolderEntry) { + if (entry.kind === "folder") { + row.addEventListener("contextmenu", (e) => this.openFolderContextMenu(e, backend, entry, row), { + passive: false, }); - actions.appendChild(deleteBtn); + return; } - if (actions.childElementCount > 0) { - row.appendChild(actions); - } + if (entry.kind !== "query") return; + + // Query actions are shown in a right-click context menu to preserve space for the query name. + row.addEventListener("contextmenu", (e) => this.openQueryContextMenu(e, backend, entry, row), { passive: false }); } private renderFlatEntries(backend: ReturnType, entries: FolderEntry[]) { diff --git a/packages/yasgui/src/queryManagement/backends/GitWorkspaceBackend.ts b/packages/yasgui/src/queryManagement/backends/GitWorkspaceBackend.ts index b6f8d27f..c8644a63 100644 --- a/packages/yasgui/src/queryManagement/backends/GitWorkspaceBackend.ts +++ b/packages/yasgui/src/queryManagement/backends/GitWorkspaceBackend.ts @@ -2,6 +2,7 @@ import type { GitWorkspaceConfig, FolderEntry, ReadResult, VersionInfo, WriteQue import type { WorkspaceBackend } from "./WorkspaceBackend"; import { WorkspaceBackendError } from "./errors"; import { normalizeQueryFilename } from "../normalizeQueryFilename"; +import { parseGitRemote } from "./gitRemote"; export interface GitProviderClient { validateAccess(config: GitWorkspaceConfig): Promise; @@ -112,4 +113,42 @@ export default class GitWorkspaceBackend implements WorkspaceBackend { await this.client.deleteQuery(this.config, queryId); } + + getQueryUri(queryId: string): string | undefined { + try { + const { host, repoPath } = parseGitRemote(this.config.remoteUrl); + const parts = repoPath.split("/").filter(Boolean); + const [owner, repoName] = parts; + if (!owner || !repoName) return undefined; + + const filePath = [this.config.rootPath, queryId].filter(Boolean).join("/"); + const branch = this.config.branch?.trim() || "HEAD"; + const provider = this.config.provider; + + const isGithub = + provider === "github" || + ((!provider || provider === "auto") && + (host === "github.com" || (host.includes("github") && !host.includes("gitlab")))); + if (isGithub) { + return `https://${host}/${owner}/${repoName}/blob/${encodeURIComponent(branch)}/${filePath}`; + } + + const isGitlab = + provider === "gitlab" || + ((!provider || provider === "auto") && (host === "gitlab.com" || host.includes("gitlab"))); + if (isGitlab) { + return `https://${host}/${repoPath}/-/blob/${encodeURIComponent(branch)}/${filePath}`; + } + + const isBitbucket = provider === "bitbucket" || ((!provider || provider === "auto") && host === "bitbucket.org"); + if (isBitbucket) { + return `https://bitbucket.org/${owner}/${repoName}/src/${encodeURIComponent(branch)}/${filePath}`; + } + + // Gitea (and other self-hosted providers) + return `https://${host}/${owner}/${repoName}/src/branch/${encodeURIComponent(branch)}/${filePath}`; + } catch { + return undefined; + } + } } diff --git a/packages/yasgui/src/queryManagement/backends/SparqlWorkspaceBackend.ts b/packages/yasgui/src/queryManagement/backends/SparqlWorkspaceBackend.ts index 056a056d..e6952dc3 100644 --- a/packages/yasgui/src/queryManagement/backends/SparqlWorkspaceBackend.ts +++ b/packages/yasgui/src/queryManagement/backends/SparqlWorkspaceBackend.ts @@ -795,4 +795,9 @@ LIMIT 1`; description: row.description?.value, }; } + + getQueryUri(queryId: string): string | undefined { + // In a SPARQL workspace the queryId is the managed-query IRI itself. + return queryId || undefined; + } } diff --git a/packages/yasgui/src/queryManagement/backends/WorkspaceBackend.ts b/packages/yasgui/src/queryManagement/backends/WorkspaceBackend.ts index 793ae9d1..c43edec4 100644 --- a/packages/yasgui/src/queryManagement/backends/WorkspaceBackend.ts +++ b/packages/yasgui/src/queryManagement/backends/WorkspaceBackend.ts @@ -44,4 +44,12 @@ export interface WorkspaceBackend { * Optional: Delete a folder and everything inside it (recursive). */ deleteFolder?(folderId: string): Promise; + + /** + * Optional: Return a URI for the given query. + * - For SPARQL workspaces this is the managed-query IRI stored in the triplestore. + * - For git workspaces this is the browser-accessible URL to the file in the repository. + * Returns `undefined` when no meaningful URI can be determined. + */ + getQueryUri?(queryId: string): string | undefined; } diff --git a/test/unit/query-management-get-query-uri-test.ts b/test/unit/query-management-get-query-uri-test.ts new file mode 100644 index 00000000..ef7a53c7 --- /dev/null +++ b/test/unit/query-management-get-query-uri-test.ts @@ -0,0 +1,113 @@ +import * as chai from "chai"; +import { describe, it } from "mocha"; + +import GitWorkspaceBackend from "../../packages/yasgui/src/queryManagement/backends/GitWorkspaceBackend.js"; +import SparqlWorkspaceBackend from "../../packages/yasgui/src/queryManagement/backends/SparqlWorkspaceBackend.js"; +import type { GitWorkspaceConfig, SparqlWorkspaceConfig } from "../../packages/yasgui/src/queryManagement/types.js"; + +const expect = chai.expect; + +function makeGitConfig(overrides: Partial = {}): GitWorkspaceConfig { + return { + id: "ws-git", + label: "Git WS", + type: "git", + remoteUrl: "https://github.com/owner/repo.git", + branch: "main", + rootPath: "", + auth: { type: "pat", token: "tok" }, + ...overrides, + }; +} + +describe("Query management - getQueryUri", () => { + describe("GitWorkspaceBackend", () => { + it("generates a GitHub blob URL", () => { + const backend = new GitWorkspaceBackend(makeGitConfig()); + const uri = backend.getQueryUri("folder/query.rq"); + expect(uri).to.equal("https://github.com/owner/repo/blob/main/folder/query.rq"); + }); + + it("includes rootPath in the file path", () => { + const backend = new GitWorkspaceBackend(makeGitConfig({ rootPath: "queries" })); + const uri = backend.getQueryUri("sub/query.rq"); + expect(uri).to.equal("https://github.com/owner/repo/blob/main/queries/sub/query.rq"); + }); + + it("generates a GitHub URL for SCP-style remote", () => { + const backend = new GitWorkspaceBackend(makeGitConfig({ remoteUrl: "git@github.com:owner/repo.git" })); + const uri = backend.getQueryUri("query.rq"); + expect(uri).to.equal("https://github.com/owner/repo/blob/main/query.rq"); + }); + + it("generates a GitLab URL for gitlab.com", () => { + const backend = new GitWorkspaceBackend( + makeGitConfig({ remoteUrl: "https://gitlab.com/owner/repo.git", provider: "auto" }), + ); + const uri = backend.getQueryUri("query.rq"); + expect(uri).to.equal("https://gitlab.com/owner/repo/-/blob/main/query.rq"); + }); + + it("generates a Bitbucket URL for bitbucket.org", () => { + const backend = new GitWorkspaceBackend( + makeGitConfig({ remoteUrl: "https://bitbucket.org/workspace/repo.git", provider: "auto" }), + ); + const uri = backend.getQueryUri("query.rq"); + expect(uri).to.equal("https://bitbucket.org/workspace/repo/src/main/query.rq"); + }); + + it("generates a Gitea-style URL for unknown hosts", () => { + const backend = new GitWorkspaceBackend( + makeGitConfig({ remoteUrl: "https://gitea.example.com/owner/repo.git", provider: "gitea" }), + ); + const uri = backend.getQueryUri("query.rq"); + expect(uri).to.equal("https://gitea.example.com/owner/repo/src/branch/main/query.rq"); + }); + + it("uses explicit provider=github over host detection", () => { + const backend = new GitWorkspaceBackend( + makeGitConfig({ remoteUrl: "https://github.example.com/owner/repo.git", provider: "github" }), + ); + const uri = backend.getQueryUri("q.rq"); + expect(uri).to.equal("https://github.example.com/owner/repo/blob/main/q.rq"); + }); + + it("uses explicit provider=gitlab over host detection", () => { + const backend = new GitWorkspaceBackend( + makeGitConfig({ remoteUrl: "https://git.example.com/owner/repo.git", provider: "gitlab" }), + ); + const uri = backend.getQueryUri("q.rq"); + expect(uri).to.equal("https://git.example.com/owner/repo/-/blob/main/q.rq"); + }); + + it("falls back to HEAD when branch is not set", () => { + const backend = new GitWorkspaceBackend(makeGitConfig({ branch: "" })); + const uri = backend.getQueryUri("query.rq"); + expect(uri).to.equal("https://github.com/owner/repo/blob/HEAD/query.rq"); + }); + }); + + describe("SparqlWorkspaceBackend", () => { + function makeSparqlBackend(): SparqlWorkspaceBackend { + const config: SparqlWorkspaceConfig = { + id: "ws-sparql", + label: "SPARQL WS", + type: "sparql", + endpoint: "https://example.com/sparql", + workspaceIri: "https://example.com/workspace", + }; + return new SparqlWorkspaceBackend(config); + } + + it("returns the managed query IRI as the URI", () => { + const backend = makeSparqlBackend(); + const iri = "https://example.com/workspace_mq_abc123"; + expect(backend.getQueryUri!(iri)).to.equal(iri); + }); + + it("returns undefined for an empty queryId", () => { + const backend = makeSparqlBackend(); + expect(backend.getQueryUri!("")).to.equal(undefined); + }); + }); +});