diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000000..f8c39eec0f6 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,52 @@ + + +# JSON Crack — Copilot instructions (concise) + +Be productive quickly: this repo is a Next.js (14.x) TypeScript web app that visualizes JSON as interactive graphs. + +Key facts (quick): +- Framework: Next.js pages-based routing (see `src/pages/*`). +- Styles: `styled-components` + Mantine; theme toggles via `ThemeProvider` and `NEXT_PUBLIC_*` env flags. +- State: small, local stores use `zustand` (look in `src/store/*` and feature stores under `src/features/*/stores`). +- Graph UI: uses `reaflow` (see `src/features/editor/views/GraphView`) and heavy client-only libs (Monaco editor, drag/drop, image export). +- Data tools: conversion and type generation live in `src/lib/utils/` (e.g. `generateType.ts`, `jsonAdapter.ts`). Many heavy libs are dynamically imported at runtime. + +Important developer commands (use pnpm; Node >= 18): +- Install: `pnpm install` +- Dev server (Next): `pnpm dev` (default http://localhost:3000) +- Build: `pnpm build` (runs `next build`; `postbuild` runs sitemap generation) +- Start (production): `pnpm start` +- Lint/typecheck: `pnpm lint` (runs `tsc`, `eslint src`, and `prettier --check src`) +- Fix style/lint: `pnpm lint:fix` +- Docker: `docker compose build` + `docker compose up` (README notes local port 8888 for compose) + +Patterns & conventions (concrete): +- Client-only UI components are dynamically imported with `next/dynamic({ ssr: false })` (see `src/pages/editor.tsx` for examples: `TextEditor`, `LiveEditor`, and modal controllers). +- Cross-store updates: stores call each other directly via their getters (example: `useJson` calls `useGraph.getState().setGraph(json)` in `src/store/useJson.ts`). Follow this established pattern for inter-store side effects. +- Code generation/conversion: prefer using `lib/utils/*` helpers. `generateType.ts` converts inputs to JSON using `jsonAdapter` and then dynamically imports `json2go.js` or `json_typegen_wasm` — follow this pattern when adding new converters or languages. +- Theme & env flags: runtime features are toggled with `NEXT_PUBLIC_DISABLE_EXTERNAL_MODE` and `NEXT_PUBLIC_NODE_LIMIT` (see `src/pages/editor.tsx` and README). Use `process.env.NEXT_PUBLIC_*` checks for feature toggles. + +Where to look for examples: +- Page layout and app wiring: `src/pages/_app.tsx`, `src/pages/editor.tsx`. +- State/store patterns: `src/store/*` and `src/features/*/stores/*` (for graph-specific stores see `src/features/editor/views/GraphView/stores`). +- Utilities & codegen: `src/lib/utils/generateType.ts`, `src/lib/utils/jsonAdapter.ts`, `src/lib/utils/json2go.js`. +- UI building blocks: `src/features/editor/*` (Toolbar, BottomBar, LiveEditor, TextEditor) demonstrate composition and dynamic imports. + +Implementation guidance (do this, not that): +- DO use dynamic imports for large or browser-only libs (Monaco, gofmt, wasm) to keep server builds fast. +- DO follow existing zustand patterns and name hooks `useX` with default exports. +- DO run `pnpm lint` locally before pushing; the lint step includes `tsc` which catches many issues. +- DO update `next-sitemap.config.js` and `public/` assets if you add new public pages or site metadata. +- DON'T add long-running or network-heavy tasks to server-side rendering; heavy processing should be done client-side or via explicit serverless endpoints. + +Edge examples to copy quickly: +- Set JSON and update the graph in the same action: + - `useJson.getState().setJson(jsonStr)` which calls `useGraph.getState().setGraph(jsonStr)` internally (see `src/store/useJson.ts`). +- Create a client-only modal: + - `const Modal = dynamic(() => import('../features/modals/MyModal'), { ssr: false });` + +If unsure where to change something: trace the feature in `src/features/*` then inspect related store(s) in `src/store/*` or the feature's `stores` folder. + +Files referenced above provide canonical examples; prefer copying patterns from them. + +If any of this is unclear or you want more examples (tests, CI, or more detailed architecture flow), tell me which area to expand and I will update this file. diff --git a/src/features/editor/views/GraphView/CustomNode/ObjectNode.tsx b/src/features/editor/views/GraphView/CustomNode/ObjectNode.tsx index bd87e928f01..a003c97ff3b 100644 --- a/src/features/editor/views/GraphView/CustomNode/ObjectNode.tsx +++ b/src/features/editor/views/GraphView/CustomNode/ObjectNode.tsx @@ -1,18 +1,21 @@ -import React from "react"; +import React, { useState } from "react"; import type { CustomNodeProps } from "."; import { NODE_DIMENSIONS } from "../../../../../constants/graph"; import type { NodeData } from "../../../../../types/graph"; import { TextRenderer } from "./TextRenderer"; import * as Styled from "./styles"; +import useJson from "../../../../../store/useJson"; +import useFile from "../../../../../store/useFile"; type RowProps = { row: NodeData["text"][number]; x: number; y: number; index: number; + node: NodeData; }; -const Row = ({ row, x, y, index }: RowProps) => { +const Row = ({ row, x, y, index, node }: RowProps) => { const rowPosition = index * NODE_DIMENSIONS.ROW_HEIGHT; const getRowText = () => { @@ -21,15 +24,119 @@ const Row = ({ row, x, y, index }: RowProps) => { return row.value; }; + const [isEditing, setIsEditing] = useState(false); + const [tempValue, setTempValue] = useState(String(row.value ?? "")); + + const setAtPath = (obj: any, p: Array, val: any) => { + let target = obj; + for (let i = 0; i < p.length - 1; i++) { + const key = p[i]; + if (typeof target[key] === "undefined") { + const nextKey = p[i + 1]; + target[key] = typeof nextKey === "number" ? [] : {}; + } + target = target[key]; + } + const lastKey = p[p.length - 1]; + target[lastKey] = val; + }; + + const handleSave = () => { + try { + const jsonStr = useJson.getState().json; + const jsonObj = jsonStr ? JSON.parse(jsonStr) : {}; + + // Determine new value type: preserve string if original was string, otherwise try JSON.parse + let newVal: any; + if (typeof row.value === "string") { + newVal = tempValue; + } else { + try { + newVal = JSON.parse(tempValue); + } catch { + newVal = tempValue; + } + } + + // Build path: node.path + [row.key] + const basePath = node.path ?? []; + const propKey = row.key as string; + const fullPath = [...basePath, propKey]; + + setAtPath(jsonObj, fullPath, newVal); + const updated = JSON.stringify(jsonObj, null, 2); + useJson.getState().setJson(updated); + useFile.getState().setContents({ contents: updated, hasChanges: true, skipUpdate: true }); + } catch (err) { + // keep simple + // console.warn("Failed to save row value", err); + } finally { + setIsEditing(false); + } + }; + + const handleCancel = () => { + setTempValue(String(row.value ?? "")); + setIsEditing(false); + }; + + const isEditable = row.type !== "object" && row.type !== "array" && row.key != null; + return ( {row.key}: - {getRowText()} + {isEditable ? ( + !isEditing ? ( + + {getRowText()} + + + ) : ( + + e.stopPropagation()} + onChange={e => setTempValue(e.target.value)} + style={{ fontSize: 12, padding: "2px 6px", minWidth: 80 }} + /> + + + + ) + ) : ( + {getRowText()} + )} ); }; @@ -44,7 +151,7 @@ const Node = ({ node, x, y }: CustomNodeProps) => ( $isObject > {node.text.map((row, index) => ( - + ))} ); diff --git a/src/features/editor/views/GraphView/CustomNode/TextNode.tsx b/src/features/editor/views/GraphView/CustomNode/TextNode.tsx index 718ced9d989..df60d125045 100644 --- a/src/features/editor/views/GraphView/CustomNode/TextNode.tsx +++ b/src/features/editor/views/GraphView/CustomNode/TextNode.tsx @@ -1,10 +1,12 @@ -import React from "react"; +import React, { useEffect, useState } from "react"; import styled from "styled-components"; import type { CustomNodeProps } from "."; import useConfig from "../../../../../store/useConfig"; import { isContentImage } from "../lib/utils/calculateNodeSize"; import { TextRenderer } from "./TextRenderer"; import * as Styled from "./styles"; +import useJson from "../../../../../store/useJson"; +import useFile from "../../../../../store/useFile"; const StyledTextNodeWrapper = styled.span<{ $isParent: boolean }>` display: flex; @@ -30,7 +32,71 @@ const Node = ({ node, x, y }: CustomNodeProps) => { const { text, width, height } = node; const imagePreviewEnabled = useConfig(state => state.imagePreviewEnabled); const isImage = imagePreviewEnabled && isContentImage(JSON.stringify(text[0].value)); + const value = text[0].value; + const path = node.path; + + const [isEditing, setIsEditing] = useState(false); + const [tempValue, setTempValue] = useState(String(value ?? "")); + + // Keep tempValue in sync if the underlying value changes from outside + useEffect(() => { + setTempValue(String(value ?? "")); + }, [value]); + + const setAtPath = (obj: any, p: Array, val: any) => { + let target = obj; + for (let i = 0; i < p.length - 1; i++) { + const key = p[i]; + if (typeof target[key] === "undefined") { + const nextKey = p[i + 1]; + target[key] = typeof nextKey === "number" ? [] : {}; + } + target = target[key]; + } + const lastKey = p[p.length - 1]; + target[lastKey] = val; + }; + + const handleSave = () => { + try { + const jsonStr = useJson.getState().json; + const jsonObj = jsonStr ? JSON.parse(jsonStr) : {}; + + // Determine new value type: preserve string if original was string, otherwise try JSON.parse + let newVal: any; + if (typeof value === "string") { + newVal = tempValue; + } else { + try { + newVal = JSON.parse(tempValue); + } catch { + newVal = tempValue; + } + } + + if (!path || path.length === 0) { + // Replace root + const updated = JSON.stringify(newVal, null, 2); + useJson.getState().setJson(updated); + useFile.getState().setContents({ contents: updated, hasChanges: true, skipUpdate: true }); + } else { + setAtPath(jsonObj, path, newVal); + const updated = JSON.stringify(jsonObj, null, 2); + useJson.getState().setJson(updated); + useFile.getState().setContents({ contents: updated, hasChanges: true, skipUpdate: true }); + } + } catch (err) { + // keep logic simple; log for debugging + } finally { + setIsEditing(false); + } + }; + + const handleCancel = () => { + setTempValue(String(value ?? "")); + setIsEditing(false); + }; return ( { data-y={y} data-key={JSON.stringify(text)} $isParent={false} + style={{ pointerEvents: "all" }} > - {value} + {!isEditing ? ( + + {value} + + + ) : ( + + e.stopPropagation()} + onChange={e => setTempValue(e.target.value)} + style={{ fontSize: 12, padding: "2px 6px", minWidth: 80 }} + /> + + + + )} )} @@ -61,7 +179,17 @@ const Node = ({ node, x, y }: CustomNodeProps) => { }; function propsAreEqual(prev: CustomNodeProps, next: CustomNodeProps) { - return prev.node.text === next.node.text && prev.node.width === next.node.width; + // Compare node.text by value (deep-ish) so edits cause re-render even if + // the array/object identity changes in different ways. + try { + const prevText = JSON.stringify(prev.node.text); + const nextText = JSON.stringify(next.node.text); + return prevText === nextText && prev.node.width === next.node.width; + } catch (e) { + // Fallback to reference equality if stringify fails for any reason + return prev.node.text === next.node.text && prev.node.width === next.node.width; + } } export const TextNode = React.memo(Node, propsAreEqual); +