diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000000..7e3a53aee27 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,82 @@ +**Overview** + +This repo is a Next.js (v14) single-repo web app (frontend) for JSON Crack — an interactive JSON visualizer. The app is built with React, Next.js pages (in `src/pages/`), and client-side state using `zustand` stores under `src/store/` and `src/features/*/stores/`. + +**Quick dev commands** + +- `pnpm install` : install dependencies (project uses `pnpm`, `packageManager` is `pnpm@9.1.4`). +- `pnpm dev` : local dev server (Next.js) on `http://localhost:3000`. +- `pnpm build` / `pnpm start` : build and start production Next app. `postbuild` runs `next-sitemap`. +- `pnpm lint` / `pnpm lint:fix` : TypeScript + ESLint + Prettier checks and autofix. +- Docker: `docker compose build` and `docker compose up` (README: app available at `http://localhost:8888` when using docker-compose). + +**Important files & folders** + +- `package.json` — scripts, Node engine (>=18.x), and `pnpm` requirement. +- `Dockerfile`, `docker-compose.yml` — local containerized workflows. +- `src/pages/*` — Next.js routes; editor UI is `src/pages/editor.tsx`. +- `src/features/*` — UI pieces (Editor toolbar, modals, specialized views). Example: `src/features/modals/ModalController.tsx` centralizes modal rendering. +- `src/store/*` — global client state (Zustand). Examples: `useConfig.ts`, `useFile.ts`, `useJson.ts`. +- `src/lib/utils/*` — conversion & generation helpers (`jsonAdapter.ts`, `generateType.ts`, `json2go.js`). + +**Architecture & data flow (concise)** + +- Editor content is managed via `src/store/useFile.ts`. When contents change, `useFile.setContents` converts/validates and triggers a debounced update to `useJson` and `useGraph`. +- Visual graph state and layout live in feature-scoped stores like `src/features/editor/views/GraphView/stores/useGraph`. +- UI components dynamically import large modules (e.g., `ModalController`, `TextEditor`, `LiveEditor`) to reduce SSR payloads — keep changes compatible with `next/dynamic` usage. +- Conversions and codegen are handled in `src/lib/utils` (e.g., `generateType.ts` uses `json_typegen_wasm` for TS/other languages and `json2go.js` for Go). + +**Patterns & conventions (what the agent should follow)** + +- State: prefer updating/reading from `zustand` stores (use `useXxx.getState()` only where the project uses it; stores frequently use `persist` middleware — do not rename persistence keys). Example: `useConfig` uses `persist(..., { name: "config" })`. +- Debounce & side-effects: `useFile` debounces updates and writes session data to `sessionStorage`. Preserve this behavior when moving logic. +- Dynamic imports: large components are loaded client-side (`ssr: false`) — ensure code runs safely in browser-only contexts (guard window/document usage). +- Styling: `styled-components` + Mantine themes. Theme toggling is controlled in `src/pages/editor.tsx` via `useConfig` and Mantine color scheme. +- Analytics & errors: `nextjs-google-analytics` events are used (see `useFile.setFile`) and Sentry config files exist in root (`sentry.*.config.ts`). Keep telemetry calls intact unless intentionally modifying analytics. + +**Editing guidance / PR tips** + +- Small, focused PRs are preferable: the app has tightly-coupled stores and UI components. +- When changing state shapes, update all consumers: search for `useConfig`, `useFile`, `useJson`, and `useGraph` usages. +- Avoid breaking server/client boundaries. Many modules are client-only (Monaco editor, window/sessionStorage). If you introduce server-side code inside dynamic client components, guard with `typeof window !== 'undefined'`. +- Keep build args and environment variables unchanged. Notable envs: `NEXT_PUBLIC_NODE_LIMIT` (node limit), `NEXT_PUBLIC_DISABLE_EXTERNAL_MODE` (controls external banner), and others referenced in `src/pages/editor.tsx` and features. + +**Examples (copyable patterns)** + +- Persisted store example (follow existing naming): + + ```ts + // src/store/useConfig.ts + persist(..., { name: "config" }) + ``` + +- Triggering a graph update via store (follow `useFile` pattern): + + ```ts + // setContents -> convert -> useGraph.getState().setLoading(true) + ``` + +- Dev / prod commands to run locally: + + ```sh + pnpm install + pnpm dev + pnpm build + pnpm start + pnpm lint + docker compose up + ``` + +**What the agent should NOT assume** + +- There are no unit tests in the repo (no `jest` or test runners visible). Do not add test changes without a PR discussion. +- Do not change persisted store key names or the `sessionStorage` keys (`content`/`format`) unless coordinating with the maintainer. + +**Where to look for more context** + +- `src/pages/editor.tsx` — the main editor wiring (themes, QueryClient, dynamic imports). +- `src/store/useFile.ts` — how editor contents flow into JSON + graph updates. +- `src/lib/utils/*` — format conversion and type generation utilities. +- `README.md` — local docker and pnpm instructions. + +If anything here is unclear or you want me to add examples for a specific subsystem (modals, conversions, or the graph layout stores), tell me which area and I will expand the instructions. diff --git a/src/features/modals/NodeModal/index.tsx b/src/features/modals/NodeModal/index.tsx index caba85febac..768b146ad66 100644 --- a/src/features/modals/NodeModal/index.tsx +++ b/src/features/modals/NodeModal/index.tsx @@ -1,9 +1,23 @@ -import React from "react"; +import React, { useEffect, useState } from "react"; import type { ModalProps } from "@mantine/core"; -import { Modal, Stack, Text, ScrollArea, Flex, CloseButton } from "@mantine/core"; +import { + Modal, + Stack, + Text, + ScrollArea, + Flex, + CloseButton, + Button, + Textarea, + Group, +} from "@mantine/core"; import { CodeHighlight } from "@mantine/code-highlight"; import type { NodeData } from "../../../types/graph"; import useGraph from "../../editor/views/GraphView/stores/useGraph"; +import useJson from "../../../store/useJson"; +import useFile from "../../../store/useFile"; +import { jsonToContent } from "../../../lib/utils/jsonAdapter"; +import { toast } from "react-hot-toast"; // return object from json removing array and object fields const normalizeNodeData = (nodeRows: NodeData["text"]) => { @@ -28,25 +42,174 @@ const jsonPathToString = (path?: NodeData["path"]) => { export const NodeModal = ({ opened, onClose }: ModalProps) => { const nodeData = useGraph(state => state.selectedNode); + const [editing, setEditing] = useState(false); + const [editedText, setEditedText] = useState(normalizeNodeData(nodeData?.text ?? [])); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (!editing) setEditedText(normalizeNodeData(nodeData?.text ?? [])); + }, [nodeData, editing]); + + const handleCancel = () => { + setEditing(false); + setEditedText(normalizeNodeData(nodeData?.text ?? [])); + }; + + const handleSave = async () => { + if (saving) return; + setSaving(true); + try { + // parse current full JSON + const currentJsonStr = useJson.getState().json; + const doc = currentJsonStr ? JSON.parse(currentJsonStr) : {}; + + // determine if node is primitive + const rows = nodeData?.text ?? []; + const isPrimitive = rows.length === 1 && !rows[0].key; + + let newValue: any; + try { + newValue = JSON.parse(editedText); + } catch (err) { + if (isPrimitive) { + newValue = editedText; + } else { + throw new Error("Invalid JSON for object/array nodes"); + } + } + + // helpers to get/set value at path + const getAtPath = (root: any, path: NodeData["path"] | undefined) => { + if (!path || path.length === 0) return root; + let cur = root; + for (let i = 0; i < path.length; i++) { + const key = path[i] as any; + if (cur == null) return undefined; + cur = typeof key === "number" ? cur[key] : cur[key]; + } + return cur; + }; + + const setAtPath = (root: any, path: NodeData["path"] | undefined, value: any) => { + if (!path || path.length === 0) return value; + let cur = root; + for (let i = 0; i < path.length - 1; i++) { + const key = path[i] as any; + if (cur[key] == null) { + const nextKey = path[i + 1] as any; + cur[key] = typeof nextKey === "number" ? [] : {}; + } + cur = typeof key === "number" ? cur[key] : cur[key]; + } + const last = path[path.length - 1] as any; + if (typeof last === "number") cur[last] = value; + else cur[last] = value; + return root; + }; + + // decide merging strategy to preserve nested fields + const existingValue = getAtPath(doc, nodeData?.path); + let finalValue: any; + + if (Array.isArray(existingValue)) { + finalValue = newValue; + } else if ( + existingValue && + typeof existingValue === "object" && + !Array.isArray(existingValue) && + newValue && + typeof newValue === "object" && + !Array.isArray(newValue) + ) { + finalValue = { ...existingValue, ...newValue }; + } else { + finalValue = newValue; + } + + const updatedDoc = setAtPath(doc, nodeData?.path, finalValue); + const updatedJsonStr = JSON.stringify(updatedDoc, null, 2); + + // update graph immediately + useJson.getState().setJson(updatedJsonStr); + + // update left editor contents in current format + const format = useFile.getState().format; + try { + const converted = await jsonToContent(updatedJsonStr, format); + useFile.getState().setContents({ contents: converted, hasChanges: true, format }); + } catch (convErr) { + console.warn("Failed to convert JSON to current format", convErr); + } + + toast.success("Node updated"); + setEditing(false); + onClose?.(); + } catch (error: any) { + toast.error(error?.message ?? "Failed to save node"); + } finally { + setSaving(false); + } + }; return ( - - - Content - - + + + + Node Content + + + + + + + Content + + + {editing ? ( + <> + + + + ) : ( + + )} + + - + {!editing ? ( + + ) : ( +
+