From b3d4621bebe71c4d4545593a25e2fc2eca936292 Mon Sep 17 00:00:00 2001 From: Simon Date: Wed, 24 Jun 2026 01:34:20 +0800 Subject: [PATCH] feat: copy the path of the selected node (#165, #23) Adds a way to copy the path to the currently-selected JSON node, in two formats: - JSONPath: $.data[0]["user-name"] - JavaScript accessor: data[0]["user-name"] A clipboard button in the PathBar opens a small menu with both options, and a Shift+P shortcut copies the JSONPath form directly (mirroring the existing Shift+C 'copy selected node' shortcut). Path formatting lives in a pure, unit-tested utility (formatPath) that walks the JSONHeroPath components, using bracket notation for array indices and for keys that are not safe JS identifiers. --- app/components/CopySelectedNode.tsx | 20 ++++++++++ app/components/JsonColumnView.tsx | 3 +- app/components/JsonTreeView.tsx | 3 +- app/components/PathBar.tsx | 58 ++++++++++++++++++++++++++++- app/utilities/pathFormatter.ts | 56 ++++++++++++++++++++++++++++ tests/pathFormatter.test.ts | 56 ++++++++++++++++++++++++++++ 6 files changed, 193 insertions(+), 3 deletions(-) create mode 100644 app/utilities/pathFormatter.ts create mode 100644 tests/pathFormatter.test.ts diff --git a/app/components/CopySelectedNode.tsx b/app/components/CopySelectedNode.tsx index 2b2af8e41..91957e904 100644 --- a/app/components/CopySelectedNode.tsx +++ b/app/components/CopySelectedNode.tsx @@ -1,5 +1,7 @@ import { useHotkeys } from "react-hotkeys-hook"; import { useSelectedInfo } from "../hooks/useSelectedInfo"; +import { useJsonColumnViewState } from "../hooks/useJsonColumnView"; +import { formatPath } from "../utilities/pathFormatter"; export function CopySelectedNodeShortcut() { const selectedInfo = useSelectedInfo(); @@ -18,3 +20,21 @@ export function CopySelectedNodeShortcut() { return <>; } + +export function CopySelectedNodePathShortcut() { + const { selectedNodeId } = useJsonColumnViewState(); + + useHotkeys( + 'shift+p,shift+P', + (e) => { + if (!selectedNodeId) { + return; + } + e.preventDefault(); + navigator.clipboard.writeText(formatPath(selectedNodeId, "jsonpath")); + }, + [selectedNodeId] + ); + + return <>; +} diff --git a/app/components/JsonColumnView.tsx b/app/components/JsonColumnView.tsx index 6e7bfd9d3..105a91013 100644 --- a/app/components/JsonColumnView.tsx +++ b/app/components/JsonColumnView.tsx @@ -4,7 +4,7 @@ import { } from "../hooks/useJsonColumnView"; import { useHotkeys } from "react-hotkeys-hook"; import { Columns } from "./Columns"; -import { CopySelectedNodeShortcut } from "./CopySelectedNode"; +import { CopySelectedNodeShortcut, CopySelectedNodePathShortcut } from "./CopySelectedNode"; export function JsonColumnView() { const { getColumnViewProps, columns } = useJsonColumnViewState(); @@ -70,5 +70,6 @@ function KeyboardShortcuts() { return <> + ; } diff --git a/app/components/JsonTreeView.tsx b/app/components/JsonTreeView.tsx index f549b2662..f434996b5 100644 --- a/app/components/JsonTreeView.tsx +++ b/app/components/JsonTreeView.tsx @@ -7,7 +7,7 @@ import { import { useJsonDoc } from "~/hooks/useJsonDoc"; import { JsonTreeViewNode, useJsonTreeViewContext } from "~/hooks/useJsonTree"; import { VirtualNode } from "~/hooks/useVirtualTree"; -import { CopySelectedNodeShortcut } from "./CopySelectedNode"; +import { CopySelectedNodeShortcut, CopySelectedNodePathShortcut } from "./CopySelectedNode"; import { Body } from "./Primitives/Body"; import { Mono } from "./Primitives/Mono"; @@ -84,6 +84,7 @@ export function JsonTreeView() { return ( <> +
); })} + + + +
+ + +
+ +
+ + ); +} + export function PathHistoryControls() { const { canGoBack, canGoForward } = useJsonColumnViewState(); const { goBack, goForward } = useJsonColumnViewAPI(); diff --git a/app/utilities/pathFormatter.ts b/app/utilities/pathFormatter.ts new file mode 100644 index 000000000..00c6b104c --- /dev/null +++ b/app/utilities/pathFormatter.ts @@ -0,0 +1,56 @@ +import { JSONHeroPath, PathComponent } from "@jsonhero/path"; + +export type PathFormat = "jsonpath" | "js"; + +// The public PathComponent interface doesn't expose `keyName`, but every +// concrete component (SimpleKeyPathComponent, StartPathComponent, ...) does. +function keyNameOf(component: PathComponent): string { + return (component as PathComponent & { keyName: string }).keyName; +} + +const SAFE_IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/; + +function quoteKey(key: string): string { + const escaped = key.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + return `["${escaped}"]`; +} + +/** + * Formats a JSONHeroPath string (e.g. `$.data.0.user-name`) as either a + * JSONPath expression or a JavaScript accessor. + * + * - jsonpath: `$.data[0]["user-name"]` (root -> `$`) + * - js: `data[0]["user-name"]` (root -> ``) + */ +export function formatPath(path: string, format: PathFormat): string { + const heroPath = new JSONHeroPath(path); + + // Drop the leading StartPathComponent (`$`). + const components = heroPath.components.slice(1); + + let result = format === "jsonpath" ? "$" : ""; + + components.forEach((component, index) => { + const key = keyNameOf(component); + + if (component.isArray) { + result += `[${key}]`; + return; + } + + if (!SAFE_IDENTIFIER.test(key)) { + result += quoteKey(key); + return; + } + + // A safe identifier: use dot notation, except for the very first + // component of a JS accessor, which has no leading dot. + if (format === "js" && index === 0) { + result += key; + } else { + result += `.${key}`; + } + }); + + return result; +} diff --git a/tests/pathFormatter.test.ts b/tests/pathFormatter.test.ts new file mode 100644 index 000000000..98b43e949 --- /dev/null +++ b/tests/pathFormatter.test.ts @@ -0,0 +1,56 @@ +import { formatPath } from "../app/utilities/pathFormatter"; + +describe("formatPath", () => { + describe("jsonpath", () => { + it("returns $ for the root path", () => { + expect(formatPath("$", "jsonpath")).toBe("$"); + }); + + it("formats a single simple key with a dot", () => { + expect(formatPath("$.name", "jsonpath")).toBe("$.name"); + }); + + it("formats array indices with bracket notation", () => { + expect(formatPath("$.data.0.name", "jsonpath")).toBe("$.data[0].name"); + }); + + it("brackets and quotes keys that are not safe identifiers", () => { + expect(formatPath("$.user-name", "jsonpath")).toBe('$["user-name"]'); + }); + + it("handles consecutive array indices", () => { + expect(formatPath("$.a.b.0.1", "jsonpath")).toBe("$.a.b[0][1]"); + }); + + it("escapes embedded quotes and backslashes in bracketed keys", () => { + // a key literally containing a double-quote + expect(formatPath('$.a."b', "jsonpath")).toBe('$.a["\\"b"]'); + }); + }); + + describe("js accessor", () => { + it("returns an empty string for the root path", () => { + expect(formatPath("$", "js")).toBe(""); + }); + + it("drops the leading $ and uses no leading dot for the first key", () => { + expect(formatPath("$.name", "js")).toBe("name"); + }); + + it("formats nested keys and array indices", () => { + expect(formatPath("$.data.0.name", "js")).toBe("data[0].name"); + }); + + it("uses bracket notation for unsafe keys, even when first", () => { + expect(formatPath("$.user-name", "js")).toBe('["user-name"]'); + }); + + it("uses bracket notation for an unsafe key after a simple key", () => { + expect(formatPath("$.data.user-name", "js")).toBe('data["user-name"]'); + }); + + it("treats a leading array index as bracket notation", () => { + expect(formatPath("$.0.name", "js")).toBe("[0].name"); + }); + }); +});