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}
-
{
- searchOpen = true
- tick().then(() => searchInput?.focus())
- }}
- >
+ {#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"
+ >
+
+ selectProp(key, undefined, false)}
+ on:mouseenter={() => {
+ hoveredKey = key
+ }}
+ on:mouseleave={() => (hoveredKey = null)}
+ size="xs2"
+ color="light"
+ variant="border"
+ wrapperClasses="p-0 whitespace-nowrap w-fit"
+ btnClasses={twMerge(
+ 'hover:bg-surface',
+ 'font-mono h-4 py-1 text-2xs',
+ 'font-thin px-1 rounded-[0.275rem]',
+ metaData ? 'rounded-r-none border-r-0.5' : ''
+ )}
+ title={computeFullKey(key, rawKey)}
+ >
+ {!isArray ? key : index}
+
+ {@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)}
+
(keyLimit += increment)} class="text-2xs px-2 text-secondary">
+ {keyLimit}/{keys.length}: Load {increment} more...
+
+ {/if}
+
+ {#if level == 0 && topBrackets}
+
+
{closeBracket}
+ {#if getTypeAsString(jsonFiltered) === 's3object'}
+
+ download
+
+
{
+ s3FileViewer?.open?.(jsonFiltered)
+ }}
+ >open preview
+
+ {/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}
-
- {#each keys.length > keyLimit ? keys.slice(0, keyLimit) : keys as key, index (key)}
- -
-
- selectProp(key, undefined, false)}
- on:mouseenter={() => {
- hoveredKey = key
- }}
- on:mouseleave={() => (hoveredKey = null)}
- size="xs2"
- color="light"
- variant="border"
- wrapperClasses="p-0 whitespace-nowrap w-fit"
- btnClasses={twMerge(
- 'hover:bg-surface',
- 'font-mono h-4 py-1 text-2xs',
- 'font-thin px-1 rounded-[0.275rem]',
- metaData ? 'rounded-r-none border-r-0.5' : ''
- )}
- title={computeFullKey(key, rawKey)}
- >
- {!isArray ? key : index}
-
- {@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)}
- (keyLimit += increment)} class="text-2xs px-2 text-secondary">
- {keyLimit}/{keys.length}: Load {increment} more...
-
- {/if}
-
- {#if level == 0 && topBrackets}
-
-
{closeBracket}
- {#if getTypeAsString(jsonFiltered) === 's3object'}
-
- download
-
-
{
- s3FileViewer?.open?.(jsonFiltered)
- }}
- >open preview
-
- {/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}
-
-
- {openBracket}{collapsedSymbol}{closeBracket}
-
-
- {/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}