mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-09-19 16:21:37 +02:00
JSON breadcrumbs for the response viewer (YK-724) (#653)
This commit is contained in:
@@ -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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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: `<xml/>` });
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<HTMLDivElement>(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 (
|
||||||
|
<div className="rounded bg-surface shadow pointer-events-auto">
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
"flex items-stretch h-6 rounded border overflow-hidden font-mono text-xs text-text-subtle select-none",
|
||||||
|
!isFiltered && "border-border-subtle",
|
||||||
|
isFiltered && !filterError && "border-primary/40 bg-primary/10",
|
||||||
|
filterError && "border-danger/40 bg-danger/10",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isFiltered && appliedDepth == null ? (
|
||||||
|
<span
|
||||||
|
className={classNames(
|
||||||
|
"flex items-center min-w-0 truncate px-2",
|
||||||
|
filterError ? "text-danger" : "text-primary",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{appliedFilter}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<Crumb
|
||||||
|
label="$"
|
||||||
|
tooltip="Clear filter"
|
||||||
|
isApplied={isFiltered}
|
||||||
|
onClick={appliedDepth != null && appliedDepth > 0 ? () => onSelect(0) : null}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{overflow.left && (
|
||||||
|
<IconButton
|
||||||
|
size="xs"
|
||||||
|
icon="chevron_left"
|
||||||
|
title="Scroll breadcrumbs left"
|
||||||
|
iconColor="secondary"
|
||||||
|
onClick={() => scrollBy(-1)}
|
||||||
|
className="shrink-0 h-auto!"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
onScroll={measure}
|
||||||
|
className="flex items-stretch min-w-0 overflow-x-auto hide-scrollbars whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{segments.map((segment, i) => {
|
||||||
|
const count = i + 1;
|
||||||
|
const isApplied = appliedDepth != null && count <= appliedDepth;
|
||||||
|
const isClickable = (!isFiltered || appliedDepth != null) && count !== appliedDepth;
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex items-stretch shrink-0">
|
||||||
|
<Icon
|
||||||
|
icon="chevron_right"
|
||||||
|
size="xs"
|
||||||
|
className="text-text-subtlest shrink-0 self-center"
|
||||||
|
/>
|
||||||
|
<Crumb
|
||||||
|
label={segment.kind === "index" ? `[${segment.index}]` : segment.key}
|
||||||
|
tooltip={
|
||||||
|
segment.kind === "index"
|
||||||
|
? `Filter to element ${segment.index}`
|
||||||
|
: `Filter to ${segment.key}`
|
||||||
|
}
|
||||||
|
isApplied={isApplied}
|
||||||
|
onClick={isClickable ? () => onSelect(count) : null}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{overflow.right && (
|
||||||
|
<IconButton
|
||||||
|
size="xs"
|
||||||
|
icon="chevron_right"
|
||||||
|
title="Scroll breadcrumbs right"
|
||||||
|
iconColor="secondary"
|
||||||
|
onClick={() => scrollBy(1)}
|
||||||
|
className="shrink-0 h-auto!"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<span className={classNames(outerClassName, "shrink-0")}>
|
||||||
|
<span className={innerClassName}>{label}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip content={tooltip} className="shrink-0 items-stretch!">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={classNames(outerClassName, "group/crumb focus-visible:outline-none")}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={classNames(
|
||||||
|
innerClassName,
|
||||||
|
"transition-colors group-hover/crumb:text-text group-hover/crumb:bg-surface-highlight",
|
||||||
|
"group-focus-visible/crumb:ring-1 group-focus-visible/crumb:ring-border-focus",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,18 +1,18 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Children, useCallback, useMemo } from "react";
|
import { Children, useCallback, useMemo, useState } from "react";
|
||||||
import { Banner, HStack, Icon, InlineCode } from "@yaakapp-internal/ui";
|
|
||||||
import { useFormatText } from "../../hooks/useFormatText";
|
import { useFormatText } from "../../hooks/useFormatText";
|
||||||
import type { ResponseFilterApi } from "../../hooks/useResponseFilter";
|
import type { ResponseFilterApi } from "../../hooks/useResponseFilter";
|
||||||
import { Button } from "../core/Button";
|
|
||||||
import type { EditorProps } from "../core/Editor/Editor";
|
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 { hyperlink } from "../core/Editor/hyperlink/extension";
|
||||||
import { Editor } from "../core/Editor/LazyEditor";
|
import { Editor } from "../core/Editor/LazyEditor";
|
||||||
import { IconButton } from "../core/IconButton";
|
import { IconButton } from "../core/IconButton";
|
||||||
import { Input } from "../core/Input";
|
import { Input } from "../core/Input";
|
||||||
|
import { ResponseBreadcrumbBar } from "./ResponseBreadcrumbBar";
|
||||||
import { RecentFiltersDropdown } from "./RecentFiltersDropdown";
|
import { RecentFiltersDropdown } from "./RecentFiltersDropdown";
|
||||||
|
|
||||||
const extraExtensions = [hyperlink];
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
text: string;
|
text: string;
|
||||||
language: EditorProps["language"];
|
language: EditorProps["language"];
|
||||||
@@ -38,12 +38,43 @@ export function TextViewer({
|
|||||||
filter,
|
filter,
|
||||||
filterResult,
|
filterResult,
|
||||||
}: Props) {
|
}: 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<JsonPathSegment[]>([]);
|
||||||
|
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 =
|
const canFilter =
|
||||||
filter != null && (language === "json" || language === "xml" || language === "html");
|
filter != null && (language === "json" || language === "xml" || language === "html");
|
||||||
const isSearching = filter?.isSearching ?? false;
|
const isSearching = filter?.isSearching ?? false;
|
||||||
const appliedFilter = filter?.appliedFilter ?? null;
|
const appliedFilter = filter?.appliedFilter ?? null;
|
||||||
const resultError = filterResult?.error ?? false;
|
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(
|
const handleFilterKeyDown = useCallback(
|
||||||
(e: KeyboardEvent) => {
|
(e: KeyboardEvent) => {
|
||||||
if (filter == null) return;
|
if (filter == null) return;
|
||||||
@@ -155,16 +186,7 @@ export function TextViewer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full w-full">
|
<div className="relative h-full w-full">
|
||||||
{appliedFilter && filter != null ? (
|
|
||||||
<AppliedFilterBar
|
|
||||||
filter={appliedFilter}
|
|
||||||
error={resultError}
|
|
||||||
onClear={() => filter.replaceFilter("")}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span />
|
|
||||||
)}
|
|
||||||
<Editor
|
<Editor
|
||||||
readOnly
|
readOnly
|
||||||
className={className}
|
className={className}
|
||||||
@@ -174,45 +196,23 @@ export function TextViewer({
|
|||||||
extraExtensions={extraExtensions}
|
extraExtensions={extraExtensions}
|
||||||
stateKey={stateKey}
|
stateKey={stateKey}
|
||||||
/>
|
/>
|
||||||
|
{showBreadcrumbs && (
|
||||||
|
<div className="absolute top-0 right-3 max-w-[70%] pointer-events-none">
|
||||||
|
<ResponseBreadcrumbBar
|
||||||
|
segments={language === "json" ? pathSegments : []}
|
||||||
|
appliedFilter={appliedFilter}
|
||||||
|
appliedDepth={appliedSegments?.length ?? null}
|
||||||
|
filterError={resultError}
|
||||||
|
onSelect={(count) =>
|
||||||
|
filter.replaceFilter(count === 0 ? "" : segmentsToJsonPath(pathSegments, count))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 (
|
|
||||||
<Banner color={error ? "danger" : "info"} className="py-1! mb-2! text-sm">
|
|
||||||
<HStack space={2} className="min-w-0">
|
|
||||||
<Icon icon="filter" size="xs" className="shrink-0 opacity-70" />
|
|
||||||
<span className="truncate min-w-0" title={filter}>
|
|
||||||
Response filtered by <InlineCode>{filter}</InlineCode>
|
|
||||||
{error && " (invalid expression)"}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
size="2xs"
|
|
||||||
variant="border"
|
|
||||||
color={error ? "danger" : "info"}
|
|
||||||
className="ml-auto shrink-0"
|
|
||||||
onClick={onClear}
|
|
||||||
>
|
|
||||||
Clear
|
|
||||||
</Button>
|
|
||||||
</HStack>
|
|
||||||
</Banner>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Convert \uXXXX to actual Unicode characters */
|
/** Convert \uXXXX to actual Unicode characters */
|
||||||
function decodeUnicodeLiterals(text: string): string {
|
function decodeUnicodeLiterals(text: string): string {
|
||||||
return text.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => {
|
return text.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user