Skip to content

Commit a0dfead

Browse files
CopilotMathiasVDA
andauthored
feat: add Move button to change a managed query's folder (fixes #141)
* Initial plan * feat: add Move button to change a managed query's folder - Add moveQuery?(queryId, newFolderId): Promise<string> to WorkspaceBackend interface - Implement moveQuery in GitWorkspaceBackend (read+write to new path+delete old) - Implement moveQuery in SparqlWorkspaceBackend (update dcterms:isPartOf triple, ensure folder exists) - Implement moveQuery in InMemoryWorkspaceBackend (for tests) - Add showFolderPickerOnly() to SaveManagedQueryModal for folder-only selection - Add Move button in QueryBrowser that opens folder picker and calls moveQuery - Update open git tabs when a query is moved to preserve path metadata - Add 3 unit tests for moveQuery behaviour Co-authored-by: MathiasVDA <15101339+MathiasVDA@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: MathiasVDA <15101339+MathiasVDA@users.noreply.github.com>
1 parent f86ed34 commit a0dfead

7 files changed

Lines changed: 316 additions & 3 deletions

File tree

packages/yasgui/src/queryManagement/QueryBrowser.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { getEndpointToAutoSwitch } from "./openManagedQuery";
88
import { hashQueryText } from "./textHash";
99
import type { BackendType, VersionRef, ManagedTabMetadata } from "./types";
1010
import { normalizeQueryFilename } from "./normalizeQueryFilename";
11+
import SaveManagedQueryModal from "./SaveManagedQueryModal";
1112

1213
import "./QueryBrowser.scss";
1314

@@ -47,6 +48,7 @@ export default class QueryBrowser {
4748
private lastRenderedSignature: string | undefined;
4849

4950
private lastPointerPos: { x: number; y: number } | undefined;
51+
private folderPickerModal?: SaveManagedQueryModal;
5052

5153
private entrySignature(entry: FolderEntry): string {
5254
const parent = entry.parentId || "";
@@ -712,6 +714,76 @@ export default class QueryBrowser {
712714
actions.appendChild(renameBtn);
713715
}
714716

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+
715787
if (backend.deleteQuery) {
716788
const deleteBtn = document.createElement("button");
717789
deleteBtn.type = "button";

packages/yasgui/src/queryManagement/SaveManagedQueryModal.ts

Lines changed: 82 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ export default class SaveManagedQueryModal {
4141
private saveBtn!: HTMLButtonElement;
4242
private cancelBtn!: HTMLButtonElement;
4343
private saveBtnOriginalText = "Save";
44+
private titleEl!: HTMLHeadingElement;
45+
private workspaceRowEl!: HTMLDivElement;
4446

4547
private filenameTouched = false;
4648
private folderPickerOpen = false;
@@ -49,6 +51,8 @@ export default class SaveManagedQueryModal {
4951

5052
private resolve?: (value: SaveManagedQueryModalResult) => void;
5153
private reject?: (reason?: unknown) => void;
54+
private moveResolve?: (value: string | undefined) => void;
55+
private isMoveMode = false;
5256

5357
constructor(yasgui: Yasgui) {
5458
this.yasgui = yasgui;
@@ -65,6 +69,7 @@ export default class SaveManagedQueryModal {
6569

6670
const titleEl = document.createElement("h2");
6771
titleEl.textContent = "Save as managed query";
72+
this.titleEl = titleEl;
6873

6974
const closeBtn = document.createElement("button");
7075
closeBtn.type = "button";
@@ -203,6 +208,7 @@ export default class SaveManagedQueryModal {
203208
this.messageEl.setAttribute("aria-label", "Save message");
204209

205210
const workspaceRow = this.row("Workspace", this.workspaceSelectEl);
211+
this.workspaceRowEl = workspaceRow;
206212
const folderRow = this.folderRow();
207213
this.nameRowEl = this.row("Name", this.nameEl);
208214
this.filenameRowEl = this.row("Filename", this.filenameEl);
@@ -343,9 +349,24 @@ export default class SaveManagedQueryModal {
343349
this.mouseDownOnOverlay = false;
344350
this.close();
345351
this.overlayEl.remove();
346-
this.reject?.(new Error("cancelled"));
347-
this.resolve = undefined;
348-
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 = "";
349370
}
350371

351372
private setLoading(isLoading: boolean) {
@@ -371,6 +392,18 @@ export default class SaveManagedQueryModal {
371392
}
372393

373394
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+
374407
const workspaceId = this.workspaceSelectEl.value;
375408
const name = this.nameEl.value.trim();
376409
const filename = this.filenameEl.value.trim();
@@ -570,4 +603,50 @@ export default class SaveManagedQueryModal {
570603
this.newFolderNameEl.value = "";
571604
void this.refreshFolderPicker();
572605
}
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+
}
573652
}

packages/yasgui/src/queryManagement/backends/GitWorkspaceBackend.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,25 @@ export default class GitWorkspaceBackend implements WorkspaceBackend {
6969
return this.client.deleteQuery(this.config, _queryId);
7070
}
7171

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+
7291
async renameQuery(queryId: string, newLabel: string): Promise<void> {
7392
if (!this.client) throw this.missingClientError();
7493

packages/yasgui/src/queryManagement/backends/InMemoryWorkspaceBackend.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,23 @@ export default class InMemoryWorkspaceBackend implements WorkspaceBackend {
152152
return { queryText: found.queryText, versionTag: found.id };
153153
}
154154

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+
155172
async renameQuery(queryId: string, newLabel: string): Promise<void> {
156173
const trimmed = newLabel.trim();
157174
if (!trimmed) throw new WorkspaceBackendError("UNKNOWN", "New name is required");

packages/yasgui/src/queryManagement/backends/SparqlWorkspaceBackend.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,75 @@ WHERE { OPTIONAL { ${iri(mqIri)} rdfs:label ?oldLabel . } }
422422
await this.sparqlUpdate(update);
423423
}
424424

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+
425494
async deleteQuery(queryId: string): Promise<void> {
426495
const mqIri = this.resolveManagedQueryIri(queryId);
427496
const update = `

packages/yasgui/src/queryManagement/backends/WorkspaceBackend.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ export interface WorkspaceBackend {
2323
*/
2424
renameQuery?(queryId: string, newLabel: string): Promise<void>;
2525

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+
2632
/**
2733
* Optional: Delete a query and its version history.
2834
* Implementations may not support this (e.g., some Git provider clients).

0 commit comments

Comments
 (0)