diff --git a/apps/yaak-client/components/core/Editor/json/breadcrumbExtension.ts b/apps/yaak-client/components/core/Editor/json/breadcrumbExtension.ts new file mode 100644 index 00000000..67cd59e7 --- /dev/null +++ b/apps/yaak-client/components/core/Editor/json/breadcrumbExtension.ts @@ -0,0 +1,41 @@ +import { syntaxTree } from "@codemirror/language"; +import type { Extension } from "@codemirror/state"; +import type { EditorView, ViewUpdate } from "@codemirror/view"; +import { ViewPlugin } from "@codemirror/view"; +import type { JsonPathSegment } from "./jsonPath"; +import { jsonPathSegmentsAt } from "./jsonPath"; + +export interface BreadcrumbUpdate { + /** Path to the cursor, or `null` when the document isn't JSON. */ + segments: JsonPathSegment[] | null; +} + +/** + * Reports the JSON path under the cursor when the editor is created (it may restore a + * cached selection), and whenever the selection, document, or parsed tree changes. The + * tree matters because large documents parse in the background, so a path read early + * can be wrong until the parser reaches the cursor. + * + * The callback is not fired on blur: moving focus to the filter box (by clicking + * a crumb) must not erase the breadcrumb that was just acted on. + */ +export function jsonBreadcrumbExtension(onUpdate: (update: BreadcrumbUpdate) => void): Extension { + const report = (view: EditorView) => { + onUpdate({ segments: jsonPathSegmentsAt(view.state, view.state.selection.main.head) }); + }; + + return ViewPlugin.define((view) => { + report(view); + return { + update(update: ViewUpdate) { + if ( + update.selectionSet || + update.docChanged || + syntaxTree(update.startState) !== syntaxTree(update.state) + ) { + report(update.view); + } + }, + }; + }); +} diff --git a/apps/yaak-client/components/core/Editor/json/jsonPath.test.ts b/apps/yaak-client/components/core/Editor/json/jsonPath.test.ts new file mode 100644 index 00000000..8f3d109e --- /dev/null +++ b/apps/yaak-client/components/core/Editor/json/jsonPath.test.ts @@ -0,0 +1,123 @@ +import { EditorState } from "@codemirror/state"; +import { jsonc } from "@shopify/lang-jsonc"; +import { forceParsing } from "@codemirror/language"; +import { describe, expect, test } from "vite-plus/test"; +import { jsonPathSegmentsAt, jsonPathToSegments, segmentsToJsonPath } from "./jsonPath"; + +function stateFor(doc: string): EditorState { + const state = EditorState.create({ doc, extensions: [jsonc()] }); + // Ensure the whole doc is parsed so resolveInner sees a complete tree. + forceParsing({ state } as never, doc.length, 5000); + return state; +} + +/** JSONPath produced when the cursor sits just after the first occurrence of `needle`. */ +function pathAt(doc: string, needle: string): string { + const pos = doc.indexOf(needle) + needle.length; + const segs = jsonPathSegmentsAt(stateFor(doc), pos); + if (segs == null) throw new Error("no JSON tree"); + return segmentsToJsonPath(segs); +} + +/** JSONPath produced when the cursor sits just before the first occurrence of `needle`. */ +function pathBefore(doc: string, needle: string): string { + const pos = doc.indexOf(needle); + const segs = jsonPathSegmentsAt(stateFor(doc), pos); + if (segs == null) throw new Error("no JSON tree"); + return segmentsToJsonPath(segs); +} + +describe("jsonPathSegmentsAt", () => { + test("nested object keys", () => { + const doc = `{ "address": { "geo": { "lat": "-37" } } }`; + expect(pathAt(doc, `-37`)).toBe(`$.address.geo.lat`); + }); + + test("array element index", () => { + const doc = `{ "featured": [ { "id": 1 }, { "id": 2 } ] }`; + expect(pathAt(doc, `2`)).toBe(`$.featured[1].id`); + }); + + test("root array element", () => { + const doc = `[ { "id": 1 }, { "id": 2 } ]`; + expect(pathAt(doc, `"id": 2`)).toBe(`$[1].id`); + }); + + test("key containing a dot uses bracket-quote notation", () => { + const doc = `{ "sort": { "link": { "filter": { "category.id": [ "100" ] } } } }`; + expect(pathAt(doc, `"100"`)).toBe(`$.sort.link.filter["category.id"][0]`); + }); + + test("cursor on a property name resolves that key", () => { + const doc = `{ "outer": { "inner": 5 } }`; + expect(pathAt(doc, `"inner"`)).toBe(`$.outer.inner`); + }); + + test("deeply nested arrays and objects", () => { + const doc = `{ "a": [ { "b": [ 10, 20, 30 ] } ] }`; + expect(pathAt(doc, `30`)).toBe(`$.a[0].b[2]`); + }); + + test("root scalar yields the root path", () => { + expect(pathAt(`"hello"`, `hello`)).toBe(`$`); + }); + + test("caret just before a key still resolves that key", () => { + // The character to the left is the object's whitespace, not the property. + const doc = `[\n {\n "userId": 1,\n "body": "x"\n }\n]`; + expect(pathBefore(doc, `"body"`)).toBe(`$[0].body`); + }); + + test("caret in a line's indentation resolves that line's member", () => { + // Caret in the blank indentation of the `title` line (structurally the + // object's whitespace) reads as being on `title`, not a neighbour. + const doc = `[\n {\n "userId": 1,\n "id": 1,\n "title": "x"\n }\n]`; + const pos = doc.indexOf(`"title"`) - 2; // two spaces into the title line + const segs = jsonPathSegmentsAt(stateFor(doc), pos); + expect(segs == null ? null : segmentsToJsonPath(segs)).toBe(`$[0].title`); + }); + + test("caret at the end of an array element keeps the element index", () => { + // The caret sits after `}` where the left token is the object's close brace. + const doc = `[\n { "id": 1 },\n { "id": 2 }\n]`; + const pos = doc.indexOf(`},`) + 1; + const segs = jsonPathSegmentsAt(stateFor(doc), pos); + expect(segs == null ? null : segmentsToJsonPath(segs)).toBe(`$[0]`); + }); + + test("non-JSON documents return null", () => { + const state = EditorState.create({ doc: `` }); + expect(jsonPathSegmentsAt(state, 3)).toBeNull(); + }); +}); + +describe("segmentsToJsonPath", () => { + test("empty segment list is the root", () => { + expect(segmentsToJsonPath([])).toBe(`$`); + }); + + test("partial path via count", () => { + const segs = [ + { kind: "key", key: "a" }, + { kind: "index", index: 2 }, + { kind: "key", key: "b" }, + ] as const; + expect(segmentsToJsonPath([...segs], 2)).toBe(`$.a[2]`); + }); +}); + +describe("jsonPathToSegments", () => { + test("round-trips paths built by segmentsToJsonPath", () => { + for (const path of ["$", "$[0]", "$[0].id", '$.data["user.name"][12]', '$["say \\"hi\\""]']) { + const segments = jsonPathToSegments(path); + expect(segments).not.toBeNull(); + expect(segmentsToJsonPath(segments ?? [])).toBe(path); + } + }); + + test("returns null for paths that don't name a single location", () => { + for (const path of ["$..id", "$[*]", "$.items[?(@.id)]", "$[0:2]", "items[0]", "$.a b"]) { + expect(jsonPathToSegments(path)).toBeNull(); + } + }); +}); diff --git a/apps/yaak-client/components/core/Editor/json/jsonPath.ts b/apps/yaak-client/components/core/Editor/json/jsonPath.ts new file mode 100644 index 00000000..f9614400 --- /dev/null +++ b/apps/yaak-client/components/core/Editor/json/jsonPath.ts @@ -0,0 +1,134 @@ +import { syntaxTree } from "@codemirror/language"; +import type { EditorState } from "@codemirror/state"; +import type { SyntaxNode } from "@lezer/common"; + +/** + * One step of a path into a JSON document. A `key` names an object member; an + * `index` names a position within an array. They are kept separate so the UI + * can show `items > 2` rather than folding the index into the key name. + */ +export type JsonPathSegment = + | { readonly kind: "key"; readonly key: string } + | { readonly kind: "index"; readonly index: number }; + +// Lezer node names from the JSONC grammar (@shopify/lang-jsonc). A JSON value is +// exactly one of these; everything else in the tree is punctuation or a comment. +const VALUE_NODES = new Set(["Object", "Array", "String", "Number", "True", "False", "Null"]); +// The nodes with children that a caret can land "between" — its whitespace and +// punctuation belong to the container, not to any value inside it. +const CONTAINER_NODES = new Set(["Object", "Array", "JsoncText"]); + +/** Read a `PropertyName` (a quoted JSON string) back to its raw key. */ +function keyFromPropertyName(node: SyntaxNode, state: EditorState): string { + const raw = state.doc.sliceString(node.from, node.to); + try { + return JSON.parse(raw) as string; + } catch { + // A half-typed or malformed key still deserves a best-effort label. + return raw.replace(/^"|"$/g, ""); + } +} + +/** Index of `child` among the value elements of its parent `Array` node. */ +function indexInArray(array: SyntaxNode, child: SyntaxNode): number { + let idx = 0; + for (let c = array.firstChild; c != null; c = c.nextSibling) { + if (c.from === child.from && c.to === child.to) return idx; + if (VALUE_NODES.has(c.name)) idx++; + } + return idx; +} + +/** + * The path from the document root to the member on the caret's line, or `null` + * when the document has no JSON tree (a different language, or empty). An empty + * array means the caret sits at the root value itself. + * + * A caret exactly on a token uses that token. A caret in a line's whitespace + * resolves to the enclosing object or array, so it's re-anchored to the first + * non-whitespace character on that line — in pretty-printed JSON each member + * starts its own line, so that's the member's own token. This tracks "the line + * I'm on" without guessing between neighbouring keys. + * + * From the anchor, each ancestor contributes a segment: a `Property` its key, a + * value directly inside an `Array` its index. Collected deepest-first, reversed. + */ +export function jsonPathSegmentsAt(state: EditorState, pos: number): JsonPathSegment[] | null { + const tree = syntaxTree(state); + if (tree.topNode.name !== "JsoncText") return null; + + let node: SyntaxNode = tree.resolveInner(pos, -1); + if (CONTAINER_NODES.has(node.name)) { + const line = state.doc.lineAt(pos); + const indent = line.text.length - line.text.trimStart().length; + if (indent < line.text.length) { + node = tree.resolveInner(line.from + indent, 1); + } + } + + const segments: JsonPathSegment[] = []; + let cur: SyntaxNode | null = node; + while (cur != null) { + const parent: SyntaxNode | null = cur.parent; + if (cur.name === "Property") { + const nameNode = cur.getChild("PropertyName"); + if (nameNode != null) { + segments.push({ kind: "key", key: keyFromPropertyName(nameNode, state) }); + } + } else if (parent != null && parent.name === "Array" && VALUE_NODES.has(cur.name)) { + segments.push({ kind: "index", index: indexInArray(parent, cur) }); + } + cur = parent; + } + + segments.reverse(); + return segments; +} + +const BARE_KEY = /^[A-Za-z_$][A-Za-z0-9_$]*$/; + +/** A single segment as JSONPath, choosing dot vs. bracket form for a key. */ +function segmentToJsonPath(segment: JsonPathSegment): string { + if (segment.kind === "index") return `[${segment.index}]`; + if (BARE_KEY.test(segment.key)) return `.${segment.key}`; + // Anything with a dot, space, quote, etc. must be a quoted bracket accessor. + // JSON.stringify gives correct double-quoting and escaping, matching the + // convention the JSONPath filter box already displays. + return `[${JSON.stringify(segment.key)}]`; +} + +/** + * Build a JSONPath expression for the first `count` segments (all of them by + * default). `segmentsToJsonPath([])` is the root, `$`. + */ +export function segmentsToJsonPath(segments: JsonPathSegment[], count = segments.length): string { + return "$" + segments.slice(0, count).map(segmentToJsonPath).join(""); +} + +const PATH_SEGMENT = /^(?:\.([A-Za-z_$][A-Za-z0-9_$]*)|\[(\d+)\]|\[("(?:[^"\\]|\\.)*")\])/; + +/** + * Parse a JSONPath that names a single location (`$.a[0]["b.c"]`) back into + * segments. Returns `null` for anything else, like wildcards or recursive descent. + */ +export function jsonPathToSegments(path: string): JsonPathSegment[] | null { + let rest = path.trim(); + if (!rest.startsWith("$")) return null; + rest = rest.slice(1); + + const segments: JsonPathSegment[] = []; + while (rest.length > 0) { + const match = PATH_SEGMENT.exec(rest); + if (match == null) return null; + const [whole, bareKey, index, quotedKey] = match; + if (bareKey != null) { + segments.push({ kind: "key", key: bareKey }); + } else if (index != null) { + segments.push({ kind: "index", index: Number(index) }); + } else if (quotedKey != null) { + segments.push({ kind: "key", key: JSON.parse(quotedKey) as string }); + } + rest = rest.slice(whole.length); + } + return segments; +} diff --git a/apps/yaak-client/components/responseViewers/ResponseBreadcrumbBar.tsx b/apps/yaak-client/components/responseViewers/ResponseBreadcrumbBar.tsx new file mode 100644 index 00000000..ba03595f --- /dev/null +++ b/apps/yaak-client/components/responseViewers/ResponseBreadcrumbBar.tsx @@ -0,0 +1,192 @@ +import { Icon } from "@yaakapp-internal/ui"; +import classNames from "classnames"; +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; +import type { JsonPathSegment } from "../core/Editor/json/jsonPath"; +import { IconButton } from "../core/IconButton"; +import { Tooltip } from "../core/Tooltip"; + +interface Props { + segments: JsonPathSegment[]; + appliedFilter: string | null; + /** + * How many leading segments make up the applied filter. `null` when unfiltered, or + * when the filter isn't a plain path and is shown as raw text instead. + */ + appliedDepth: number | null; + filterError: boolean; + /** Called with the number of leading segments to filter to. `0` clears the filter. */ + onSelect: (count: number) => void; +} + +/** + * Shows where the cursor sits inside a response (`$ > sort > [0] > id`). An applied + * plain-path filter is the start of the trail, so any crumb moves it. While filtered, + * the bar is tinted so a lingering filter is hard to miss. + */ +export function ResponseBreadcrumbBar({ + segments, + appliedFilter, + appliedDepth, + filterError, + onSelect, +}: Props) { + const scrollRef = useRef(null); + const [overflow, setOverflow] = useState({ left: false, right: false }); + + const measure = useCallback(() => { + const el = scrollRef.current; + if (el == null) return; + const maxScroll = el.scrollWidth - el.clientWidth; + setOverflow({ + left: el.scrollLeft > 1, + right: el.scrollLeft < maxScroll - 1, + }); + }, []); + + // Keep the deepest crumb (where the cursor is) in view as the path changes. + useLayoutEffect(() => { + const el = scrollRef.current; + if (el == null) return; + el.scrollLeft = el.scrollWidth; + measure(); + }, [segments, measure]); + + useEffect(() => { + const el = scrollRef.current; + if (el == null) return; + const observer = new ResizeObserver(measure); + observer.observe(el); + return () => observer.disconnect(); + }, [measure]); + + const scrollBy = useCallback((direction: -1 | 1) => { + const el = scrollRef.current; + if (el == null) return; + el.scrollBy({ left: direction * Math.max(120, el.clientWidth * 0.6), behavior: "smooth" }); + }, []); + + const isFiltered = appliedFilter != null; + + return ( +
+
+ {isFiltered && appliedDepth == null ? ( + + {appliedFilter} + + ) : ( + 0 ? () => onSelect(0) : null} + /> + )} + {overflow.left && ( + scrollBy(-1)} + className="shrink-0 h-auto!" + /> + )} +
+ {segments.map((segment, i) => { + const count = i + 1; + const isApplied = appliedDepth != null && count <= appliedDepth; + const isClickable = (!isFiltered || appliedDepth != null) && count !== appliedDepth; + return ( +
+ + onSelect(count) : null} + /> +
+ ); + })} +
+ {overflow.right && ( + scrollBy(1)} + className="shrink-0 h-auto!" + /> + )} +
+
+ ); +} + +function Crumb({ + label, + tooltip, + isApplied, + onClick, +}: { + label: string; + tooltip: string; + isApplied: boolean; + onClick: (() => void) | null; +}) { + const outerClassName = classNames("flex items-center px-0.5 py-0.5", isApplied && "text-primary"); + const innerClassName = "flex items-center h-full px-1 rounded-sm"; + if (onClick == null) { + return ( + + {label} + + ); + } + + return ( + + + + ); +} diff --git a/apps/yaak-client/components/responseViewers/TextViewer.tsx b/apps/yaak-client/components/responseViewers/TextViewer.tsx index d277992c..41052be5 100644 --- a/apps/yaak-client/components/responseViewers/TextViewer.tsx +++ b/apps/yaak-client/components/responseViewers/TextViewer.tsx @@ -1,18 +1,18 @@ import type { ReactNode } from "react"; -import { Children, useCallback, useMemo } from "react"; -import { Banner, HStack, Icon, InlineCode } from "@yaakapp-internal/ui"; +import { Children, useCallback, useMemo, useState } from "react"; import { useFormatText } from "../../hooks/useFormatText"; import type { ResponseFilterApi } from "../../hooks/useResponseFilter"; -import { Button } from "../core/Button"; import type { EditorProps } from "../core/Editor/Editor"; +import { jsonBreadcrumbExtension } from "../core/Editor/json/breadcrumbExtension"; +import type { JsonPathSegment } from "../core/Editor/json/jsonPath"; +import { jsonPathToSegments, segmentsToJsonPath } from "../core/Editor/json/jsonPath"; import { hyperlink } from "../core/Editor/hyperlink/extension"; import { Editor } from "../core/Editor/LazyEditor"; import { IconButton } from "../core/IconButton"; import { Input } from "../core/Input"; +import { ResponseBreadcrumbBar } from "./ResponseBreadcrumbBar"; import { RecentFiltersDropdown } from "./RecentFiltersDropdown"; -const extraExtensions = [hyperlink]; - interface Props { text: string; language: EditorProps["language"]; @@ -38,12 +38,43 @@ export function TextViewer({ filter, filterResult, }: Props) { + // Track the JSON path under the cursor to drive the breadcrumb bar. Selection + // works even in this read-only editor, so it updates as the user clicks around. + const [breadcrumbSegments, setBreadcrumbSegments] = useState([]); + const handleBreadcrumbUpdate = useCallback( + ({ segments }: { segments: JsonPathSegment[] | null }) => + setBreadcrumbSegments((prev) => { + const next = segments ?? []; + return segmentsToJsonPath(prev) === segmentsToJsonPath(next) ? prev : next; + }), + [], + ); + const extraExtensions = useMemo( + () => + language === "json" + ? [hyperlink, jsonBreadcrumbExtension(handleBreadcrumbUpdate)] + : [hyperlink], + [language, handleBreadcrumbUpdate], + ); + const canFilter = filter != null && (language === "json" || language === "xml" || language === "html"); const isSearching = filter?.isSearching ?? false; const appliedFilter = filter?.appliedFilter ?? null; const resultError = filterResult?.error ?? false; + // Filter output is always an array of matches, so under a plain-path filter the + // cursor's leading index is that single match and the rest extends the filter. + const appliedSegments = + appliedFilter != null && language === "json" ? jsonPathToSegments(appliedFilter) : null; + const pathSegments = + appliedSegments != null + ? [...appliedSegments, ...breadcrumbSegments.slice(1)] + : breadcrumbSegments; + const showBreadcrumbs = + filter != null && + (appliedFilter != null || (language === "json" && breadcrumbSegments.length > 0)); + const handleFilterKeyDown = useCallback( (e: KeyboardEvent) => { if (filter == null) return; @@ -155,16 +186,7 @@ export function TextViewer({ } return ( -
- {appliedFilter && filter != null ? ( - filter.replaceFilter("")} - /> - ) : ( - - )} +
+ {showBreadcrumbs && ( +
+ + filter.replaceFilter(count === 0 ? "" : segmentsToJsonPath(pathSegments, count)) + } + /> +
+ )}
); } -/** - * Shows what's actually filtering the body, which the filter box below can't convey - * once it holds an edited expression that hasn't been applied yet. - */ -function AppliedFilterBar({ - filter, - error, - onClear, -}: { - filter: string; - error: boolean; - onClear: () => void; -}) { - return ( - - - - - Response filtered by {filter} - {error && " (invalid expression)"} - - - - - ); -} - /** Convert \uXXXX to actual Unicode characters */ function decodeUnicodeLiterals(text: string): string { return text.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => {