Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<!-- Copilot instructions for contributors and AI coding agents -->

# 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.
115 changes: 111 additions & 4 deletions src/features/editor/views/GraphView/CustomNode/ObjectNode.tsx
Original file line number Diff line number Diff line change
@@ -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 = () => {
Expand All @@ -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<string | number>, 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 (
<Styled.StyledRow
$value={row.value}
data-key={`${row.key}: ${row.value}`}
data-x={x}
data-y={y + rowPosition}
style={{ pointerEvents: "all" }}
>
<Styled.StyledKey $type="object">{row.key}: </Styled.StyledKey>
<TextRenderer>{getRowText()}</TextRenderer>
{isEditable ? (
!isEditing ? (
<span style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
<TextRenderer>{getRowText()}</TextRenderer>
<button
onClick={e => {
e.stopPropagation();
setTempValue(String(row.value ?? ""));
setIsEditing(true);
}}
style={{ fontSize: 11, padding: "2px 6px", borderRadius: 3, cursor: "pointer" }}
>
Edit
</button>
</span>
) : (
<span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
<input
value={tempValue}
onClick={e => e.stopPropagation()}
onChange={e => setTempValue(e.target.value)}
style={{ fontSize: 12, padding: "2px 6px", minWidth: 80 }}
/>
<button
onClick={e => {
e.stopPropagation();
handleSave();
}}
style={{ fontSize: 11, padding: "2px 6px", borderRadius: 3, cursor: "pointer" }}
>
Save
</button>
<button
onClick={e => {
e.stopPropagation();
handleCancel();
}}
style={{ fontSize: 11, padding: "2px 6px", borderRadius: 3, cursor: "pointer" }}
>
Cancel
</button>
</span>
)
) : (
<TextRenderer>{getRowText()}</TextRenderer>
)}
</Styled.StyledRow>
);
};
Expand All @@ -44,7 +151,7 @@ const Node = ({ node, x, y }: CustomNodeProps) => (
$isObject
>
{node.text.map((row, index) => (
<Row key={`${node.id}-${index}`} row={row} x={x} y={y} index={index} />
<Row key={`${node.id}-${index}`} row={row} x={x} y={y} index={index} node={node as NodeData} />
))}
</Styled.StyledForeignObject>
);
Expand Down
134 changes: 131 additions & 3 deletions src/features/editor/views/GraphView/CustomNode/TextNode.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<string | number>, 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 (
<Styled.StyledForeignObject
Expand All @@ -50,9 +116,61 @@ const Node = ({ node, x, y }: CustomNodeProps) => {
data-y={y}
data-key={JSON.stringify(text)}
$isParent={false}
style={{ pointerEvents: "all" }}
>
<Styled.StyledKey $value={value} $type={typeof text[0].value}>
<TextRenderer>{value}</TextRenderer>
{!isEditing ? (
<span style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
<TextRenderer>{value}</TextRenderer>
<button
onClick={e => {
e.stopPropagation();
setTempValue(String(value ?? ""));
setIsEditing(true);
}}
title="Edit"
style={{
fontSize: 11,
padding: "2px 6px",
borderRadius: 3,
border: "1px solid rgba(0,0,0,0.1)",
background: "transparent",
cursor: "pointer",
}}
>
Edit
</button>
</span>
) : (
<span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
<input
value={tempValue}
onClick={e => e.stopPropagation()}
onChange={e => setTempValue(e.target.value)}
style={{ fontSize: 12, padding: "2px 6px", minWidth: 80 }}
/>
<button
onClick={e => {
e.stopPropagation();
handleSave();
}}
title="Save"
style={{ fontSize: 11, padding: "2px 6px", borderRadius: 3, cursor: "pointer" }}
>
Save
</button>
<button
onClick={e => {
e.stopPropagation();
handleCancel();
}}
title="Cancel"
style={{ fontSize: 11, padding: "2px 6px", borderRadius: 3, cursor: "pointer" }}
>
Cancel
</button>
</span>
)}
</Styled.StyledKey>
</StyledTextNodeWrapper>
)}
Expand All @@ -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);