From bd6cfff14e0e794feecd9e9b870e91643a45f98f Mon Sep 17 00:00:00 2001 From: Fy <57202839+JSerFeng@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:33:48 +0800 Subject: [PATCH 1/2] feat: refine playground workspace UI --- src/App.tsx | 6 +- .../Dependencies/ChunkGraphCanvas.tsx | 250 +++++++++--------- src/components/Dependencies/GraphControls.tsx | 74 ++++++ .../Dependencies/ModuleGraphCanvas.tsx | 219 +++++++-------- .../Dependencies/graphCanvasUtils.ts | 23 +- src/components/Dependencies/index.tsx | 138 +++++----- src/components/Editor/CodeEditor.tsx | 73 ++++- src/components/Editor/FileTabs.tsx | 55 +++- src/components/Editor/index.tsx | 87 +++--- src/components/Header.tsx | 30 ++- src/components/ModeToggle.tsx | 2 +- src/components/Preview/index.tsx | 104 +++++--- src/index.css | 225 +++++++++++----- 13 files changed, 831 insertions(+), 455 deletions(-) create mode 100644 src/components/Dependencies/GraphControls.tsx diff --git a/src/App.tsx b/src/App.tsx index a105dd5..ca22f55 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -19,10 +19,10 @@ const App = () => { ); return ( - -
+ +
-
+
diff --git a/src/components/Dependencies/ChunkGraphCanvas.tsx b/src/components/Dependencies/ChunkGraphCanvas.tsx index 0de149e..46ae110 100644 --- a/src/components/Dependencies/ChunkGraphCanvas.tsx +++ b/src/components/Dependencies/ChunkGraphCanvas.tsx @@ -2,9 +2,11 @@ import type { PointerEvent as ReactPointerEvent } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { RspackChunkGroupInfo, RspackChunkInfo } from "@/lib/bundle/dependency"; import { cn } from "@/lib/utils"; +import GraphControls, { useGraphRenderScale } from "./GraphControls"; import { buildCurvedPath, clampScale, + getGraphFrame, getSvgPoint, labelFromGraphText, projectPointToRect, @@ -479,6 +481,22 @@ export default function ChunkGraphCanvas({ }; }, []); + const frame = useMemo( + () => + getGraphFrame( + graph.nodes.map((node) => ({ + position: layout.positions[node.id] ?? { x: 0, y: 0 }, + width: node.width, + height: node.height, + })), + ), + [graph.nodes, layout.positions], + ); + + const renderScale = useGraphRenderScale(svgRef, frame, view.scale); + const compact = renderScale < 0.7; + const labelSize = Math.min(24, 12 / renderScale); + const positionedNodes = useMemo(() => { return graph.nodes.map((node) => ({ ...node, @@ -591,11 +609,19 @@ export default function ChunkGraphCanvas({ }; const updateScale = (factor: number) => { - setView((current) => ({ - scale: clampScale(current.scale * factor), - panX: current.panX, - panY: current.panY, - })); + const svg = svgRef.current; + if (!svg) return; + const rect = svg.getBoundingClientRect(); + const center = getSvgPoint(svg, rect.x + rect.width / 2, rect.y + rect.height / 2); + if (!center) return; + setView((current) => { + const scale = clampScale(current.scale * factor); + return { + scale, + panX: center.x - ((center.x - current.panX) * scale) / current.scale, + panY: center.y - ((center.y - current.panY) * scale) / current.scale, + }; + }); }; if (graph.nodes.length === 0) { @@ -612,54 +638,28 @@ export default function ChunkGraphCanvas({ } return ( -
-
-
Chunk Graph
-
- - - -
+
+
+

Chunk groups

+ { + setView({ panX: 0, panY: 0, scale: 1 }); + setNodeOverrides({}); + }} + />
-
+
- - - - + @@ -694,14 +693,14 @@ export default function ChunkGraphCanvas({ target.position, source.width, source.height, - 6, + -2, ); const end = projectPointToRect( target.position, source.position, target.width, target.height, - 12, + -8, ); const isCurrent = currentGroupIdSet.has(edge.sourceId) || currentGroupIdSet.has(edge.targetId); @@ -711,9 +710,9 @@ export default function ChunkGraphCanvas({ key={edge.id} d={buildCurvedPath(start, end)} fill="none" - stroke="rgba(59, 130, 246, 0.45)" + stroke="var(--graph-edge)" strokeWidth={isCurrent ? 2.6 : 1.6} - strokeOpacity={isCurrent ? 0.95 : 0.45} + strokeOpacity={isCurrent ? 1 : 0.8} markerEnd="url(#chunk-graph-arrow)" strokeLinecap="round" strokeLinejoin="round" @@ -729,22 +728,19 @@ export default function ChunkGraphCanvas({ key={node.id} transform={`translate(${node.position.x} ${node.position.y})`} onPointerDown={(event) => handleGroupPointerDown(event, node.id)} - className="cursor-grab active:cursor-grabbing" + className="graph-node cursor-grab active:cursor-grabbing" > + {node.name} - {trimGraphLabel(node.name, 34)} + {trimGraphLabel(node.name, compact ? 14 : 23)} - {`${node.chunks.length} chunks · ${node.parents.length} in · ${node.children.length} out`} - - - - {node.initial ? "initial" : "async"} + {compact + ? `${node.initial ? "Initial" : "Async"} · ${node.chunks.length} ${node.chunks.length === 1 ? "chunk" : "chunks"}` + : `${node.chunks.length} chunks · ${node.parents.length} in · ${node.children.length} out`} + {!compact && ( + <> + + + {node.initial ? "initial" : "async"} + + + )} {node.chunks.length > 0 ? ( node.chunks.map((chunk, index) => { @@ -809,22 +809,27 @@ export default function ChunkGraphCanvas({ key={`${node.id}-${chunk.id}`} transform={`translate(${-node.width / 2 + GROUP_PADDING} ${rowY})`} > + {[chunk.name, ...chunk.files].join("\n")} { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onSelectChunk?.(chunk.id); + } + }} onPointerDown={(event) => { event.stopPropagation(); }} @@ -835,29 +840,32 @@ export default function ChunkGraphCanvas({ /> - {trimGraphLabel(labelFromGraphText(chunk.name), 28)} - - - {`${chunk.modules.length} modules${chunk.files.length > 0 ? ` · ${chunk.files[0]}` : ""}`} + {trimGraphLabel(labelFromGraphText(chunk.name), compact ? 18 : 28)} - {chunk.entry ? ( + {!compact && ( + + {`${chunk.modules.length} modules${chunk.files.length > 0 ? ` · ${chunk.files[0]}` : ""}`} + + )} + {chunk.entry && !compact ? ( <>
-
+
{selectedChunk ? ( Selected chunk:{" "} diff --git a/src/components/Dependencies/GraphControls.tsx b/src/components/Dependencies/GraphControls.tsx new file mode 100644 index 0000000..f5ff9e9 --- /dev/null +++ b/src/components/Dependencies/GraphControls.tsx @@ -0,0 +1,74 @@ +import { Minus, Plus, RotateCcw } from "lucide-react"; +import { useEffect, useState, type RefObject } from "react"; +import { Button } from "@/components/ui/button"; + +export function useGraphRenderScale( + svgRef: RefObject, + frame: { width: number; height: number }, + zoom: number, +) { + const [fitScale, setFitScale] = useState(1); + useEffect(() => { + const svg = svgRef.current; + if (!svg) return; + const observer = new ResizeObserver(([entry]) => { + const { width, height } = entry.contentRect; + if (width && height) setFitScale(Math.min(width / frame.width, height / frame.height)); + }); + observer.observe(svg); + return () => observer.disconnect(); + }, [svgRef, frame.width, frame.height]); + return fitScale * zoom; +} + +export default function GraphControls({ + scale, + onZoom, + onReset, +}: { + scale: number; + onZoom: (factor: number) => void; + onReset: () => void; +}) { + return ( +
+ + + {Math.round(scale * 100)}% + + + + +
+ ); +} diff --git a/src/components/Dependencies/ModuleGraphCanvas.tsx b/src/components/Dependencies/ModuleGraphCanvas.tsx index 106bdcb..d86aca7 100644 --- a/src/components/Dependencies/ModuleGraphCanvas.tsx +++ b/src/components/Dependencies/ModuleGraphCanvas.tsx @@ -6,13 +6,13 @@ import type { RspackModuleDeps, } from "@/lib/bundle/dependency"; import { cn } from "@/lib/utils"; +import GraphControls from "./GraphControls"; import { - buildCurvedPath, clampScale, + getGraphFrame, getSvgPoint, labelFromGraphText, normalizeGraphText, - projectPointToRect, trimGraphLabel, type Point, type ViewState, @@ -90,21 +90,6 @@ function isDynamicImportEdge(_dep: RspackDependency, kind: GraphEdge["kind"]) { return kind === "block"; } -function categoryClassName(category: RspackModuleCategory, isActive: boolean) { - if (isActive) { - return "fill-primary/26 stroke-primary"; - } - - switch (category) { - case "source": - return "fill-emerald-300/30 stroke-emerald-300"; - case "dependency": - return "fill-sky-300/30 stroke-sky-300"; - case "runtime": - return "fill-amber-300/30 stroke-amber-300"; - } -} - function buildGraph(modules: RspackModuleDeps[]): { nodes: GraphNode[]; edges: GraphEdge[] } { const nodes = new Map(); const aliases = new Map(); @@ -454,12 +439,12 @@ function buildLayout( }; } -const MODULE_EDGE_COLOR = "rgba(96, 165, 250, 0.9)"; -const MODULE_EDGE_ARROW_COLOR = "#60a5fa"; +const MODULE_EDGE_COLOR = "var(--graph-edge)"; +const MODULE_EDGE_ARROW_COLOR = "var(--graph-edge)"; const MODULE_EDGE_MARKER_ID = "module-graph-arrow"; -const MODULE_NODE_WIDTH = 156; -const MODULE_NODE_HEIGHT = 56; +const MODULE_NODE_WIDTH = 220; +const MODULE_NODE_HEIGHT = 76; export default function ModuleGraphCanvas({ modules, @@ -562,6 +547,18 @@ export default function ModuleGraphCanvas({ }; }, []); + const frame = useMemo( + () => + getGraphFrame( + graph.nodes.map((node) => ({ + position: layout.positions[node.id] ?? { x: 0, y: 0 }, + width: MODULE_NODE_WIDTH, + height: MODULE_NODE_HEIGHT, + })), + ), + [graph.nodes, layout.positions], + ); + const positionedNodes = useMemo(() => { return graph.nodes.map((node) => ({ ...node, @@ -678,11 +675,19 @@ export default function ModuleGraphCanvas({ }; const updateScale = (factor: number) => { - setView((current) => ({ - scale: clampScale(current.scale * factor), - panX: current.panX, - panY: current.panY, - })); + const svg = svgRef.current; + if (!svg) return; + const rect = svg.getBoundingClientRect(); + const center = getSvgPoint(svg, rect.x + rect.width / 2, rect.y + rect.height / 2); + if (!center) return; + setView((current) => { + const scale = clampScale(current.scale * factor); + return { + scale, + panX: center.x - ((center.x - current.panX) * scale) / current.scale, + panY: center.y - ((center.y - current.panY) * scale) / current.scale, + }; + }); }; if (graph.nodes.length === 0) { @@ -699,54 +704,28 @@ export default function ModuleGraphCanvas({ } return ( -
-
-
Module Graph
-
- - - -
+
+
+

Module graph

+ { + setView({ panX: 0, panY: 0, scale: 1 }); + setNodeOverrides({}); + }} + />
-
+
- - - @@ -776,27 +754,29 @@ export default function ModuleGraphCanvas({ const target = nodeById.get(edge.targetId); if (!source || !target) return null; - const start = projectPointToRect( - source.position, - target.position, - MODULE_NODE_WIDTH, - MODULE_NODE_HEIGHT, - 4, - ); - const end = projectPointToRect( - target.position, - source.position, - MODULE_NODE_WIDTH, - MODULE_NODE_HEIGHT, - 10, - ); + const deltaX = target.position.x - source.position.x; + const sameColumn = Math.abs(deltaX) < MODULE_NODE_WIDTH; + const direction = sameColumn || deltaX < 0 ? -1 : 1; + // Side ports keep long edges clear of other cards in the same column. + const start = { + x: source.position.x + direction * (MODULE_NODE_WIDTH / 2 + 2), + y: source.position.y, + }; + const end = { + x: target.position.x + (sameColumn ? -1 : -direction) * (MODULE_NODE_WIDTH / 2 + 8), + y: target.position.y, + }; + const laneX = Math.min(start.x, end.x) - 28; + const tension = Math.max(Math.abs(end.x - start.x) / 2, 12); + const control1X = sameColumn ? laneX : start.x + direction * tension; + const control2X = sameColumn ? laneX : end.x - direction * tension; return ( { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + setSelectedNodeId(node.id); + if (!node.isVirtual) onSelectModule?.(node.id); + } + }} > + {node.name} + - - {trimGraphLabel(labelFromGraphText(node.name))} + + {node.category === "dependency" + ? "Package" + : node.category === "runtime" + ? "Runtime" + : "Source"} + {node.isVirtual ? " · reference" : ""} + + + {trimGraphLabel(labelFromGraphText(node.name), 20)} + + + {trimGraphLabel(normalizeGraphText(node.name), 34)} ); @@ -852,7 +861,7 @@ export default function ModuleGraphCanvas({
-
+
{selectedNode ? ( Selected: {selectedNode.name} diff --git a/src/components/Dependencies/graphCanvasUtils.ts b/src/components/Dependencies/graphCanvasUtils.ts index 00e8588..4974a60 100644 --- a/src/components/Dependencies/graphCanvasUtils.ts +++ b/src/components/Dependencies/graphCanvasUtils.ts @@ -11,6 +11,23 @@ export type ViewState = { scale: number; }; +export function getGraphFrame(nodes: { position: Point; width: number; height: number }[]) { + if (!nodes.length) return { x: 0, y: 0, width: 360, height: 240 }; + let left = Infinity; + let top = Infinity; + let right = -Infinity; + let bottom = -Infinity; + for (const node of nodes) { + left = Math.min(left, node.position.x - node.width / 2); + right = Math.max(right, node.position.x + node.width / 2); + top = Math.min(top, node.position.y - node.height / 2); + bottom = Math.max(bottom, node.position.y + node.height / 2); + } + const width = Math.max(right - left + 80, 360); + const height = Math.max(bottom - top + 80, 240); + return { x: (left + right - width) / 2, y: (top + bottom - height) / 2, width, height }; +} + export const normalizeGraphText = normalizePath; export function labelFromGraphText(name: string) { @@ -77,7 +94,11 @@ export function buildCurvedPath(start: Point, end: Point) { const deltaX = end.x - start.x; const deltaY = end.y - start.y; const direction = deltaX >= 0 ? 1 : -1; - const horizontalTension = Math.max(Math.abs(deltaX) * 0.4, 52); + if (Math.abs(deltaY) > Math.abs(deltaX)) { + const verticalTension = deltaY * 0.45; + return `M ${start.x} ${start.y} C ${start.x} ${start.y + verticalTension}, ${end.x} ${end.y - verticalTension}, ${end.x} ${end.y}`; + } + const horizontalTension = Math.abs(deltaX) * 0.45; const verticalTension = deltaY * 0.16; const control1 = { x: start.x + direction * horizontalTension, diff --git a/src/components/Dependencies/index.tsx b/src/components/Dependencies/index.tsx index 7e90b3f..428b8f1 100644 --- a/src/components/Dependencies/index.tsx +++ b/src/components/Dependencies/index.tsx @@ -1,3 +1,4 @@ +import { GitBranch, Layers, Network } from "lucide-react"; import { useMonaco } from "@monaco-editor/react"; import type * as Monaco from "monaco-editor"; import type { ReactNode, RefObject } from "react"; @@ -66,11 +67,11 @@ function categoryLabel(category: RspackModuleCategory) { function categoryClassName(category: RspackModuleCategory) { switch (category) { case "source": - return "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"; + return "border-border bg-muted text-chart-2"; case "dependency": - return "border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-300"; + return "border-border bg-muted text-chart-1"; case "runtime": - return "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300"; + return "border-border bg-muted text-chart-3"; } } @@ -111,7 +112,7 @@ function Tag({ className, children }: { className?: string; children: ReactNode return ( @@ -130,10 +131,14 @@ function StatCard({ hint?: string; }) { return ( -
+
{label}
-
{value}
- {hint ?
{hint}
: null} +
{value}
+ {hint ? ( +
+ {hint} +
+ ) : null}
); } @@ -850,7 +855,7 @@ export default function DependencyPanel({ return (
-
+
-
+
{!currentModule ? ( -
- Current file is not part of the module graph. The graph above still shows the bundled - module relationships; click a graph node to inspect its chunk placement and - dependencies. +
+ This file is outside the module graph. Select a node to inspect its dependencies.
) : null} - {inspectedModule && !isInspectingActiveFile ? ( -
- You are inspecting a graph-selected module. Source-range highlighting and import hover - sync only work when that module's file is active in the editor. + {currentModule && inspectedModule && !isInspectingActiveFile ? ( +
+ Open this module in the editor to see source highlights and import links.
) : null} {inspectedModule && currentGroups.length > 0 ? ( -
+
Module chunk groups
{currentGroups.map((group) => @@ -958,7 +960,7 @@ export default function DependencyPanel({ return (
-
+
{selectedChunk ? ( -
-
+
+
{selectedChunk.name}
@@ -1022,17 +1024,13 @@ export default function DependencyPanel({ ) : null}
- ) : ( -
- Click a chunk in the graph above to inspect the modules bundled into it. -
- )} + ) : null}
{selectedChunk ? ( -
+
-
+
Chunk modules ({filteredSelectedChunkModules.length} {chunkSearchQuery ? ` / ${selectedChunk.modules.length} matched` : ""}) @@ -1042,7 +1040,7 @@ export default function DependencyPanel({ value={chunkSearch} onChange={(event) => setChunkSearch(event.target.value)} placeholder="Search modules in chunk" - className="h-8 rounded-md border bg-background px-3 text-xs outline-none transition-colors placeholder:text-muted-foreground focus:border-primary sm:w-56" + className="h-8 w-full min-w-0 rounded-md border bg-muted/30 px-2.5 text-xs outline-none transition-colors placeholder:text-muted-foreground focus:border-ring" />
@@ -1069,7 +1067,9 @@ export default function DependencyPanel({ ) : null}
-
{module.name}
+
+ {module.name} +
{module.path && module.path !== module.name ? (
{module.path} @@ -1096,49 +1096,53 @@ export default function DependencyPanel({ <>
-
-
-
-
Graph Explorer
-
- {modules.length} modules · {chunks.length} chunks · {chunkGroups.length} groups - {currentModule ? ` · active: ${currentModule.name}` : ""} -
-
- {viewMode === "module" && totalDeps > 0 && isInspectingActiveFile ? ( +
+
+ +

Dependencies

+ + {modules.length} + +
+ {viewMode === "module" && totalDeps > 0 && isInspectingActiveFile ? ( + + ) : null} +
+
+ {(["module", "chunk"] as GraphView[]).map((mode) => { + const Icon = mode === "module" ? GitBranch : Layers; + return ( - ) : null} -
-
-
- {(["module", "chunk"] as GraphView[]).map((mode) => ( - - ))} -
-
+ ); + })}
diff --git a/src/components/Editor/CodeEditor.tsx b/src/components/Editor/CodeEditor.tsx index c4880d2..b3f893d 100644 --- a/src/components/Editor/CodeEditor.tsx +++ b/src/components/Editor/CodeEditor.tsx @@ -1,6 +1,7 @@ import MonacoEditor from "@monaco-editor/react"; import type * as Monaco from "monaco-editor"; import type React from "react"; +import { FileCode2 } from "lucide-react"; import FileTabs from "@/components/Editor/FileTabs"; import { useTheme } from "@/components/ThemeProvider"; import { getSourceFileIdentity, type SourceFile } from "@/store/bundler"; @@ -82,7 +83,7 @@ export default function CodeEditor({ if (!currentFile) { return ( -
+
No file selected
); @@ -90,10 +91,10 @@ export default function CodeEditor({ // `path` identifies the Monaco model. Input files use an immutable identity so // renaming only changes the displayed filename and preserves editor history. - const modelPath = `rspack-playground://${readonly ? "output" : "input"}/${encodeURIComponent(getSourceFileIdentity(currentFile))}`; + const modelPath = `rspack-playground://${readonly ? "output" : "input"}/${encodeURIComponent(getSourceFileIdentity(currentFile).replace(/^\/+/, ""))}`; return ( -
+
{ + monaco.editor.defineTheme("workspace-dark", { + base: "vs-dark", + inherit: true, + rules: [ + { token: "comment", foreground: "7f848d" }, + { token: "keyword", foreground: "b6b5d7" }, + { token: "string", foreground: "b5c8b4" }, + { token: "number", foreground: "d4ba8c" }, + { token: "type.identifier", foreground: "c6cbd8" }, + ], + colors: { + "editor.background": "#0f1011", + "editor.foreground": "#d0d6e0", + "editorLineNumber.foreground": "#4b4f56", + "editorLineNumber.activeForeground": "#a2a6af", + "editor.lineHighlightBackground": "#141516", + "editor.selectionBackground": "#333544", + "editor.inactiveSelectionBackground": "#272930", + "editorCursor.foreground": "#d0d6e0", + "editorIndentGuide.background1": "#202226", + "editorWidget.background": "#191a1b", + "editorWidget.border": "#34343a", + "editorSuggestWidget.background": "#191a1b", + "editorSuggestWidget.border": "#34343a", + "editorHoverWidget.background": "#191a1b", + "editorHoverWidget.border": "#34343a", + }, + }); + monaco.editor.defineTheme("workspace-light", { + base: "vs", + inherit: true, + rules: [ + { token: "comment", foreground: "6f737c" }, + { token: "keyword", foreground: "6c619b" }, + { token: "string", foreground: "59775c" }, + { token: "number", foreground: "9b7a48" }, + ], + colors: { + "editor.background": "#ffffff", + "editor.foreground": "#393a40", + "editorLineNumber.foreground": "#babcc4", + "editorLineNumber.activeForeground": "#777981", + "editor.lineHighlightBackground": "#fafafa", + "editor.selectionBackground": "#e8e8f4", + "editorIndentGuide.background1": "#f0f0f2", + }, + }); + }} onChange={handleContentChange} onMount={(editor, monaco) => { const jsOptions = monaco.languages.typescript.javascriptDefaults.getCompilerOptions(); @@ -139,7 +189,13 @@ export default function CodeEditor({ options={{ readOnly: readonly, minimap: { enabled: false }, - fontSize: 14, + fontSize: 13, + fontFamily: '"SF Mono", Menlo, Monaco, Consolas, monospace', + lineHeight: 22, + padding: { top: 18, bottom: 18 }, + lineNumbersMinChars: 3, + overviewRulerBorder: false, + scrollbar: { verticalScrollbarSize: 7, horizontalScrollbarSize: 7 }, lineNumbers: "on", roundedSelection: false, scrollBeyondLastLine: false, @@ -149,6 +205,13 @@ export default function CodeEditor({ }} />
+
+ + + {currentFile.filename} + + {getLanguage(currentFile.filename)} +
); } diff --git a/src/components/Editor/FileTabs.tsx b/src/components/Editor/FileTabs.tsx index 0adccc1..747750a 100644 --- a/src/components/Editor/FileTabs.tsx +++ b/src/components/Editor/FileTabs.tsx @@ -1,6 +1,6 @@ -import { Edit2, Plus, X } from "lucide-react"; +import { Edit2, FileCode2, Plus, X } from "lucide-react"; import type React from "react"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -39,6 +39,11 @@ export default function FileTabs({ const [showRenameDialog, setShowRenameDialog] = useState(false); const [showCreateDialog, setShowCreateDialog] = useState(false); const [newFileName, setNewFileName] = useState(""); + const activeTabRef = useRef(null); + const activeFilename = files[activeIndex]?.filename; + useEffect(() => { + activeTabRef.current?.scrollIntoView({ block: "nearest", inline: "nearest" }); + }, [activeIndex, activeFilename]); const handleStartEdit = (index: number, currentName: string) => { if (readonly) return; @@ -72,18 +77,47 @@ export default function FileTabs({ return ( <> -
-
+
+
{files.map((file, index) => (
{ + if (event.target !== event.currentTarget) return; + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onFileSelect(index); + } + if (event.key === "ArrowRight" || event.key === "ArrowLeft") { + event.preventDefault(); + const next = + (index + (event.key === "ArrowRight" ? 1 : -1) + files.length) % files.length; + onFileSelect(next); + const tabs = + event.currentTarget.parentElement?.querySelectorAll( + '[role="tab"]', + ); + tabs?.[next]?.focus(); + } + }} className={cn( - "group flex h-9 flex-shrink-0 items-center space-x-1.5 border-r px-3 text-sm cursor-pointer hover:bg-accent/50 transition-colors", - activeIndex === index && "bg-background border-b-2 border-b-primary", + "group relative flex h-9 max-w-[250px] shrink-0 cursor-pointer items-center gap-2 border-r border-border/70 px-3 text-[11px] text-muted-foreground transition-colors hover:bg-muted focus-visible:outline focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-ring", + activeIndex === index && + "bg-muted text-foreground after:absolute after:inset-x-3 after:bottom-0 after:h-px after:bg-foreground/60", )} onClick={() => onFileSelect(index)} > + handleStartEdit(index, file.filename)} @@ -95,7 +129,8 @@ export default function FileTabs({
{(!readonly || actions) && ( -
+
{!readonly && ( diff --git a/src/components/Editor/index.tsx b/src/components/Editor/index.tsx index 1d7a542..575f769 100644 --- a/src/components/Editor/index.tsx +++ b/src/components/Editor/index.tsx @@ -1,7 +1,7 @@ import ansis from "ansis"; import { useAtom, useAtomValue } from "jotai"; import { debounce } from "lodash-es"; -import { Check, Settings2, X } from "lucide-react"; +import { Check, Code2, Layers, LoaderCircle, Settings2, X } from "lucide-react"; import type * as Monaco from "monaco-editor"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; @@ -26,6 +26,7 @@ import { import { activeInputFileAtom, activeOutputFileAtom, enableDependenciesAtom } from "@/store/editor"; interface InputPanelProps { + defaultSize?: number; inputFiles: EditorSourceFile[]; activeInputFile: number; setActiveInputFile: (index: number) => void; @@ -38,6 +39,7 @@ interface InputPanelProps { } function InputPanel({ + defaultSize = 50, inputFiles, activeInputFile, setActiveInputFile, @@ -49,8 +51,21 @@ function InputPanel({ panelRef, }: InputPanelProps) { return ( - -
+ +
+
+
+ +

Source

+ + {inputFiles.length} + +
+ + + Auto build + +
{showSettings && ( -
+
Output Settings
-
+