Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
213 changes: 212 additions & 1 deletion src/routes/v2/pages/Editor/components/DebugPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,27 @@
import yaml from "js-yaml";
import { observer } from "mobx-react-lite";
import { useEffect } from "react";
import { useEffect, useState } from "react";

import CodeSyntaxHighlighter from "@/components/shared/CodeViewer/CodeSyntaxHighlighter";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Icon } from "@/components/ui/icon";
import { BlockStack, InlineStack } from "@/components/ui/layout";
import { Separator } from "@/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Text } from "@/components/ui/typography";
import { useUpgradeComponentsWindow } from "@/routes/v2/pages/Editor/components/UpgradeComponents/useUpgradeComponentsWindow";
import { useEditorSession } from "@/routes/v2/pages/Editor/store/EditorSessionContext";
import {
readSystemClipboardInfo,
type SystemClipboardInfo,
} from "@/routes/v2/shared/clipboard/clipboardEnvelope";
import { ShortcutBadge } from "@/routes/v2/shared/components/ShortcutBadge";
import { useSharedStores } from "@/routes/v2/shared/store/SharedStoreContext";
import { tracking } from "@/utils/tracking";
Expand All @@ -25,7 +38,32 @@ const DEBUG_PANEL_WINDOW_ID = "debug-panel";
*/
const DebugPanelContent = observer(function DebugPanelContent() {
const { editor, keyboard, navigation } = useSharedStores();
const { clipboard } = useEditorSession();
const [clipboardDialogOpen, setClipboardDialogOpen] = useState(false);
const [systemClipboard, setSystemClipboard] = useState<SystemClipboardInfo>({
kind: "unavailable",
});
const openUpgradeComponentsWindow = useUpgradeComponentsWindow();

const refreshSystemClipboard = async () => {
setSystemClipboard(await readSystemClipboardInfo());
};

useEffect(() => {
void refreshSystemClipboard();
}, [clipboard.snapshots, clipboard.pasteOffsetIndex]);

useEffect(() => {
const handler = () => void refreshSystemClipboard();
window.addEventListener("copy", handler);
window.addEventListener("focus", handler);
navigator.clipboard?.addEventListener?.("clipboardchange", handler);
return () => {
window.removeEventListener("copy", handler);
window.removeEventListener("focus", handler);
navigator.clipboard?.removeEventListener?.("clipboardchange", handler);
};
}, []);
const spec = navigation.rootSpec;
const specYaml = getSpecYaml(spec);
const keybordShortcuts = [...keyboard.shortcuts.values()];
Expand Down Expand Up @@ -64,6 +102,87 @@ const DebugPanelContent = observer(function DebugPanelContent() {
<StatItem label="Selected" value={selectedInfo} />
</StatGroup>

<StatGroup title="Clipboard">
<StatItem
label="Source"
value={clipboardSourceLabel(systemClipboard)}
/>
<StatItem
label="Pastes since copy"
value={clipboard.pasteOffsetIndex}
/>
{systemClipboard.kind === "envelope" && (
<ClipboardNodePreview
snapshots={systemClipboard.envelope.snapshots}
onViewAll={() => setClipboardDialogOpen(true)}
/>
)}
{systemClipboard.kind === "text" && (
<ClipboardTextPreview
text={systemClipboard.text}
onViewAll={() => setClipboardDialogOpen(true)}
/>
)}
{systemClipboard.kind === "unavailable" && (
<Text size="xs" tone="subdued" className="py-1">
Click refresh to read clipboard
</Text>
)}
</StatGroup>

<Dialog
open={clipboardDialogOpen}
onOpenChange={setClipboardDialogOpen}
>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Clipboard contents</DialogTitle>
<DialogDescription className="hidden">
{clipboardSourceLabel(systemClipboard)}
</DialogDescription>
</DialogHeader>
{systemClipboard.kind === "envelope" && (
<BlockStack gap="3">
<InlineStack gap="4">
<Text size="xs" tone="subdued">
Nodes:{" "}
<Text as="span" font="mono" weight="semibold">
{systemClipboard.envelope.snapshots.length}
</Text>
</Text>
<Text size="xs" tone="subdued">
Connections:{" "}
<Text as="span" font="mono" weight="semibold">
{systemClipboard.envelope.bindings.length}
</Text>
</Text>
</InlineStack>
<div className="flex flex-col h-[60vh] rounded-md overflow-hidden w-full bg-gray-50 border border-gray-200">
<div className="flex-1 relative">
<div className="absolute inset-0">
<CodeSyntaxHighlighter
code={yaml.dump(
{
snapshots: systemClipboard.envelope.snapshots,
bindings: systemClipboard.envelope.bindings,
},
{ lineWidth: -1, noRefs: true, indent: 2 },
)}
language="yaml"
/>
</div>
</div>
</div>
</BlockStack>
)}
{systemClipboard.kind === "text" && (
<pre className="max-h-96 overflow-auto rounded-md bg-gray-50 border border-gray-200 p-3 text-xs font-mono text-gray-700 whitespace-pre-wrap break-all">
{systemClipboard.text}
</pre>
)}
</DialogContent>
</Dialog>

<StatGroup title="Graph Structure">
<StatItem label="Inputs" value={stats.inputs} />
<StatItem label="Outputs" value={stats.outputs} />
Expand Down Expand Up @@ -129,6 +248,98 @@ const DebugPanelContent = observer(function DebugPanelContent() {
);
});

function clipboardSourceLabel(info: SystemClipboardInfo): string {
switch (info.kind) {
case "envelope":
return `nodes (${info.envelope.snapshots.length})`;
case "text":
return "text";
case "empty":
return "empty";
case "unavailable":
return "unavailable";
}
}

const PREVIEW_NODE_LIMIT = 2;
const PREVIEW_TEXT_MAX_CHARS = 160;

function ClipboardNodePreview({
snapshots,
onViewAll,
}: {
snapshots: { entityId: string; $type: string; name: string }[];
onViewAll: () => void;
}) {
if (snapshots.length === 0) return null;
const visible = snapshots.slice(0, PREVIEW_NODE_LIMIT);
const remaining = snapshots.length - visible.length;
return (
<BlockStack gap="1" className="pt-1">
<BlockStack gap="0">
{visible.map((s) => (
<Text
key={s.entityId}
size="xs"
font="mono"
tone="subdued"
className="truncate"
>
{s.$type}: {s.name}
</Text>
))}
</BlockStack>
<Button
variant="ghost"
size="sm"
className="w-full justify-start gap-2 h-6 px-1"
onClick={onViewAll}
{...tracking("v2.pipeline_editor.debug_panel.clipboard_view_all")}
>
<Icon name="Maximize2" size="xs" />
<Text size="xs">
{remaining > 0
? `View all (${snapshots.length})`
: "View full details"}
</Text>
</Button>
</BlockStack>
);
}

function ClipboardTextPreview({
text,
onViewAll,
}: {
text: string;
onViewAll: () => void;
}) {
const trimmed = text.length > PREVIEW_TEXT_MAX_CHARS;
const preview = trimmed ? `${text.slice(0, PREVIEW_TEXT_MAX_CHARS)}…` : text;
return (
<BlockStack gap="1" className="pt-1">
<Text
size="xs"
font="mono"
tone="subdued"
className="line-clamp-2 break-all whitespace-pre-wrap"
>
{preview}
</Text>
<Button
variant="ghost"
size="sm"
className="w-full justify-start gap-2 h-6 px-1"
onClick={onViewAll}
{...tracking("v2.pipeline_editor.debug_panel.clipboard_view_all")}
>
<Icon name="Maximize2" size="xs" />
<Text size="xs">View full text</Text>
</Button>
</BlockStack>
);
}

/**
* DebugPanel component that manages the debug panel window lifecycle.
* If persisted as hidden, windowStore will auto-hide it.
Expand Down
26 changes: 25 additions & 1 deletion src/routes/v2/shared/clipboard/clipboardEnvelope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type {

const CLIPBOARD_ENVELOPE_TYPE = "tangle-pipeline-nodes";

interface ClipboardEnvelope {
export interface ClipboardEnvelope {
_type: typeof CLIPBOARD_ENVELOPE_TYPE;
snapshots: NodeSnapshot[];
bindings: BindingSnapshot[];
Expand All @@ -20,6 +20,30 @@ function isClipboardEnvelope(data: unknown): data is ClipboardEnvelope {
);
}

export type SystemClipboardInfo =
| { kind: "envelope"; envelope: ClipboardEnvelope }
| { kind: "text"; text: string }
| { kind: "empty" }
| { kind: "unavailable" };

export async function readSystemClipboardInfo(): Promise<SystemClipboardInfo> {
try {
const text = await navigator.clipboard.readText();
if (!text) return { kind: "empty" };
try {
const parsed = JSON.parse(text);
if (isClipboardEnvelope(parsed)) {
return { kind: "envelope", envelope: parsed };
}
} catch {
// Not JSON — fall through to plain-text result
}
return { kind: "text", text };
} catch {
return { kind: "unavailable" };
}
}

export async function writeToSystemClipboard(
snapshots: NodeSnapshot[],
bindings: BindingSnapshot[],
Expand Down
Loading