diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..a03c497 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,17 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "vite", + "runtimeExecutable": "npx", + "runtimeArgs": ["vite", "--port", "3000"], + "port": 3000 + }, + { + "name": "netlify", + "runtimeExecutable": "npx", + "runtimeArgs": ["netlify", "dev"], + "port": 8888 + } + ] +} diff --git a/.github/workflows/dev.yaml b/.github/workflows/dev.yaml index a641dfa..6710ee8 100644 --- a/.github/workflows/dev.yaml +++ b/.github/workflows/dev.yaml @@ -26,6 +26,8 @@ jobs: ${{ runner.os }}- - name: install run: npm ci + - name: lint + run: npm run lint - name: test run: npm test - name: build diff --git a/.gitignore b/.gitignore index 2b689ae..e727af5 100644 --- a/.gitignore +++ b/.gitignore @@ -26,4 +26,5 @@ npm-debug.log* yarn-debug.log* yarn-error.log* -shared/dist \ No newline at end of file +shared/dist +design_handoff* \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..65361cc --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,96 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +Structure (https://structure.codes) is an interactive web tool for designing and exploring repository directory structures. The user pastes a GitHub repo URL or picks a community template; the app renders the file tree two ways at once — as editable ASCII tree text (Monaco editor) and as a pannable/zoomable node graph (SVG) — and lets them filter by depth, hide files/dot-entries, then copy or download the result as a `.tree` file. + +It is a React SPA served by Netlify, with a handful of serverless functions acting as a CORS/User-Agent proxy in front of the GitHub API and a remote templates repo. There is no database and no app-owned backend state. + +## Commands + +```sh +npm start # netlify dev — runs Vite (port 3000) proxied on :8888 with the /api functions +npm run build # tsc type-check then vite build -> dist/ +npm run lint # eslint . (flat config in eslint.config.mjs) +npm test # vitest run (all tests, one pass) +npm run test:watch # vitest in watch mode +npm run test:functions # vitest run netlify — only the serverless-function tests +``` + +Run a single test file or test by name: + +```sh +npx vitest run src/app/HomeView/Dropdown/__tests__/Dropdown.test.tsx +npx vitest run -t "returns template names" +``` + +Always run the app with `npm start` (`netlify dev`), **not** bare `vite` — the `/api/*` routes are Netlify functions and won't exist under plain Vite. The functions self-register their routes via the exported `config.path` in each file (e.g. `/api/github`), so there are no redirect rules wiring them up. + +## Test environment + +Vitest runs two environments from one config (`vite.config.mts`): +- `src/**` → **jsdom**, given a real origin (`http://localhost:3000/`) so the app's relative `fetch("/api/...")` calls resolve to absolute URLs that **msw** can intercept. +- `netlify/**` → **node** (matched via `environmentMatchGlobs`). Function tests stub `global.fetch` directly rather than using msw. + +`src/setupTests.ts` is the shared setup file. + +## Architecture + +### Shared tree model and the two-pane bridge + +The whole app revolves around one data type, `TreeType` (from the external `@structure-codes/utils` package): a recursive `{ _index, name, children }` node. The package also provides the two canonical converters used everywhere: +- `treeStringToJson(str)` — ASCII tree text → `TreeType[]` +- `treeJsonToString({ tree, tabChar, options })` — `TreeType[]` → ASCII text, applying the visibility `options` (depth limit, hideFiles, hideDots) + +State lives in **Jotai** atoms in [src/store.ts](src/store.ts) (`useAtom`/`useAtomValue`/`useSetAtom`; the app is wrapped in a Jotai `` in [main.tsx](src/app/main.tsx)): +- `treeAtom` — the current `TreeType[]` (the source of truth for structure) +- `settingsAtom` — `{ depth, hideFiles, hideDots }` visibility filters +- `baseTreeAtom` — a string identifying where the current tree came from (a GitHub URL or a template name); used to derive repo name/link and to reset the graph on source change +- `hoveredNodeAtom` / `selectedNodeAtom` — **transient, non-persisted** cross-pane link state holding a node's stable path id + +The two panes must agree on *exactly* which nodes are visible. This is the central invariant: the filtering predicates in [src/app/HomeView/ModelPanel/layout.ts](src/app/HomeView/ModelPanel/layout.ts) (`isHidden`, `looksLikeFile`, the `SPECIAL_FILES` set) are hand-mirrored from `treeJsonToString`'s behavior so the graph and the Monaco text never disagree. **If you change visibility logic, change it in both places or the panes desync.** + +Cross-pane hover/select linking works through stable path ids (e.g. `__root__/src/app`): +- `layout.ts:toRawTree` assigns each node an id from its path; `codeLineIds` produces the ordered id list mirroring `treeJsonToString`'s pre-order line output, so **editor line N ↔ ids[N-1]**. +- [CodePanel](src/app/HomeView/CodePanel/index.tsx) maps Monaco mouse events → line → id → sets the hover/select atoms, and conversely renders Monaco line decorations from those atoms. +- [ModelPanel](src/app/HomeView/ModelPanel/index.tsx) sets the same atoms from SVG node events, highlights the hovered node's lineage to the root, and recenters on selection. + +So hovering a node in either pane lights up the matching row/node in the other, and clicking recenters the graph. + +### The layout pipeline (ModelPanel) + +[layout.ts](src/app/HomeView/ModelPanel/layout.ts) is a pure pipeline, ported from a design prototype and decoupled from React: + +``` +TreeType[] --toRawTree--> RawNode --buildVisibleTree--> VisNode --layoutTree--> { nodes, links, bbox } --linkPath--> SVG path +``` + +`buildVisibleTree` applies filters + collapse state; `layoutTree` does naive leaf-packing positioning for three layout kinds (`tree-h` default, `tree-v`, `radial`). ModelPanel memoizes this pipeline aggressively so pan/zoom/hover re-renders (which only touch transform + atoms) don't recompute geometry. Above ~600 visible nodes it drops the per-node glow filter to stay responsive (`heavy` flag). Pan/zoom is hand-rolled on an SVG `` (pointer events + a non-passive wheel listener), not a library. + +### CodePanel (Monaco) + +[CodePanel](src/app/HomeView/CodePanel/index.tsx) registers a custom `"tree"` Monaco language ([customLang.ts](src/app/HomeView/CodePanel/customLang.ts)) and theme, and a folding-range provider derived from the tree. The editor is currently **read-only**: it renders `treeJsonToString(treeState)` and reflects atom-driven hover/select decorations. There is a large `handleEditorChange` function for live tree-prefix auto-formatting that is **intentionally disabled** (the `onDidChangeModelContent` wiring is commented out) — it's WIP, not dead code; leave it unless explicitly working on edit support. The branch-glyph constants/helpers live in [treeHelper.ts](src/app/HomeView/CodePanel/treeHelper.ts) (`│`, `├──`, `└──`). + +### Data loading (Dropdown) and serverless functions + +[Dropdown](src/app/HomeView/Dropdown/index.tsx) is the only place that loads trees. It reads the source from the URL on mount (deep-linkable: `/template/:template`, and `/template/github?owner=&repo=&branch=`), fetches via **`@tanstack/react-query`** (v5; query keys are arrays, e.g. `["templatesData"]`, and `useQuery` uses the object form), and writes `treeAtom` + `baseTreeAtom`. ModelPanel reuses the same query key (`["templatesData"]`) to resolve a template's origin GitHub link from the manifest. + +The three Netlify functions in [netlify/functions/](netlify/functions/) are thin proxies (each adds a `User-Agent`, which GitHub requires, and handles errors): +- `github.ts` (`POST /api/github`) — fetches a repo's recursive git tree from the GitHub API, tries the requested branch then falls back `main`→`master`, and converts to `TreeType[]` via `netlify/lib/tree.ts:githubToTree`. +- `templates.ts` (`GET /api/templates`) — lists templates from the remote `structure-templates` repo. +- `template.ts` (`GET /api/template/:template`) — fetches one template's `.tree` text. + +Templates and `.tree` files are **not** in this repo — they live in `structure-codes/structure-templates` on GitHub and are fetched at runtime. + +## Conventions + +- **Routing/dev:** `netlify dev` on `:8888` is the real entry point; Vite is the proxied framework server. +- **Component styles: CSS Modules.** Each component has a sibling `style.module.css` imported as `import classes from "./style.module.css"` (default import named `classes`, so call sites stay `classes.foo`). All values come from the token CSS vars — no hardcoded colors/fonts/dimensions. The component library is **MUI v5+ (`@mui/material`, emotion engine)**; there are no `makeStyles`/`useStyles` `style.ts` files. **Gotcha:** MUI's internal classes (`.MuiOutlinedInput-root`, `.MuiSlider-thumb`, …) are global, so wrap them in `:global(...)`. [main.tsx](src/app/main.tsx) wraps the app in `` so emotion injects its styles *first* and the CSS Modules win the cascade on order; as a belt-and-suspenders specificity margin, overrides applied to the *same element* as a MUI base class also double the local class (`.button.button`). Plain `.css` (non-module) is still used for the viz/Monaco internals ([tree.css](src/app/HomeView/ModelPanel/tree.css), [codePanel.css](src/app/HomeView/CodePanel/codePanel.css)) and the global utilities in [index.css](src/app/index.css). +- **Design tokens:** the color scale is authored once, in oklch, in [src/app/tokens.ts](src/app/tokens.ts). `applyTokens()` (called in `main.tsx` before render) injects the colors, `--accent`, and the font vars (`--font-ui`, `--font-mono`) onto `:root`; the `tokens` hex mirror and the `FONT_*` constants are exported for the JS-only consumers that can't read CSS vars / parse `oklch()` — the MUI theme ([theme.ts](src/app/theme.ts)) and Monaco ([customLang.ts](src/app/HomeView/CodePanel/customLang.ts), CodePanel editor options). `--accent` is the one accent definition (also feeds `depthColor.ts`). The hex mirror is *derived* via [oklch.ts](src/app/oklch.ts) — don't reintroduce a hand-maintained hex or font-stack table. Structural scales (`--radius`, `--radius-lg`, `--dur-fast`, `--dur`, `--space-1..4`) and the reusable `.glass` overlay utility live in [index.css](src/app/index.css); use them rather than hardcoding values. +- **Token guardrail:** component styles under `src/app` must use the `--token` vars, not raw colors. `npm run lint` (and CI) runs [scripts/check-tokens.mjs](scripts/check-tokens.mjs), which fails on any hex/`oklch()` literal outside the allowlisted token/bridge files (`tokens.ts`, `oklch.ts`, `theme.ts`, `index.css`, `customLang.ts`, `depthColor.ts`). Add a new literal home to that script's `ALLOW` set only if a JS bridge genuinely can't read a CSS var. +- **Facelift status:** all phases complete. The MUI v4→v5+ upgrade landed (`@mui/material` + `@emotion/*`, `palette.mode`, Autocomplete imported from core, `slotProps.input` replacing the removed `InputProps`, no `.MuiButton-label` wrapper). MUI/emotion are **inlined** in [vite.config.mts](vite.config.mts) so jsdom can transform their native ESM (a leftover guard from when React 17 didn't expose `react/jsx-runtime`; harmless to keep). +- **State + React stack:** state is **Jotai** (migrated off Recoil). The app is on **React 19** (`createRoot` in [main.tsx](src/app/main.tsx)). Data fetching is **`@tanstack/react-query` v5** (migrated off the unmaintained `react-query` v3). Clipboard uses the native `navigator.clipboard` (the old `react-copy-to-clipboard` dep was dropped). A batch of unused deps (`react-d3-tree`, `react-flow-renderer`, `react-organizational-chart`, `react-syntax-highlighter`, `prismjs`) was removed. Tests use `@testing-library/react` v16 (with an explicit `@testing-library/dom` peer). +- **Lint:** flat ESLint config. `@typescript-eslint/no-explicit-any` is **off** by design (API payloads + MUI handlers lean on `any`); unused vars are an error but `_`-prefixed names are ignored. The React Compiler hook ruleset is intentionally not enabled — only classic rules-of-hooks + exhaustive-deps (as a warning). +- React 19, `react-jsx` runtime, TS `strict`, `noEmit` (Vite/esbuild does the transpile; `tsc` is type-check only). diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..37c73a6 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,41 @@ +import js from "@eslint/js"; +import globals from "globals"; +import tseslint from "typescript-eslint"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; + +export default tseslint.config( + { + ignores: ["dist", "node_modules", ".netlify", "public"], + }, + { + files: ["**/*.{ts,tsx}"], + extends: [js.configs.recommended, ...tseslint.configs.recommended], + languageOptions: { + ecmaVersion: 2022, + globals: { ...globals.browser, ...globals.node }, + }, + plugins: { + "react-hooks": reactHooks, + "react-refresh": reactRefresh, + }, + rules: { + // Classic rules of hooks; the React Compiler rule set in v7's + // `recommended` is too noisy for this codebase. + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "warn", + "react-refresh/only-export-components": [ + "warn", + { allowConstantExport: true }, + ], + // Catching unused references is the primary reason we lint here. + "@typescript-eslint/no-unused-vars": [ + "error", + { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, + ], + // This codebase leans on `any` in a number of intentional spots + // (API payloads, MUI event handlers); don't fail the build on it. + "@typescript-eslint/no-explicit-any": "off", + }, + } +); diff --git a/index.html b/index.html index cbe1413..f5b6192 100644 --- a/index.html +++ b/index.html @@ -10,6 +10,13 @@ content="An interactive design tool to help developers structure their code" /> + + + + RawNode --buildVisibleTree--> VisNode +// --layoutTree--> { nodes, links, bbox, ... } --linkPath--> SVG path +// +// IMPORTANT: the filtering predicates here mirror @structure-codes/utils' +// treeJsonToString EXACTLY, so the graph and the Monaco code panel always agree on +// which nodes are visible (this fixes the old ModelPanel, which ignored settings). +import { TreeType } from "@structure-codes/utils"; + +export type LayoutKind = "tree-h" | "tree-v" | "radial"; +export type NodeType = "dir" | "file"; + +export interface RawNode { + id: string; + /** Raw name (predicates run on this, matching treeJsonToString). */ + name: string; + /** Display label (a single trailing "/" stripped). */ + label: string; + type: NodeType; + depth: number; // root = 0, top-level entries = 1 (matches treeJsonToString levels) + index: number; // TreeType._index — link key to the Monaco code line + children: RawNode[]; +} + +export interface VisNode { + id: string; + label: string; + type: NodeType; + depth: number; + index: number; + visKids: VisNode[]; + collapsed: boolean; + hasHiddenChildren: boolean; + hiddenCount: number; +} + +export interface BuildOpts { + collapsed: Set; + hideFiles: boolean; + hideDots: boolean; + depthLimit: number; // 0 = show all (matches settingsAtom.depth) +} + +export interface LayoutOpts { + layout: LayoutKind; + nodeGap: number; + levelGap: number; +} + +export interface PlacedNode { + id: string; + label: string; + type: NodeType; + depth: number; + index: number; + x: number; + y: number; + ang?: number; + collapsed: boolean; + hasHiddenChildren: boolean; + hiddenCount: number; + isLeaf: boolean; +} + +export interface PlacedLink { + id: string; + from: PlacedNode; + to: PlacedNode; + depth: number; +} + +export interface LayoutResult { + nodes: PlacedNode[]; + links: PlacedLink[]; + bbox: { minX: number; minY: number; maxX: number; maxY: number }; + maxDepth: number; + leaves: number; +} + +// Files with no extension that treeJsonToString still treats as files (so they're +// hidden by hideFiles, and as "dots" by hideDots). Exact, case-insensitive match. +const SPECIAL_FILES = new Set([ + "dockerfile", + "vagrantfile", + "jenkinsfile", + "makefile", + "license", + "changelog", + "authors", +]); +const isSpecialFile = (name: string) => SPECIAL_FILES.has(name.toLowerCase()); + +const stripSlash = (name: string) => (name.endsWith("/") ? name.slice(0, -1) : name); + +/** A leaf "looks like a file" iff its name has an extension or is a special file. */ +const looksLikeFile = (node: { name: string; children: unknown[] }) => + node.children.length === 0 && (node.name.includes(".") || isSpecialFile(node.name)); + +// ---- TreeType[] -> RawNode (single rooted tree with stable path ids) ---------- +export function toRawTree(tree: TreeType[], rootName: string): RawNode { + const build = (node: TreeType, parentPath: string, depth: number): RawNode => { + const label = stripSlash(node.name); + const id = `${parentPath}/${node.name}`; + const type: NodeType = + node.children.length > 0 || node.name.endsWith("/") || !looksLikeFile(node) ? "dir" : "file"; + return { + id, + name: node.name, + label, + type, + depth, + index: node._index, + children: node.children.map(c => build(c, id, depth + 1)), + }; + }; + return { + id: "__root__", + name: rootName, + label: rootName, + type: "dir", + depth: 0, + index: -1, + children: tree.map(c => build(c, "__root__", 1)), + }; +} + +// ---- Build the visible (filtered + collapsed) tree ---------------------------- +// Predicates intentionally match treeJsonToString so both panes agree. +const isHidden = (node: RawNode, opts: BuildOpts): boolean => { + if (node.depth > opts.depthLimit && opts.depthLimit > 0) return true; + if (opts.hideDots && (node.name.startsWith(".") || isSpecialFile(node.name))) return true; + if (opts.hideFiles && looksLikeFile(node)) return true; + return false; +}; + +function countDescendants(node: RawNode, opts: BuildOpts): number { + let n = 0; + node.children.forEach(c => { + if (isHidden(c, opts)) return; + n += 1 + countDescendants(c, opts); + }); + return n; +} + +export function buildVisibleTree(root: RawNode, opts: BuildOpts): VisNode { + const visit = (node: RawNode): VisNode => { + const candidateKids = node.children.filter(c => !isHidden(c, opts)); + // depth is 1-indexed for entries (root is 0 and never capped) + const atDepthCap = opts.depthLimit > 0 && node.depth >= opts.depthLimit; + const isCollapsed = opts.collapsed.has(node.id); + const hide = (isCollapsed || atDepthCap) && candidateKids.length > 0; + return { + id: node.id, + label: node.label, + type: node.type, + depth: node.depth, + index: node.index, + collapsed: hide, + hasHiddenChildren: hide, + hiddenCount: hide ? countDescendants(node, opts) : 0, + visKids: hide ? [] : candidateKids.map(visit), + }; + }; + return visit(root); +} + +// Ordered stable ids for the Monaco code panel, one per emitted line. Mirrors +// treeJsonToString's pre-order output (which ignores graph collapse), so line N of +// the editor maps to ids[N - 1]. This is the bridge for cross-pane hover-linking. +export function codeLineIds(root: RawNode, opts: Omit): string[] { + const vis = buildVisibleTree(root, { ...opts, collapsed: new Set() }); + const ids: string[] = []; + const rec = (n: VisNode) => { + ids.push(n.id); + n.visKids.forEach(rec); + }; + vis.visKids.forEach(rec); // skip the synthetic root (not emitted as a line) + return ids; +} + +// ---- Tidy layout (naive leaf-packing; one leaf per slot) ---------------------- +export function layoutTree(visRoot: VisNode, opts: LayoutOpts): LayoutResult { + const { layout, nodeGap, levelGap } = opts; + const pos = new Map(); + let leafCounter = 0; + + const assignPos = (n: VisNode): number => { + let p: number; + if (n.visKids.length === 0) { + p = leafCounter++; + } else { + const kids = n.visKids.map(assignPos); + p = (kids[0] + kids[kids.length - 1]) / 2; + } + pos.set(n.id, p); + return p; + }; + assignPos(visRoot); + const leaves = Math.max(1, leafCounter); + + const nodes: PlacedNode[] = []; + const links: PlacedLink[] = []; + let maxDepth = 0; + + const place = (n: VisNode, parent: PlacedNode | null) => { + maxDepth = Math.max(maxDepth, n.depth); + const p = pos.get(n.id) as number; + let x: number; + let y: number; + let ang: number | undefined; + if (layout === "tree-v") { + x = p * nodeGap; + y = n.depth * levelGap; + } else if (layout === "radial") { + const r = n.depth * (levelGap * 0.82); + const span = Math.PI * 2 * (leaves <= 1 ? 0 : 0.86); + ang = (p / leaves) * span - span / 2 - Math.PI / 2; + x = r * Math.cos(ang); + y = r * Math.sin(ang); + } else { + // tree-h (default) + x = n.depth * levelGap; + y = p * nodeGap; + } + const rec: PlacedNode = { + id: n.id, + label: n.label, + type: n.type, + depth: n.depth, + index: n.index, + x, + y, + ang, + collapsed: n.collapsed, + hasHiddenChildren: n.hasHiddenChildren, + hiddenCount: n.hiddenCount, + isLeaf: n.visKids.length === 0 && !n.hasHiddenChildren, + }; + nodes.push(rec); + if (parent) links.push({ id: `${parent.id}>${n.id}`, from: parent, to: rec, depth: n.depth }); + n.visKids.forEach(k => place(k, rec)); + }; + place(visRoot, null); + + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + nodes.forEach(p => { + minX = Math.min(minX, p.x); + maxX = Math.max(maxX, p.x); + minY = Math.min(minY, p.y); + maxY = Math.max(maxY, p.y); + }); + + return { nodes, links, bbox: { minX, minY, maxX, maxY }, maxDepth, leaves }; +} + +// ---- Link path per layout ----------------------------------------------------- +export function linkPath(l: PlacedLink, layout: LayoutKind): string { + const { from, to } = l; + if (layout === "tree-v") { + const my = (from.y + to.y) / 2; + return `M ${from.x} ${from.y} C ${from.x} ${my}, ${to.x} ${my}, ${to.x} ${to.y}`; + } + if (layout === "radial") { + const cx = ((from.x + to.x) / 2) * 0.6; + const cy = ((from.y + to.y) / 2) * 0.6; + return `M ${from.x} ${from.y} Q ${cx} ${cy}, ${to.x} ${to.y}`; + } + const mx = (from.x + to.x) / 2; + return `M ${from.x} ${from.y} C ${mx} ${from.y}, ${mx} ${to.y}, ${to.x} ${to.y}`; +} diff --git a/src/App/HomeView/ModelPanel/style.module.css b/src/App/HomeView/ModelPanel/style.module.css new file mode 100644 index 0000000..ac0b4d0 --- /dev/null +++ b/src/App/HomeView/ModelPanel/style.module.css @@ -0,0 +1,6 @@ +.modelContainer { + width: 100%; + height: 100%; + position: relative; + overflow: hidden; +} diff --git a/src/App/HomeView/ModelPanel/tree.css b/src/App/HomeView/ModelPanel/tree.css new file mode 100644 index 0000000..e24a995 --- /dev/null +++ b/src/App/HomeView/ModelPanel/tree.css @@ -0,0 +1,177 @@ +/* Visualization styles for the new ModelPanel (Structure facelift). + Colors come from the design tokens injected onto :root by src/app/tokens.ts. */ + +.viz-wrap { + position: relative; + width: 100%; + height: 100%; + overflow: hidden; + background: var(--canvas); + /* faint accent radial glow top-center + subtle dot-grid */ + background-image: radial-gradient( + circle at 50% -10%, + color-mix(in oklab, var(--accent) 14%, transparent), + transparent 55% + ), + radial-gradient(circle, var(--border-soft) 1px, transparent 1px); + background-size: 100% 100%, 30px 30px; + background-position: 0 0, 0 0; + cursor: grab; + touch-action: none; +} +.viz-wrap:active { + cursor: grabbing; +} +.viz-wrap:focus { + outline: none; +} +.viz-wrap:focus-visible { + outline: none; + box-shadow: inset 0 0 0 1.5px color-mix(in oklab, var(--accent) 55%, transparent); +} + +.viz-svg { + display: block; + width: 100%; +} + +.viz-links path { + transition: stroke-opacity 0.15s, stroke-width 0.15s; +} + +.viz-node { + cursor: pointer; + transition: opacity 0.15s; +} +.viz-node circle.viz-dot { + transition: r 0.15s; +} + +.viz-label { + font-family: var(--font-mono); + font-size: 12.5px; + paint-order: stroke; + stroke: var(--canvas); + stroke-width: 3px; + stroke-linejoin: round; + /* pointer-events: none; + user-select: none; */ +} +.viz-count { + fill: var(--faint); + font-size: 10px; +} +.viz-plus { + pointer-events: none; + user-select: none; + font-size: 10px; + font-weight: 700; +} +.viz-toggle { + cursor: pointer; +} + +/* ---- Overlays (chip / legend / zoom) ---- */ +/* Glass treatment comes from the shared `.glass` utility (index.css); this just + positions the overlays. */ +.viz-overlay { + position: absolute; +} + +.viz-repochip { + top: 16px; + left: 16px; + display: flex; + align-items: center; + gap: 9px; + padding: 7px 13px; +} +.viz-repochip .repo-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--accent); + box-shadow: 0 0 8px var(--accent); +} +.viz-repochip .repo-name { + font-family: var(--font-mono); + font-weight: 600; + font-size: 13px; + color: var(--text); +} +.viz-repochip .repo-meta { + font-size: 12px; + color: var(--muted); + padding-left: 9px; + border-left: 1px solid var(--faint); +} + +.viz-ghmark { + position: absolute; + top: 16px; + right: 16px; + color: var(--muted); +} + +.viz-legend { + bottom: 16px; + left: 16px; + padding: 11px 14px; +} +.viz-legend .legend-title { + font-size: 10.5px; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--faint); + margin-bottom: 8px; +} +.viz-legend .legend-swatches { + display: flex; + gap: 12px; +} +.viz-legend .legend-item { + display: flex; + align-items: center; + gap: 5px; + font-size: 11px; + color: var(--muted); +} +.viz-legend .legend-dot { + width: 11px; + height: 11px; + border-radius: 50%; +} + +.viz-zoom { + position: absolute; + bottom: 16px; + right: 16px; + display: flex; + flex-direction: column; + gap: 6px; + background: none; + border: none; + box-shadow: none; + backdrop-filter: none; + -webkit-backdrop-filter: none; +} +/* The `.glass` utility (added in markup) supplies background/blur/border/radius/shadow. */ +.viz-zoom button { + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + line-height: 1; + color: var(--muted); + cursor: pointer; + transition: color var(--dur-fast), border-color var(--dur-fast); +} +.viz-zoom button:hover { + color: var(--accent); + border-color: var(--accent); +} +.viz-zoom .zoom-fit { + font-size: 15px; +} diff --git a/src/App/HomeView/SettingsPanel/index.tsx b/src/App/HomeView/SettingsPanel/index.tsx new file mode 100755 index 0000000..9761ca9 --- /dev/null +++ b/src/App/HomeView/SettingsPanel/index.tsx @@ -0,0 +1,132 @@ +import React, { useEffect, useState } from "react"; +import classes from "./style.module.css"; +import { Button, Checkbox, FormGroup, FormControlLabel, Slider } from "@mui/material"; +import { baseTreeAtom, settingsAtom, treeAtom } from "../../../store"; +import { useAtom, useAtomValue } from "jotai"; +import { TreeType, treeJsonToString } from "@structure-codes/utils"; +import { saveAs } from "file-saver"; + +const getMaxDepth = (tree: TreeType[]): number => { + let maxDepth = 0; + const descendTree = (tree: TreeType[], depth: number) => { + tree.forEach(branch => { + return branch.children ? descendTree(branch.children, depth + 1) : null; + }); + maxDepth = Math.max(depth, maxDepth); + }; + descendTree(tree, maxDepth); + return maxDepth; +}; + +export const SettingsPanel = React.memo(() => { + const [settings, setSettings] = useAtom(settingsAtom); + const baseTree = useAtomValue(baseTreeAtom); + const treeState = useAtomValue(treeAtom); + const [maxDepth, setMaxDepth] = useState(0); + + const handleDepthChange = (event: Event | null, value: any) => { + setSettings({ + ...settings, + depth: value, + }); + }; + + const handleCheckboxChange = (event: React.ChangeEvent) => { + setSettings({ ...settings, [event.target.name]: event.target.checked }); + }; + + useEffect(() => { + const newDepth = getMaxDepth(treeState); + setMaxDepth(newDepth); + if (settings.depth < 0) handleDepthChange(null, newDepth); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [treeState]); + + const handleCopy = () => { + navigator.clipboard.writeText( + treeJsonToString({ tree: treeState, tabChar: "\t", options: settings }) + ); + }; + + const handleSave = () => { + const treeString = treeJsonToString({ tree: treeState, tabChar: "\t", options: settings }); + const blob = new Blob([treeString], { type: "text/plain;charset=utf-8" }); + + const stringRe = "[A-Za-z0-9-_.]+"; + const re = new RegExp( + `https://github.com/(?${stringRe})/(?${stringRe})((/tree)?/(?${stringRe}))?` + ); + const groups = baseTree.match(re)?.groups; + + const fileName = baseTree.startsWith("http") ? groups?.repo : baseTree; + saveAs(blob, `${fileName || "structure"}.tree`); + }; + + return ( +
+
+
+ Tree depth + {settings.depth > 0 ? settings.depth : "all"} +
+ +
+ + + } + label="Hide files" + /> + + } + label="Hide dot dirs and files" + /> + +
+ + +
+
+ ); +}); diff --git a/src/App/HomeView/SettingsPanel/style.module.css b/src/App/HomeView/SettingsPanel/style.module.css new file mode 100644 index 0000000..bf69192 --- /dev/null +++ b/src/App/HomeView/SettingsPanel/style.module.css @@ -0,0 +1,113 @@ +.settingsContainer { + width: 100%; + background: var(--panel); + border-top: 1px solid var(--border-soft); + padding: var(--space-4); + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.sliderContainer { + display: flex; + flex-direction: column; +} + +.sliderHead { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-bottom: var(--space-1); +} + +.sliderLabel { + color: var(--muted); + font-size: 12.5px; +} + +.sliderValue { + color: var(--accent); + font-family: var(--font-mono); + font-size: 12px; +} + +/* Local classes targeting MUI internals are doubled (.x.x) as a specificity + safety margin over MUI's base classes — see Dropdown/style.module.css. */ +.slider.slider { + color: var(--accent); + padding: 8px 0; +} +.slider.slider :global(.MuiSlider-rail) { + height: 4px; + border-radius: 4px; + background-color: var(--panel-3); + opacity: 1; +} +.slider.slider :global(.MuiSlider-track) { + height: 4px; + border-radius: 4px; +} +.slider.slider :global(.MuiSlider-thumb) { + width: 15px; + height: 15px; + background-color: var(--accent); + border: 3px solid var(--panel); + box-shadow: 0 0 0 1px var(--accent); + transition: transform var(--dur-fast); +} +.slider.slider :global(.MuiSlider-thumb):hover, +.slider.slider :global(.MuiSlider-thumb.Mui-focusVisible) { + box-shadow: 0 0 0 1px var(--accent); +} +.slider.slider :global(.MuiSlider-thumb.Mui-active) { + box-shadow: 0 0 0 2px var(--accent); +} + +.checks { + gap: var(--space-1); +} + +.check.check { + margin: 0; +} +.check.check :global(.MuiFormControlLabel-label) { + color: var(--muted); + font-size: 13.5px; + white-space: nowrap; +} +.check.check:hover :global(.MuiFormControlLabel-label) { + color: var(--text); +} +.check.check :global(.MuiCheckbox-root) { + color: var(--border); + padding: 6px; +} +.check.check :global(.MuiCheckbox-root.Mui-checked) { + color: var(--accent); +} + +.buttons { + display: flex; + gap: var(--space-2); + margin-top: var(--space-1); +} + +/* v5+ Button has no .MuiButton-label wrapper; the root is the flex container, + so icon/text spacing goes on the root via gap. */ +.button.button { + flex: 1; + gap: 6px; + color: var(--muted); + background: var(--panel-2); + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: 12.5px; + font-weight: 500; + text-transform: none; + padding: 7px 10px; +} +.button.button:hover { + background: var(--panel-3); + border-color: var(--panel-3); + color: var(--text); +} diff --git a/src/HomeView/HomeView.tsx b/src/App/HomeView/index.tsx similarity index 70% rename from src/HomeView/HomeView.tsx rename to src/App/HomeView/index.tsx index d8ed6ef..61557c3 100755 --- a/src/HomeView/HomeView.tsx +++ b/src/App/HomeView/index.tsx @@ -1,11 +1,12 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { Dropdown } from "./Dropdown"; import { CodePanel } from "./CodePanel"; import { SettingsPanel } from "./SettingsPanel"; import { ModelPanel } from "./ModelPanel"; -import { useStyles } from "./style"; -import { useMousePosition } from "./hooks"; -import { useMediaQuery, useTheme } from "@material-ui/core"; +import classes from "./style.module.css"; +import { useMousePosition } from "../../hooks"; +import { useMediaQuery, useTheme } from "@mui/material"; +import { GitHubMark } from "../../components/GitHubMark"; const dividerSize = 6; const Divider = ({ @@ -34,14 +35,14 @@ const Divider = ({ const handleMouseOver = useCallback(() => setIsHover(true), []); const handleMouseLeave = useCallback(() => setIsHover(false), []); const handleMouseDown = useCallback( - e => { + (e: any) => { onMouseDown(e); setIsDragging(true); }, [onMouseDown] ); const handleMouseUp = useCallback( - e => { + (e: any) => { onMouseUp(e); setIsDragging(false); }, @@ -55,8 +56,8 @@ const Divider = ({ height: direction === "horizontal" ? dividerSize : "100%", cursor: direction === "horizontal" ? "ns-resize" : "ew-resize", background: `${isHover || isDragging ? theme.palette.primary.main : "rgba(0,0,0,0)"}`, - borderTop: direction === "horizontal" ? "1px solid #646464" : "none", - borderLeft: direction === "vertical" ? "1px solid #646464" : "none", + borderTop: direction === "horizontal" ? "1px solid var(--border-soft)" : "none", + borderLeft: direction === "vertical" ? "1px solid var(--border-soft)" : "none", }} onMouseOver={handleMouseOver} onMouseLeave={handleMouseLeave} @@ -67,80 +68,83 @@ const Divider = ({ }; export const HomeView = () => { - const classes = useStyles(); const [leftWidth, setLeftWidth] = useState(0.25 * window.innerWidth); const [topHeight, setTopHeight] = useState(0.75 * window.innerHeight); const [isVerticalDragging, setIsVerticalDragging] = useState(false); const [isHorizontalDragging, setIsHorizontalDragging] = useState(false); const { x, y } = useMousePosition(isVerticalDragging || isHorizontalDragging); - const dropdownRef: any = useRef(null); const theme = useTheme(); const showModel = useMediaQuery(theme.breakpoints.up("sm")); - + // TODO: Get's kinda wrekt on instant window resize (changing in dev tools to phone size) + const shouldWrap = leftWidth < 700; + useEffect(() => { if (!isVerticalDragging || !x) return; // subtract half of divider width for fat divider setLeftWidth(x - dividerSize / 2); }, [isVerticalDragging, x]); - + useEffect(() => { if (!isHorizontalDragging || !y) return; // subtract half of divider width for fat divider - const dropdownHeight = dropdownRef.current?.clientHeight || 0; // TODO: FIX THIS BS WITH A REF setTopHeight(y - dividerSize / 2 - (shouldWrap ? 104 : 56)); - }, [isHorizontalDragging, y]); - + }, [isHorizontalDragging, shouldWrap, y]); + // handle the case where the mouse goes up and we miss it on the element event handler useEffect(() => { const handleMouseUp = () => { setIsVerticalDragging(false); setIsHorizontalDragging(false); }; - + window.addEventListener("mouseup", handleMouseUp); return () => window.removeEventListener("mouseup", handleMouseUp); }, []); - + const onMouseDownVertical = useCallback(() => setIsVerticalDragging(true), []); const onMouseUpVertical = useCallback(() => setIsVerticalDragging(false), []); const onMouseLeaveVertical = useCallback(() => setIsVerticalDragging(false), []); - + const onMouseDownHorizontal = useCallback(() => setIsHorizontalDragging(true), []); const onMouseUpHorizontal = useCallback(() => setIsHorizontalDragging(false), []); const onMouseLeaveHorizontal = useCallback(() => setIsHorizontalDragging(false), []); - - // TODO: Get's kinda wrekt on instant window resize (changing in dev tools to phone size) - const shouldWrap = leftWidth < 700; return ( -
-
- - - - -
- {showModel && ( - <> + <> +
+
+ + -
- -
- - )} -
+ +
+ {showModel && ( + <> + +
+ +
+ + )} +
+ + ); }; diff --git a/src/App/HomeView/style.module.css b/src/App/HomeView/style.module.css new file mode 100644 index 0000000..ef12f94 --- /dev/null +++ b/src/App/HomeView/style.module.css @@ -0,0 +1,28 @@ +.panelContainer { + display: flex; + height: 100%; + width: 100%; + background: var(--bg); +} + +.leftPanel { + display: flex; + flex-direction: column; + height: 100%; + min-width: 220px; + background: var(--panel); + border-right: 1px solid var(--border-soft); +} + +.rightPanel { + display: flex; + flex-direction: column; + flex-grow: 1; + background: var(--canvas); +} + +.icon { + position: absolute; + top: var(--space-4); + right: var(--space-4); +} diff --git a/src/App/index.css b/src/App/index.css new file mode 100644 index 0000000..9cc9be0 --- /dev/null +++ b/src/App/index.css @@ -0,0 +1,67 @@ +/* + * Design tokens for the Structure facelift. + * The color scale (--accent + the oklch surfaces/text vars) is the single source + * of truth in src/app/tokens.ts and is injected onto :root by applyTokens() at + * startup. --accent may be further overridden at runtime + * (document.documentElement.style.setProperty('--accent', …)). + * Only the derived, non-color tokens that depend on those vars live here. + * (--accent, --font-ui and --font-mono are injected by applyTokens; the color + * scale too. The structural scales below are static and CSS-only.) + */ +:root { + --shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 28px rgba(0, 0, 0, 0.35); + --focus-ring: 0 0 0 3px color-mix(in oklab, var(--accent) 18%, transparent); + --on-accent: #0c0a14; /* near-black ink for text/glyphs on an accent fill */ + + /* radii */ + --radius: 9px; /* controls: inputs, buttons */ + --radius-lg: 10px; /* surfaces: overlays, glass */ + + /* transitions */ + --dur-fast: 0.12s; + --dur: 0.15s; + + /* spacing scale */ + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; +} + +html, body { + height: 100%; + margin: 0; +} + +body { + font-family: var(--font-ui); +} + +/* Frosted-glass surface for floating overlays (repo chip, legend, zoom). Opt in + by adding the `glass` class alongside the element's own positioning class. */ +.glass { + background: color-mix(in oklab, var(--panel) 78%, transparent); + -webkit-backdrop-filter: blur(8px); + backdrop-filter: blur(8px); + border: 1px solid var(--border-soft); + border-radius: var(--radius-lg); + box-shadow: var(--shadow); +} + +#root { + height: 100%; + margin: 0; + display: flex; + flex-flow: column; +} + +/* GitHub mark link — shared base so every instance matches the repo chip in ModelPanel. */ +.repo-link { + display: inline-flex; + align-items: center; + color: var(--muted); + transition: color var(--dur-fast); +} +.repo-link:hover { + color: var(--accent); +} \ No newline at end of file diff --git a/src/App/index.tsx b/src/App/index.tsx deleted file mode 100755 index 9dc2b06..0000000 --- a/src/App/index.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import { App } from "./App"; -export { App }; \ No newline at end of file diff --git a/src/App/main.tsx b/src/App/main.tsx new file mode 100644 index 0000000..cdc3e66 --- /dev/null +++ b/src/App/main.tsx @@ -0,0 +1,42 @@ +import React from "react"; +import { createRoot } from "react-dom/client"; +import { BrowserRouter, Routes, Route } from "react-router-dom"; +import { HomeView } from "./HomeView"; +import { CssBaseline, ThemeProvider, StyledEngineProvider } from "@mui/material"; +import { theme } from "./theme"; +import { applyTokens } from "./tokens"; +import "./index.css"; +import { Provider } from "jotai"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +// Inject the design tokens onto :root before first paint (see tokens.ts). +applyTokens(); + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + }, + }, +}); + +const root = createRoot(document.getElementById("root") as HTMLElement); +root.render( + + + + + + + + + } /> + } /> + + + + + + + +); diff --git a/src/App/oklch.ts b/src/App/oklch.ts new file mode 100644 index 0000000..676cb71 --- /dev/null +++ b/src/App/oklch.ts @@ -0,0 +1,35 @@ +// oklch() → "#rrggbb". The design tokens are authored in oklch for tonal +// consistency, but MUI v4's color utilities and Monaco's theme can't parse +// oklch(), so those consumers read hex derived from this at module load. +// Math: Björn Ottosson's oklab/oklch → linear sRGB → gamma-encoded sRGB. + +const cube = (x: number) => x * x * x; + +const gamma = (c: number) => + c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055; + +const toByte = (c: number) => + Math.round(Math.min(1, Math.max(0, c)) * 255) + .toString(16) + .padStart(2, "0"); + +/** Convert "oklch(L C H)" — L in [0,1], C chroma, H in degrees — to a hex string. */ +export function oklchToHex(input: string): string { + const m = input.match(/oklch\(\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)\s*\)/i); + if (!m) throw new Error(`Unparseable oklch color: ${input}`); + const L = parseFloat(m[1]); + const C = parseFloat(m[2]); + const h = (parseFloat(m[3]) * Math.PI) / 180; + const a = C * Math.cos(h); + const b = C * Math.sin(h); + + const l_ = cube(L + 0.3963377774 * a + 0.2158037573 * b); + const m_ = cube(L - 0.1055613458 * a - 0.0638541728 * b); + const s_ = cube(L - 0.0894841775 * a - 1.291485548 * b); + + const r = gamma(4.0767416621 * l_ - 3.3077115913 * m_ + 0.2309699292 * s_); + const g = gamma(-1.2684380046 * l_ + 2.6097574011 * m_ - 0.3413193965 * s_); + const bl = gamma(-0.0041960863 * l_ - 0.7034186147 * m_ + 1.707614701 * s_); + + return `#${toByte(r)}${toByte(g)}${toByte(bl)}`; +} diff --git a/src/App/theme.ts b/src/App/theme.ts new file mode 100644 index 0000000..586743a --- /dev/null +++ b/src/App/theme.ts @@ -0,0 +1,26 @@ +import { createTheme } from "@mui/material"; +import { tokens, FONT_UI } from "./tokens"; + +export const theme = createTheme({ + palette: { + mode: "dark", + background: { + default: tokens.bg, + paper: tokens.panel, + }, + primary: { + main: tokens.accent, + }, + error: { + main: tokens.danger, + }, + text: { + primary: tokens.text, + secondary: tokens.muted, + }, + divider: tokens.borderSoft, + }, + typography: { + fontFamily: FONT_UI, + }, +}); diff --git a/src/App/tokens.ts b/src/App/tokens.ts new file mode 100644 index 0000000..0771cb3 --- /dev/null +++ b/src/App/tokens.ts @@ -0,0 +1,69 @@ +// Single source of truth for the design tokens. +// +// The color scale is authored ONCE here, in oklch. Two things derive from it so +// nothing is hand-synced: +// - applyTokens() injects the scale (+ --accent) onto :root at startup, so CSS +// uses `var(--panel)` etc. (see index.css, which no longer declares them). +// - `tokens` mirrors the scale to hex for the consumers that can't read CSS +// vars / parse oklch(): MUI v4's createTheme (theme.ts) and Monaco +// (customLang.ts). +import { oklchToHex } from "./oklch"; + +// Accent is a plain hex (not oklch): it's overridden at runtime via +// document.documentElement.style.setProperty("--accent", …) and feeds CSS +// color-mix(). This is its ONE definition — the MUI theme, Monaco, and the +// depth-color ramp (ModelPanel/depthColor.ts) all derive from it. +export const ACCENT = "#8b78f0"; + +// Font stacks. Defined here (and injected as --font-ui / --font-mono) so CSS uses +// var(--font-mono) while the JS-only consumers that need a literal string — the +// MUI theme (theme.ts) and Monaco's editor options (CodePanel) — import these. +export const FONT_UI = '"IBM Plex Sans", system-ui, -apple-system, sans-serif'; +export const FONT_MONO = '"IBM Plex Mono", ui-monospace, monospace'; + +// Keys are the CSS custom-property names without the leading "--". +const scale = { + bg: "oklch(0.158 0.008 274)", + canvas: "oklch(0.142 0.008 274)", + panel: "oklch(0.188 0.009 274)", + "panel-2": "oklch(0.222 0.010 274)", + "panel-3": "oklch(0.262 0.011 274)", + border: "oklch(0.300 0.012 274)", + "border-soft": "oklch(0.252 0.010 274)", + text: "oklch(0.945 0.004 274)", + muted: "oklch(0.660 0.012 274)", + faint: "oklch(0.480 0.012 274)", + "line-num": "oklch(0.420 0.012 274)", + folder: "oklch(0.760 0.105 248)", + file: "oklch(0.640 0.010 274)", + danger: "oklch(0.637 0.205 25)", +} as const; + +/** Inject --accent, the fonts, and the oklch scale onto :root. Call once before first render. */ +export function applyTokens(root: HTMLElement = document.documentElement): void { + root.style.setProperty("--accent", ACCENT); + root.style.setProperty("--font-ui", FONT_UI); + root.style.setProperty("--font-mono", FONT_MONO); + for (const [name, value] of Object.entries(scale)) { + root.style.setProperty(`--${name}`, value); + } +} + +// Hex mirror for JS consumers (MUI theme + Monaco). Derived, never hand-edited. +export const tokens = { + accent: ACCENT, + bg: oklchToHex(scale.bg), + canvas: oklchToHex(scale.canvas), + panel: oklchToHex(scale.panel), + panel2: oklchToHex(scale["panel-2"]), + panel3: oklchToHex(scale["panel-3"]), + border: oklchToHex(scale.border), + borderSoft: oklchToHex(scale["border-soft"]), + text: oklchToHex(scale.text), + muted: oklchToHex(scale.muted), + faint: oklchToHex(scale.faint), + lineNum: oklchToHex(scale["line-num"]), + folder: oklchToHex(scale.folder), + file: oklchToHex(scale.file), + danger: oklchToHex(scale.danger), +} as const; diff --git a/src/HomeView/CodePanel/customLang.ts b/src/HomeView/CodePanel/customLang.ts deleted file mode 100644 index 5472f96..0000000 --- a/src/HomeView/CodePanel/customLang.ts +++ /dev/null @@ -1,40 +0,0 @@ -import * as monaco from "monaco-editor/esm/vs/editor/editor.api"; - -export const themeDef: monaco.editor.IStandaloneThemeData = { - base: "vs-dark", // can also be vs-dark or hc-black - inherit: true, // can also be false to completely replace the builtin rules - rules: [ - { token: "folder", foreground: "#4EBFFC" }, - { token: "file", foreground: "#ffffff" }, - { token: "tree", foreground: "#C1C1C1" }, // will inherit fontStyle from `comment` above - ], - colors: {}, -}; - -// This config defines how the language is displayed in the editor. -// Test configs live here: https://microsoft.github.io/monaco-editor/monarch.html -export const languageDef: monaco.languages.IMonarchLanguage = { - ignoreCase: true, - defaultToken: "", - tokenizer: { - root: [ - { include: "@tree" }, - { include: "@tags" }, - ], - tags: [ - [/.*/, "folder"], - ], - tree: [ - [/^(\t+)?(│|├──|└──|\t)+/, "tree"], - ], - }, -}; - -// This config defines the editor"s behavior. -export const configuration = { - comments: { - lineComment: "//", - }, - brackets: [], - foldingStrategy: "indentation", -}; diff --git a/src/HomeView/CodePanel/foldProvider.ts b/src/HomeView/CodePanel/foldProvider.ts deleted file mode 100644 index 8624079..0000000 --- a/src/HomeView/CodePanel/foldProvider.ts +++ /dev/null @@ -1,10 +0,0 @@ -import * as monaco from "monaco-editor/esm/vs/editor/editor.api"; -type FoldingRangeProvider = monaco.languages.FoldingRangeProvider; - -export const customTreeFolding = (model: any): FoldingRangeProvider => ({ - provideFoldingRanges: function (model, context, token) { - const ranges: any = []; - - return ranges; - } -}); diff --git a/src/HomeView/CodePanel/index.tsx b/src/HomeView/CodePanel/index.tsx deleted file mode 100755 index 963760b..0000000 --- a/src/HomeView/CodePanel/index.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import { CodePanel } from "./CodePanel"; -export { CodePanel }; \ No newline at end of file diff --git a/src/HomeView/CodePanel/style.ts b/src/HomeView/CodePanel/style.ts deleted file mode 100755 index aded475..0000000 --- a/src/HomeView/CodePanel/style.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { makeStyles } from "@material-ui/core/styles"; - -export const useStyles = makeStyles(theme => ({ - codeContainer: { - width: "100%", - backgroundColor: "#212121", - }, -})) \ No newline at end of file diff --git a/src/HomeView/Dropdown/Dropdown.tsx b/src/HomeView/Dropdown/Dropdown.tsx deleted file mode 100755 index 65bad42..0000000 --- a/src/HomeView/Dropdown/Dropdown.tsx +++ /dev/null @@ -1,155 +0,0 @@ -import { useEffect, useState } from "react"; -import { useStyles } from "./style"; -import { Button, TextField, Typography } from "@material-ui/core"; -import Autocomplete from "@material-ui/lab/Autocomplete"; -import { useSetRecoilState } from "recoil"; -import { treeAtom, baseTreeAtom } from "../../store"; -import { treeStringToJson } from "@structure-codes/utils"; -import { useQuery } from "react-query"; -import { useNavigate, useSearchParams, useParams } from "react-router-dom"; -import clsx from "clsx"; -import { theme } from "../../theme"; - -export const Dropdown = ({ ref, wrap }: { ref: any; wrap: boolean }) => { - const classes = useStyles(); - const navigate = useNavigate(); - const params = useParams(); - const [searchParams]: any = useSearchParams(); - const [url, setUrl] = useState(""); - const [template, setTemplate] = useState(null); - const setTreeState = useSetRecoilState(treeAtom); - const setBaseTree = useSetRecoilState(baseTreeAtom); - - // On initial load get details from URL if present - useEffect(() => { - const { template: searchTemplate } = params; - if (!searchTemplate) return; - if (searchTemplate === "github") { - const owner = searchParams.get("owner"); - const repo = searchParams.get("repo"); - const branch = searchParams.get("branch"); - const branchString = branch ? `/tree/${branch}` : ""; - const searchUrl = `https://github.com/${owner}/${repo}${branchString}`; - setUrl(searchUrl); - handleGo(null, searchUrl); - } else { - handleTemplateChange(null, searchTemplate); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // Fetch the tree string data for the given template or repository - useQuery( - ["selectedTemplate", template], - async () => { - const data = await fetch(`/api/template/${template}`).then(res => res.text()); - const parsedData = data - .split("\n") - .filter(line => !line.startsWith("//")) - .join("\n"); - setTreeState(treeStringToJson(parsedData)); - setBaseTree(template || ""); - return parsedData; - }, - { - enabled: !!template, - } - ); - - // Fetch list of all available templates - const { data: templates } = useQuery("templatesData", () => - fetch("/api/templates").then(res => res.json()) - ); - - // Immediately pulls in new template data - const handleTemplateChange = (e: any, template: any) => { - if (template) { - navigate(`/template/${template}`); - } else { - navigate("/"); - } - // setTemplate tells us which template is selected and to load data for it from backend - setTemplate(template); - // setBaseTree tells us that the selected source of tree data has changed - setBaseTree(template); - }; - - // Handles changes to input value - const handleUrlChange = (e: React.ChangeEvent) => { - setUrl(e.target.value); - }; - - // Pulls from github api on click of button - const handleGo = (e: any, urlFromParams: string | null = null) => { - // Either get URL passed in OR use the current state value - const searchUrl = urlFromParams || url; - const stringRe = "[A-Za-z0-9-_.]+"; - const re = new RegExp( - `https://github.com/(?${stringRe})/(?${stringRe})((/tree)?/(?${stringRe}))?` - ); - const groups = searchUrl.match(re)?.groups; - if (!groups) { - console.error(`Could not parse URL: ${searchUrl} with regex: ${re.toString()}`); - return; - } - const { owner, repo, branch }: Record = groups; - fetch("/api/github", { - method: "POST", - body: JSON.stringify({ - owner, - repo, - branch, - }), - headers: { - "Content-Type": "application/json", - }, - }) - .then(res => res.json()) - .then(res => { - setTreeState(res); - setBaseTree(searchUrl); - navigate( - `/template/github?owner=${owner}&repo=${repo}${branch ? `&branch=${branch}` : ""}` - ); - }); - }; - - return ( -
- ( - - )} - /> - {!wrap && or} -
- -
- -
-
-
- ); -}; diff --git a/src/HomeView/Dropdown/__tests__/sampleTemplates.json b/src/HomeView/Dropdown/__tests__/sampleTemplates.json deleted file mode 100644 index 7c68fce..0000000 --- a/src/HomeView/Dropdown/__tests__/sampleTemplates.json +++ /dev/null @@ -1,9 +0,0 @@ -[ - "AmruthPillai-Reactive-Resume", - "alex-oser-portfolio", - "chrome-ext-js", - "cra-redux", - "react-boilerplate", - "structure-codes-structure", - "structure-codes-vscode-tree-language" -] \ No newline at end of file diff --git a/src/HomeView/Dropdown/index.tsx b/src/HomeView/Dropdown/index.tsx deleted file mode 100755 index 14b3452..0000000 --- a/src/HomeView/Dropdown/index.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import { Dropdown } from "./Dropdown"; -export { Dropdown }; \ No newline at end of file diff --git a/src/HomeView/Dropdown/style.ts b/src/HomeView/Dropdown/style.ts deleted file mode 100755 index bd4a833..0000000 --- a/src/HomeView/Dropdown/style.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { makeStyles } from "@material-ui/core/styles"; - -export const useStyles = makeStyles(theme => ({ - dropdownContainer: { - margin: theme.spacing(1), - display: "flex", - flexDirection: "column", - [theme.breakpoints.up("sm")]: { - flexDirection: "row", - }, - alignItems: "left", - }, - githubContainer: { - display: "flex", - }, - input: { - width: "100%", - maxWidth: 300, - }, - go: { - marginLeft: theme.spacing(1), - height: theme.spacing(5), - }, - or: { - padding: "0 10px 0 10px", - alignSelf: "center", - }, - icon: { - marginLeft: "auto", - }, -})); diff --git a/src/HomeView/ModelPanel/ModelPanel.tsx b/src/HomeView/ModelPanel/ModelPanel.tsx deleted file mode 100755 index aa34e23..0000000 --- a/src/HomeView/ModelPanel/ModelPanel.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import React, { useCallback, useEffect, useState } from "react"; -import { useRecoilValue } from "recoil"; -import { treeAtom, baseTreeAtom } from "../../store"; -import Tree from "react-d3-tree"; -import "./tree.css"; - -// Here we're using `renderCustomNodeElement` render a component that uses -// both SVG and HTML tags side-by-side. -// This is made possible by `foreignObject`, which wraps the HTML tags to -// allow for them to be injected into the SVG namespace. -const renderForeignObjectNode = ({ nodeDatum, toggleNode }: any) => { - const color = - nodeDatum.name === "root" ? "#efe2c3" : nodeDatum.children.length > 0 ? "#9e7f4f" : "#748e40"; - const numChildren = nodeDatum.children.length; - const isCollapsed = nodeDatum.__rd3t.collapsed; - return ( - - - {/* `foreignObject` requires width & height to be explicitly set. */} - - {nodeDatum.name} {numChildren > 0 && isCollapsed && `(+${numChildren})`} - - - ); -}; - -const defaultTranslate = { x: 0, y: 0 } -export const ModelPanel = React.memo(() => { - const treeState = useRecoilValue(treeAtom); - const baseTree = useRecoilValue(baseTreeAtom); - const nodes = { name: "root", children: treeState }; - - const [translate, setTranslate] = useState(defaultTranslate); - const [zoom, setZoom] = useState(1); - const [dimensions, setDimensions] = useState({ width: 0, height: 0 }); - const [origin, setOrigin] = useState(defaultTranslate); - const containerRef = useCallback((containerElem) => { - if (containerElem !== null) { - const { width, height } = containerElem.getBoundingClientRect(); - setOrigin({ x: width / 2, y: height / 2 }); - setTranslate({ x: width / 2, y: height / 2 }); - setDimensions({ width, height }); - } - }, []); - - useEffect(() => { - setTranslate(origin); - }, [baseTree, setTranslate, origin]); - - const onUpdate = ({translate: nodeTranslate, zoom}: any) => { - setTranslate(nodeTranslate); - setZoom(zoom); - } - - - return ( -
- "custom-link"} - rootNodeClassName="node__root" - branchNodeClassName="node__branch" - leafNodeClassName="node__leaf" - renderCustomNodeElement={({ nodeDatum, toggleNode }) => - renderForeignObjectNode({ nodeDatum, toggleNode }) - } - /> -
- ); -}); diff --git a/src/HomeView/ModelPanel/index.tsx b/src/HomeView/ModelPanel/index.tsx deleted file mode 100755 index 4c55a91..0000000 --- a/src/HomeView/ModelPanel/index.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import { ModelPanel } from "./ModelPanel"; -export { ModelPanel }; \ No newline at end of file diff --git a/src/HomeView/ModelPanel/style.ts b/src/HomeView/ModelPanel/style.ts deleted file mode 100755 index 512acdd..0000000 --- a/src/HomeView/ModelPanel/style.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { makeStyles } from "@material-ui/core/styles"; - -// TODO: use theme -export const useStyles = makeStyles({ - modelContainer: { - width: "100%", - display: "flex", - flexDirection: "column", - }, - - "@global .react-flow__node-input": { - background: "#1c1c1c", - borderColor: "#000", - color: "#fff", - fontSize: 20 - }, - "@global .react-flow__node": { - background: "#1c1c1c", - borderColor: "#000", - color: "#fff", - fontSize: 20, - boxShadow: "none", - }, - "@global .react-flow__node.selected": { - borderColor: "#fff", - }, - root: { - border: "1px solid #000", - padding: "6px 12px 6px 12px", - }, - isSelected: { - borderColor: "#fff", - } -}) \ No newline at end of file diff --git a/src/HomeView/ModelPanel/tree.css b/src/HomeView/ModelPanel/tree.css deleted file mode 100644 index b5d7b49..0000000 --- a/src/HomeView/ModelPanel/tree.css +++ /dev/null @@ -1,9 +0,0 @@ -/* custom-tree.css */ - -.custom-link { - stroke: #454545 !important; -} - -.custom-node { - stroke: #454545 !important; -} \ No newline at end of file diff --git a/src/HomeView/SettingsPanel/SettingsPanel.tsx b/src/HomeView/SettingsPanel/SettingsPanel.tsx deleted file mode 100755 index e7dd64f..0000000 --- a/src/HomeView/SettingsPanel/SettingsPanel.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import React, { useEffect, useState } from "react"; -import { useStyles } from "./style"; -import { - Button, - Checkbox, - FormGroup, - FormControlLabel, - Slider, - Typography, -} from "@material-ui/core"; -import { settingsAtom, treeAtom } from "../../store"; -import { useRecoilState, useRecoilValue } from "recoil"; -import { CopyToClipboard } from "react-copy-to-clipboard"; -import { TreeType, treeJsonToString } from "@structure-codes/utils"; -import { saveAs } from "file-saver"; - -const getMaxDepth = (tree: TreeType[]): number => { - let maxDepth = 0; - const descendTree = (tree: TreeType[], depth: number) => { - tree.forEach(branch => { - return branch.children ? descendTree(branch.children, depth + 1) : null; - }); - maxDepth = Math.max(depth, maxDepth); - }; - descendTree(tree, maxDepth); - return maxDepth; -}; - -export const SettingsPanel = React.memo(() => { - const classes = useStyles(); - const [settings, setSettings] = useRecoilState(settingsAtom); - const treeState = useRecoilValue(treeAtom); - const [maxDepth, setMaxDepth] = useState(0); - - const handleDepthChange = (event: React.ChangeEvent<{}> | null, value: any) => { - setSettings({ - ...settings, - depth: value, - }); - }; - - const handleCheckboxChange = (event: React.ChangeEvent) => { - setSettings({ ...settings, [event.target.name]: event.target.checked }); - }; - - useEffect(() => { - const newDepth = getMaxDepth(treeState); - setMaxDepth(newDepth); - if (settings.depth < 0) handleDepthChange(null, newDepth); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [treeState]); - - const handleClick = () => { - const treeString = treeJsonToString({ tree: treeState, tabChar: "\t", options: settings }); - const blob = new Blob([treeString], { type: "text/plain;charset=utf-8" }); - saveAs(blob, "structure.tree"); - }; - - return ( -
-
- - Tree depth: ({settings.depth}) - - -
- - - } - label="Hide files" - /> - - } - label="Hide dot dirs and files" - /> - -
- - - - -
-
- ); -}); diff --git a/src/HomeView/SettingsPanel/index.tsx b/src/HomeView/SettingsPanel/index.tsx deleted file mode 100755 index d076a41..0000000 --- a/src/HomeView/SettingsPanel/index.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import { SettingsPanel } from "./SettingsPanel"; -export { SettingsPanel }; \ No newline at end of file diff --git a/src/HomeView/SettingsPanel/style.ts b/src/HomeView/SettingsPanel/style.ts deleted file mode 100755 index 301b86c..0000000 --- a/src/HomeView/SettingsPanel/style.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { makeStyles } from "@material-ui/core/styles"; - -export const useStyles = makeStyles(theme => ({ - settingsContainer: { - width: "100%", - flex: "1 1 0px", - backgroundColor: "#212121", - padding: 12, - }, - buttons: { - "& button": { - margin: `0 ${theme.spacing(1)}px ${theme.spacing(1)}px 0` - } - }, - button: { - width: 200, - }, - slider: { - maxWidth: 300, - }, - sliderContainer: { - marginRight: theme.spacing(1), - } -})) \ No newline at end of file diff --git a/src/HomeView/index.tsx b/src/HomeView/index.tsx deleted file mode 100755 index 27e0f40..0000000 --- a/src/HomeView/index.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import { HomeView } from "./HomeView"; -export { HomeView }; \ No newline at end of file diff --git a/src/HomeView/style.ts b/src/HomeView/style.ts deleted file mode 100755 index dad45e1..0000000 --- a/src/HomeView/style.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { makeStyles } from "@material-ui/core/styles"; - -export const useStyles = makeStyles(theme => ({ - dropdownContainer: { - justifyContent: "center", - display: "flex", - }, - dropdown: { - display: "inline-block", - }, - leftPanel: { - display: "flex", - flexDirection: "column", - height: "100%", - minWidth: 220, - }, - rightPanel: { - display: "flex", - flexDirection: "column", - flexGrow: 1, - }, - panelContainer: { - display: "flex", - height: "100%", - width: "100%", - }, -})); \ No newline at end of file diff --git a/src/WelcomeModal/index.tsx b/src/WelcomeModal/index.tsx deleted file mode 100755 index 693da49..0000000 --- a/src/WelcomeModal/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export {} \ No newline at end of file diff --git a/src/WelcomeModal/style.ts b/src/WelcomeModal/style.ts deleted file mode 100755 index e8858c3..0000000 --- a/src/WelcomeModal/style.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { makeStyles } from "@material-ui/core/styles"; - -export const useStyles = makeStyles({ - -}) \ No newline at end of file diff --git a/src/components/GitHubMark/index.tsx b/src/components/GitHubMark/index.tsx new file mode 100644 index 0000000..712af05 --- /dev/null +++ b/src/components/GitHubMark/index.tsx @@ -0,0 +1,34 @@ +export const GitHubMark = ({ + size = 16, + className, + url, +}: { + size?: number; + className?: string; + url?: string; +}) => { + const Mark = ( + + ); + + return url ? ( + e.stopPropagation()} + > + {Mark} + + ) : ( + Mark + ); +}; diff --git a/src/HomeView/hooks.tsx b/src/hooks.ts similarity index 90% rename from src/HomeView/hooks.tsx rename to src/hooks.ts index 14be8f5..4adf091 100644 --- a/src/HomeView/hooks.tsx +++ b/src/hooks.ts @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect } from "react"; export const useMousePosition = (isDragging: boolean) => { const [mousePosition, setMousePosition] = useState<{ x: number | null; y: number | null }>({ @@ -17,4 +17,4 @@ export const useMousePosition = (isDragging: boolean) => { }, [isDragging]); return mousePosition; -}; \ No newline at end of file +}; diff --git a/src/index.css b/src/index.css deleted file mode 100644 index b6b5c27..0000000 --- a/src/index.css +++ /dev/null @@ -1,11 +0,0 @@ -html, body { - height: 100%; - margin: 0; -} - -#root { - height: 100%; - margin: 0; - display: flex; - flex-flow: column; -} \ No newline at end of file diff --git a/src/main.tsx b/src/main.tsx deleted file mode 100644 index b650759..0000000 --- a/src/main.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import React from "react"; -import { render } from "react-dom"; -import { - BrowserRouter, - Routes, - Route, -} from "react-router-dom"; -import { App } from "./App"; -import { CssBaseline, ThemeProvider } from "@material-ui/core"; -import { theme } from "./theme"; -import "./index.css"; -import { RecoilRoot } from "recoil"; -import { QueryClient, QueryClientProvider } from "react-query"; - -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - refetchOnWindowFocus: false, - } - } -}); - -render( - - - - - - - - }/> - }/> - - - - - - , - document.getElementById("root") -); diff --git a/src/store.ts b/src/store.ts index 6ee630a..703c78c 100644 --- a/src/store.ts +++ b/src/store.ts @@ -1,4 +1,4 @@ -import { atom } from "recoil"; +import { atom } from "jotai"; import { treeStringToJson } from "@structure-codes/utils"; const defaultTree: string = `├── api @@ -29,17 +29,15 @@ const defaultSettings: ISettings = { const defaultBaseTree: string = ""; -export const treeAtom = atom({ - key: "tree", - default: treeStringToJson(defaultTree), -}); +export const treeAtom = atom(treeStringToJson(defaultTree)); -export const settingsAtom = atom({ - key: "settings", - default: defaultSettings, -}); +export const settingsAtom = atom(defaultSettings); -export const baseTreeAtom = atom({ - key: "baseTree", - default: defaultBaseTree, -}); \ No newline at end of file +export const baseTreeAtom = atom(defaultBaseTree); + +// Transient cross-pane link state: a node's stable path id (see layout.ts). +// Shared by ModelPanel (graph) and CodePanel (Monaco) so hovering/selecting in +// one pane highlights the matching entry in the other. Not persisted. +export const hoveredNodeAtom = atom(null); + +export const selectedNodeAtom = atom(null); \ No newline at end of file diff --git a/src/theme.ts b/src/theme.ts deleted file mode 100644 index 92f35fa..0000000 --- a/src/theme.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { createTheme } from "@material-ui/core"; - -export const theme = createTheme({ - palette: { - type: "dark", - background: { - default: "#212121" - }, - primary: { - main: "#776089", - } - }, -}); \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 2544c81..3953684 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,7 +9,6 @@ ], "allowJs": false, "skipLibCheck": true, - "esModuleInterop": false, "allowSyntheticDefaultImports": true, "strict": true, "forceConsistentCasingInFileNames": true, diff --git a/vite.config.mts b/vite.config.mts index e779c8b..a5fe6c0 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -19,5 +19,13 @@ export default defineConfig({ environmentOptions: { jsdom: { url: "http://localhost:3000/" } }, environmentMatchGlobs: [["netlify/**", "node"]], setupFiles: ["./src/setupTests.ts"], + // MUI v5+ ships native ESM that bare-imports `react/jsx-runtime`, which + // React 17 doesn't expose via an exports map. Inline @mui/@emotion so Vite's + // resolver transforms them (and resolves the extensionless import). + server: { + deps: { + inline: [/@mui\//, /@emotion\//], + }, + }, }, });