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
3 changes: 3 additions & 0 deletions .prettierrc.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,6 @@ overrides:
- files: "**/*.tsx"
options:
parser: alloy-ts
- files: "packages/dev-tools/**/*.tsx"
options:
parser: typescript
1 change: 1 addition & 0 deletions packages/core/src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ const nodesToContext = new WeakMap<RenderTextTree, Context>();
export function getContextForRenderNode(node: RenderTextTree) {
return nodesToContext.get(node);
}

export type RenderStructure = {};

export type RenderTextTree = (string | RenderTextTree)[];
Expand Down
30 changes: 30 additions & 0 deletions packages/dev-tools/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"name": "@alloy-js/dev-tools",
"version": "0.0.1",
"description": "Development tools for Alloy code generation",
"main": "./dist/src/index.js",
"scripts": {
"build": "tsc -p .",
"test": "vitest run",
"test:watch": "vitest -w"
},
"keywords": [],
"author": "brian.terlson@microsoft.com",
"license": "MIT",
"dependencies": {
"@alloy-js/core": "workspace:~",
"@uiw/react-json-view": "2.0.0-alpha.30",
"esbuild": "^0.19.8",
"flatted": "^3.3.1",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/node": "^22.9.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"typescript": "catalog:",
"vitest": "catalog:"
},
"type": "module"
}
17 changes: 17 additions & 0 deletions packages/dev-tools/src/client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import * as Flatted from "flatted";
import ReactDOM from "react-dom";
import { AnnotatedNodeViewer } from "./components/AnnotatedNodeViewer";

declare global {
interface Window {
__INITIAL_DATA__: any;
}
}

const root = document.getElementById("root");
if (root) {
ReactDOM.render(
<AnnotatedNodeViewer node={Flatted.parse(window.__INITIAL_DATA__)} />,
root,
);
}
38 changes: 38 additions & 0 deletions packages/dev-tools/src/components/AnnotatedNodeContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import React, { createContext, useContext, useState } from "react";
import { AnnotatedNode } from "../types";

interface AnnotatedNodeContextType {
selectedNode: AnnotatedNode | null;
setSelectedNode: (node: AnnotatedNode | null) => void;
hoveredNode: AnnotatedNode | null;
setHoveredNode: (node: AnnotatedNode | null) => void;
}

const AnnotatedNodeContext = createContext<AnnotatedNodeContextType>({
selectedNode: null,
setSelectedNode: () => {},
hoveredNode: null,
setHoveredNode: () => {},
});

export const AnnotatedNodeProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [selectedNode, setSelectedNode] = useState<AnnotatedNode | null>(null);
const [hoveredNode, setHoveredNode] = useState<AnnotatedNode | null>(null);

return (
<AnnotatedNodeContext.Provider
value={{
selectedNode,
setSelectedNode,
hoveredNode,
setHoveredNode,
}}
>
{children}
</AnnotatedNodeContext.Provider>
);
};

export const useAnnotatedNode = () => useContext(AnnotatedNodeContext);
282 changes: 282 additions & 0 deletions packages/dev-tools/src/components/AnnotatedNodeViewer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,282 @@
import JsonView from "@uiw/react-json-view";
import React from "react";
import { AnnotatedNode, OutputDirectory, OutputFile } from "../types";
import { ComponentView } from "./ComponentView";
import { AnnotatedNodeProvider, useAnnotatedNode } from "./AnnotatedNodeContext";
import { FileExplorer } from "./FileExplorer";

interface AnnotatedNodeViewerProps {
node: OutputDirectory;
depth?: number;
}

const DEPTH = [
"#f0f4ff", // Level 1 - Very light blue
"#fff0f4", // Level 2 - Very light pink
"#f3fff0", // Level 3 - Very light green
"#fff0fc", // Level 4 - Very light purple
"#fffaf0", // Level 5 - Very light orange
"#f0fffc", // Level 6 - Very light cyan
"#fff0f0", // Level 7 - Very light red
"#f4f0ff", // Level 8 - Very light violet
"#f0fff4", // Level 9 - Very light mint
];

// TODO: Reactive contexts
// TODO: Source maps (babel transform)
// TODO: Live transform

const NodeContent: React.FC<{
item: AnnotatedNode;
depth: number;
index: number;
}> = React.memo(({ item, depth, index }) => {
const { selectedNode, setSelectedNode, hoveredNode, setHoveredNode } =
useAnnotatedNode();

if (typeof item === "string") {
return item;
}

if (
!item.component ||
item.component === "Provider" ||
item.component === "Indent"
) {
return item.rendered.map((subItem, index) => (
<NodeContent key={index} item={subItem} depth={depth} index={index} />
));
}

const isSelected = item === selectedNode;
const isHovered = item === hoveredNode;

return (
<span
className="content"
style={{
backgroundColor: DEPTH[(depth * (index + 2)) % DEPTH.length],
outline:
isSelected ? "2px solid #007bff"
: isHovered ? "1px solid #ccc"
: "none",
cursor: "pointer",
display: "inline",
}}
onClick={(e) => {
e.stopPropagation();
if (isSelected) {
setSelectedNode(null);
} else {
setSelectedNode(item);
}
}}
onMouseEnter={() => setHoveredNode(item)}
onMouseLeave={() => setHoveredNode(null)}
>
{item.rendered.map((subItem, index) => (
<NodeContent
key={index}
item={subItem}
depth={depth + 1}
index={index}
/>
))}
</span>
);
});

const NodeDetails: React.FC = () => {
const { selectedNode, hoveredNode } = useAnnotatedNode();
const nodeToShow = selectedNode || hoveredNode;

if (!nodeToShow) {
return <div>No element selected</div>;
}

if (typeof nodeToShow === "string") {
return (
<div>
<h3 style={{ fontSize: "1em", fontFamily: "monospace" }}>Text Node</h3>
<pre
style={{
whiteSpace: "pre-wrap",
wordBreak: "break-word",
borderRadius: "4px",
}}
>
{nodeToShow}
</pre>
</div>
);
}

let jsonView = null;
// @ts-expect-error
globalThis.__temp = nodeToShow.props;
try {
JSON.stringify(nodeToShow.props);
jsonView = <JsonView value={nodeToShow.props as any} />;
} catch {
jsonView = "Circular references detected, access __temp in dev tools";
}

return (
<div>
<h3 style={{ fontSize: "1em", fontFamily: "monospace" }}>
{nodeToShow.component}
</h3>
<p className="label">props</p>
{jsonView}
<p className="label">context</p>
<p className="label">source</p>
<pre>{nodeToShow.implementation}</pre>
</div>
);
};

export const AnnotatedNodeViewer: React.FC<AnnotatedNodeViewerProps> = ({
node,
depth = 0,
}) => {
const [selectedFile, setSelectedFile] = React.useState<
OutputFile | undefined
>();

return (
<AnnotatedNodeProvider>
<div
className="element-node-viewer"
style={{
backgroundColor: "white",
display: "flex",
flexDirection: "column",
height: "100vh",
}}
>
<div
style={{
display: "flex",
overflowY: "auto",
flex: "3",
padding: "1rem",
gap: "1rem",
minHeight: "100px", // Ensure minimum height
}}
>
<div
className="fileExplorer"
style={{
margin: 0,
minWidth: 300,
fontFamily: "monospace",
flex: 0,
overflowY: "auto",
}}
>
<FileExplorer
directory={node}
onFileSelect={setSelectedFile}
selectedFile={selectedFile}
/>
</div>
<div style={{ flex: 1, display: "flex", flexDirection: "column" }}>
{selectedFile && (
<div
style={{
padding: "0.5rem",
borderBottom: "1px solid #ccc",
fontFamily: "monospace",
fontSize: "14px",
color: "#666",
}}
>
📄 {selectedFile.path}
</div>
)}
<pre
style={{
margin: 0,
padding: "0.5rem",
border: "1px solid #ccc",
borderTop: "none",
overflow: "auto",
fontFamily: "monospace",
flex: 1,
}}
>
{selectedFile?.contents.map((item, index) => (
<React.Fragment key={index}>
<NodeContent item={item} depth={depth} index={index} />
</React.Fragment>
)) ?? "Select a file!"}
</pre>
</div>
</div>
<div
className="gutter"
style={{
height: "4px",
backgroundColor: "#ccc",
cursor: "row-resize",
position: "relative",
}}
onMouseDown={(e) => {
const startY = e.clientY;
const bottomPane = e.currentTarget
.nextElementSibling as HTMLElement;
const startHeight = bottomPane.offsetHeight;

const handleMouseMove = (e: MouseEvent) => {
const delta = e.clientY - startY;
const newHeight = Math.max(100, startHeight - delta);
bottomPane.style.height = `${newHeight}px`;
bottomPane.style.flex = "none";
};

const handleMouseUp = () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};

document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
}}
/>
<div
style={{
display: "flex",
minHeight: "100px",
height: "300px",
overflowY: "auto",
borderTop: "1px solid #ccc",
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
padding: "1rem",
overflowY: "auto",
borderRight: "1px solid #dee2e6",
flex: "1",
}}
>
{selectedFile?.contents.map((item, index) => (
<ComponentView key={index} item={item} depth={0} index={index} />
))}
</div>
<div
style={{
width: 300,
padding: "1rem",
overflowY: "auto",
}}
>
<NodeDetails />
</div>
</div>
</div>
</AnnotatedNodeProvider>
);
};
Loading