diff --git a/frontend/src/lib/components/common/contextmenu/ContextMenu.svelte b/frontend/src/lib/components/common/contextmenu/ContextMenu.svelte index 8e0f440bde..86a2dc7ccb 100644 --- a/frontend/src/lib/components/common/contextmenu/ContextMenu.svelte +++ b/frontend/src/lib/components/common/contextmenu/ContextMenu.svelte @@ -85,6 +85,11 @@ exclude: getMenuElements, onClickOutside: handlePointerDownOutside }} + onpointerdown={(e) => { + if (e.button === 0 && open.get()) { + close() + } + }} data-context-menu-trigger > {@render children?.()} diff --git a/frontend/src/lib/components/propertyPicker/ObjectViewer.svelte b/frontend/src/lib/components/propertyPicker/ObjectViewer.svelte index b22e1ceeac..75dc1573d3 100644 --- a/frontend/src/lib/components/propertyPicker/ObjectViewer.svelte +++ b/frontend/src/lib/components/propertyPicker/ObjectViewer.svelte @@ -3,17 +3,35 @@ import { copyToClipboard, truncate } from '$lib/utils' - import { createEventDispatcher, tick, untrack, type Snippet } from 'svelte' + import { + createEventDispatcher, + tick, + untrack, + setContext, + getContext, + type Snippet + } from 'svelte' import { computeKey, keepByKeyOrValue } from './utils' import { NEVER_TESTED_THIS_FAR } from '../flows/models' import Portal from '$lib/components/Portal.svelte' import { Button } from '$lib/components/common' - import { Download, PanelRightOpen, Search, TriangleAlertIcon, X } from 'lucide-svelte' + import { + Download, + PanelRightOpen, + Search, + TriangleAlertIcon, + X, + ClipboardCopy, + Braces + } from 'lucide-svelte' import S3FilePicker from '../S3FilePicker.svelte' import { workspaceStore } from '$lib/stores' import AnimatedButton from '$lib/components/common/button/AnimatedButton.svelte' import Popover from '../Popover.svelte' import { twMerge } from 'tailwind-merge' + import ContextMenu, { + type ContextMenuItem + } from '$lib/components/common/contextmenu/ContextMenu.svelte' interface Props { json: any @@ -112,7 +130,11 @@ const fullKey = computeFullKey(key, rawKey) if (allowCopy) { if (pureViewer && clickedValue) { - copyToClipboard(typeof value == 'string' ? value : JSON.stringify(value)) + try { + copyToClipboard(typeof value == 'string' ? value : JSON.stringify(value)) + } catch (e) { + copyToClipboard(String(value)) + } } else { copyToClipboard(fullKey) } @@ -140,6 +162,74 @@ let keyLimit = $derived(isArray ? 5 : 100) let fullyCollapsed = $derived(keys.length > 1 && collapsed) let searchInput: HTMLInputElement | undefined = $state(undefined) + + // Context menu — shared via context so nested ObjectViewer instances + // update the root-level data and a single ContextMenu renders the menu. + let lastContextData = $state<{ key: string; value: any; parentObj: any }>({ + key: '', + value: null, + parentObj: null + }) + + function setContextMenuData(e: MouseEvent, key: string, value: any, parentObj: any) { + // Only the deepest handler should set the data — mark the event + // so ancestor
  • handlers skip their update. + if (!(e as any).__objViewerCtxSet) { + lastContextData = { key, value, parentObj } + ;(e as any).__objViewerCtxSet = true + } + } + + const CONTEXT_MENU_CTX_KEY = 'objectViewerContextMenu' + + if (level === 0) { + setContext(CONTEXT_MENU_CTX_KEY, setContextMenuData) + } + + const updateContextData: typeof setContextMenuData = + level === 0 ? setContextMenuData : getContext(CONTEXT_MENU_CTX_KEY) + + let menuItems: ContextMenuItem[] = $derived([ + { + id: 'copy-value', + label: 'Copy value', + icon: ClipboardCopy, + onClick: () => { + const v = lastContextData.value + try { + copyToClipboard(typeof v === 'string' ? v : v ? JSON.stringify(v) : String(v)) + } catch (e) { + copyToClipboard(String(v)) + } + } + }, + ...(lastContextData.key !== '' + ? [ + { + id: 'copy-key', + label: 'Copy object key', + icon: ClipboardCopy, + onClick: () => copyToClipboard(lastContextData.key) + } + ] + : []), + ...(lastContextData.parentObj != null + ? [ + { + id: 'copy-object', + label: 'Copy entire object', + icon: Braces, + onClick: () => { + try { + copyToClipboard(JSON.stringify(lastContextData.parentObj)) + } catch (e) { + copyToClipboard(String(lastContextData.parentObj)) + } + } + } + ] + : []) + ]) {#snippet renderScalar(k: string, v: any)} @@ -180,184 +270,203 @@ {/snippet} -{#if getTypeAsString(jsonFiltered) === 's3object'} - - - -{/if} +{#snippet viewerContent()} + {#if getTypeAsString(jsonFiltered) === 's3object'} + + + + {/if} -{#if level == 0} -
    - {#if searchOpen} -
    - { - if ((event as KeyboardEvent)?.key === 'Escape') { + {#if level == 0} +
    + {#if searchOpen} +
    + { + if ((event as KeyboardEvent)?.key === 'Escape') { + clearSearch() + } + event.stopPropagation() + }} + type="text" + class="!h-6 !text-2xs mt-0.5" + bind:value={search} + placeholder="Search..." + bind:this={searchInput} + /> + +
    + {:else} + + {/if} +
    + {/if} + + {#if keys.length > 0} + {#if !fullyCollapsed} + + {#if level != 0 && keys.length > 1} + + + + + {/if} + {#if level == 0 && topBrackets}{openBracket}{/if} +
      -
    - {:else} - + {#each keys.length > keyLimit ? keys.slice(0, keyLimit) : keys as key, index (key)} + +
  • updateContextData(e, key, json[key], json)} + class="[&:hover:not(:has(li:hover))]:bg-surface-hover" + > + + + {@render metaData?.(key)} + + : + + {#if getTypeAsString(jsonFiltered[key]) === 'object'} + = collapseLevel && key != expandedEvenOnLevel0 + : undefined} + /> + {:else} + {@render renderScalar(key, jsonFiltered[key])} + {/if} + {@render editKey?.(key)} +
  • + {/each} + {#if keys.length > keyLimit} + {@const increment = Math.min(100, keys.length - keyLimit)} + + {/if} + + {#if level == 0 && topBrackets} +
    + {closeBracket} + {#if getTypeAsString(jsonFiltered) === 's3object'} + + download + + + {/if} +
    + {/if} + {/if} - -{/if} -{#if keys.length > 0} - {#if !fullyCollapsed} - - {#if level != 0 && keys.length > 1} - - + + + {#if fullyCollapsed} + - {/if} - {#if level == 0 && topBrackets}{openBracket}{/if} - - {#if level == 0 && topBrackets} -
    - {closeBracket} - {#if getTypeAsString(jsonFiltered) === 's3object'} - - download - - - {/if} -
    - {/if} + {openBracket}{collapsedSymbol}{closeBracket} + +
    + {/if} + {:else if topBrackets} + {openBracket}{closeBracket} + {:else if jsonFiltered == undefined} + undefined + {:else if typeof jsonFiltered != 'object'} + + updateContextData(e, '', jsonFiltered, null)}> + {@render renderScalar('', jsonFiltered)} + {:else} + No items {/if} +{/snippet} - - - - {#if fullyCollapsed} - - - - {/if} -{:else if topBrackets} - {openBracket}{closeBracket} -{:else if jsonFiltered == undefined} - undefined -{:else if typeof jsonFiltered != 'object'} - {@render renderScalar('', jsonFiltered)} +{#if level === 0} + + {@render viewerContent()} + {:else} - No items + {@render viewerContent()} {/if}