diff --git a/package-lock.json b/package-lock.json index 8c35d04..2df5720 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.1", "dependencies": { "@intechstudio/grid-protocol": "1.20260828.1315", - "@intechstudio/grid-uikit": "1.20260825.1204", + "@intechstudio/grid-uikit": "1.20260922.1223", "@melt-ui/svelte": "^0.86.6", "firebase": "^12.2.1", "marked": "^12.0.2", @@ -1255,9 +1255,9 @@ } }, "node_modules/@intechstudio/grid-uikit": { - "version": "1.20260825.1204", - "resolved": "https://registry.npmjs.org/@intechstudio/grid-uikit/-/grid-uikit-1.20260825.1204.tgz", - "integrity": "sha512-hLfy/dVbIDTTU/R6Gl3Kkn2K5MsSO97Xww9mx67AHmI8XImH6hA1VxJ/Gu3wki26azPfwW/f28n9nc/KfBSYfQ==", + "version": "1.20260922.1223", + "resolved": "https://registry.npmjs.org/@intechstudio/grid-uikit/-/grid-uikit-1.20260922.1223.tgz", + "integrity": "sha512-GEJICGrecYwLgCBLA+6Luz03O1Jrej13OoHN7mEohpkgsQV2+FW8TTYuSL4Z1eDfbY8Bs1JCrX3IgzXFrg6fCA==", "dependencies": { "@melt-ui/svelte": "^0.86.6", "color-convert": "^2.0.1", diff --git a/package.json b/package.json index 9b9585e..ae8a0dc 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "type": "module", "dependencies": { "@intechstudio/grid-protocol": "1.20260828.1315", - "@intechstudio/grid-uikit": "1.20260825.1204", + "@intechstudio/grid-uikit": "1.20260922.1223", "@melt-ui/svelte": "^0.86.6", "firebase": "^12.2.1", "marked": "^12.0.2", diff --git a/src/lib/actions/tooltip.ts b/src/lib/actions/tooltip.ts deleted file mode 100644 index bce7fdb..0000000 --- a/src/lib/actions/tooltip.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { Action } from "svelte/action"; -import { MoltenTooltip } from "@intechstudio/grid-uikit"; -import { mount } from "svelte"; - -export const tooltip: Action = ( - node: HTMLElement, - options: any, -): any => { - if (typeof options === "undefined") { - return; - } - - const sibling = document.createElement("div"); - node.parentNode?.insertBefore(sibling, node.nextSibling); - - options.referenceElement = node; - - setTimeout(() => { - mount(MoltenTooltip, { target: sibling, props: options }); - }); - - return { - destroy() { - sibling.remove(); - }, - }; -}; diff --git a/src/lib/components/tree/ConfigTree.svelte b/src/lib/components/tree/ConfigTree.svelte index 641d384..0116a48 100644 --- a/src/lib/components/tree/ConfigTree.svelte +++ b/src/lib/components/tree/ConfigTree.svelte @@ -24,7 +24,11 @@ import { TreeItemType } from "./TreeNode.svelte"; import type { AbstractFolderData, AbstractTreeNode } from "./TreeNode.svelte"; import type { TreeProperties } from "./TreeComponent.svelte"; - import type { ContextMenuOptions } from "@intechstudio/grid-uikit"; + import { + type ContextMenuOptions, + MoltenInput, + IconButton, + } from "@intechstudio/grid-uikit"; import TreeFolder from "./TreeFolder.svelte"; @@ -174,18 +178,38 @@ return { root, filteredConfigs }; } - function handleDeleteVirtualDirectory(title: string) { + async function handleDeleteVirtualDirectory(title: string) { const cm = get(config_manager); - for (const config of configs) { - if (config.virtualPath?.includes(title)) { - const newPath = config.virtualPath - .split("/") - .filter((e: string) => e !== title) + const affected = configs.filter((c) => + (c.virtualPath?.split("/") ?? []).includes(title), + ); + try { + for (const config of affected) { + const newPath = config + .virtualPath!.split("/") + .filter((e) => e !== title) .join("/"); - config.virtualPath = newPath === "" ? undefined : newPath; - cm?.saveConfig(config, false); + await cm?.saveConfig( + { ...config, virtualPath: newPath === "" ? undefined : newPath }, + false, + ); } + parentIframeCommunication({ + windowPostMessageName: "sendLogMessage", + dataForParent: { + type: "success", + message: `Deleted virtual directory '${title}'`, + }, + }); + } catch (e) { + parentIframeCommunication({ + windowPostMessageName: "sendLogMessage", + dataForParent: { + type: "fail", + message: `Failed to delete virtual directory '${title}'. ${e}`, + }, + }); } } @@ -233,23 +257,263 @@ }); } + function handleContextMenu(node: AbstractTreeNode) { + const config = (get(node).data as Tree.ItemData).item; + selected_config.set(config); + } + + // ── Inline rename ────────────────────────────────────────────────────────── + + let renamingConfigId: string | null = null; + let renameValue = ""; + let renameInProgress = false; + let renameError: string | null = null; + let renameInput: MoltenInput; + // Set once confirmRename's save resolves, holding the name we're waiting to + // see land back in `configs`. saveConfig's promise only resolves once the + // write completes, not once the file-watcher-driven reload reports it back + // to us - closing the inline editor right away would flash the old name + // for a moment. The reactive block below watches for it to land and only + // then exits edit mode. + let pendingRenameName: string | null = null; + let pendingRenameTimeout: ReturnType | undefined; + + async function startRename(config: Config) { + renamingConfigId = config.id; + renameValue = config.name; + renameError = null; + await tick(); + renameInput?.focus(); + } + + function cancelRename() { + clearTimeout(pendingRenameTimeout); + renamingConfigId = null; + renameValue = ""; + renameError = null; + pendingRenameName = null; + } + + async function confirmRename(config: Config) { + if (renamingConfigId !== config.id) return; + const newName = renameValue.trim(); + if (!newName || newName === config.name) { + cancelRename(); + return; + } + renameInProgress = true; + renameError = null; + try { + const cm = get(config_manager); + await cm?.saveConfig({ ...config, name: newName }, false); + parentIframeCommunication({ + windowPostMessageName: "sendLogMessage", + dataForParent: { + type: "success", + message: `Config '${newName}' saved`, + }, + }); + pendingRenameName = newName; + clearTimeout(pendingRenameTimeout); + // Safety net: if the reload never lands (e.g. watcher hiccup), don't + // leave the editor stuck open forever. + pendingRenameTimeout = setTimeout(() => { + if (renamingConfigId === config.id) cancelRename(); + }, 5000); + } catch (e) { + renameError = String(e); + parentIframeCommunication({ + windowPostMessageName: "sendLogMessage", + dataForParent: { + type: "fail", + message: `Failed to rename '${config.name}'. ${e}`, + }, + }); + } finally { + renameInProgress = false; + } + } + + $: if ( + renamingConfigId && + pendingRenameName && + configs.find((c) => c.id === renamingConfigId)?.name === pendingRenameName + ) { + cancelRename(); + } + + // ── Inline rename (virtual directory) ───────────────────────────────────── + // A virtual directory is just a shared segment string in each config's + // virtualPath (see buildVirtualFolders in ./ConfigTree.ts) - there's no + // single entity to rename, so this rewrites that segment on every config + // that has it, the same way handleDeleteVirtualDirectory removes it. + + let renamingFolderTitle: string | null = null; + let renameFolderValue = ""; + let renameFolderInProgress = false; + let renameFolderError: string | null = null; + let renameFolderInput: MoltenInput; + let folderRenameSaved = false; + let folderRenameTimeout: ReturnType | undefined; + + async function startRenameFolder(title: string) { + renamingFolderTitle = title; + renameFolderValue = title; + renameFolderError = null; + folderRenameSaved = false; + await tick(); + renameFolderInput?.focus(); + } + + function cancelRenameFolder() { + clearTimeout(folderRenameTimeout); + renamingFolderTitle = null; + renameFolderValue = ""; + renameFolderError = null; + folderRenameSaved = false; + } + + async function confirmRenameFolder(oldTitle: string) { + if (renamingFolderTitle !== oldTitle) return; + const newTitle = renameFolderValue.trim(); + if (!newTitle || newTitle === oldTitle) { + cancelRenameFolder(); + return; + } + renameFolderInProgress = true; + renameFolderError = null; + try { + const cm = get(config_manager); + const affected = configs.filter((c) => + c.virtualPath?.split("/").includes(oldTitle), + ); + // Sequential rather than Promise.all: each save round-trips through + // grid-editor's file watcher, and there's no need to have several + // in flight at once for a rename that isn't performance-sensitive. + for (const config of affected) { + const newPath = config + .virtualPath!.split("/") + .map((segment) => (segment === oldTitle ? newTitle : segment)) + .join("/"); + await cm?.saveConfig({ ...config, virtualPath: newPath }, false); + } + parentIframeCommunication({ + windowPostMessageName: "sendLogMessage", + dataForParent: { + type: "success", + message: `Renamed virtual directory '${oldTitle}' to '${newTitle}' (${affected.length} config${affected.length === 1 ? "" : "s"})`, + }, + }); + folderRenameSaved = true; + clearTimeout(folderRenameTimeout); + // Safety net: if the reload never lands, don't leave the editor stuck + // open forever. + folderRenameTimeout = setTimeout(() => { + if (renamingFolderTitle === oldTitle) cancelRenameFolder(); + }, 5000); + } catch (e) { + renameFolderError = String(e); + parentIframeCommunication({ + windowPostMessageName: "sendLogMessage", + dataForParent: { + type: "fail", + message: `Failed to rename virtual directory '${oldTitle}'. ${e}`, + }, + }); + } finally { + renameFolderInProgress = false; + } + } + + $: if ( + renamingFolderTitle && + folderRenameSaved && + !configs.some((c) => + c.virtualPath?.split("/").includes(renamingFolderTitle!), + ) + ) { + cancelRenameFolder(); + } + + // Both rename inputs sit inside melt-ui's tree item