Skip to content
Merged
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
119 changes: 99 additions & 20 deletions examples/TscircuitWorkspaceWithRunframe.fixture.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,104 @@
import { useMemo, useState } from "react"
import "../src/styles.css"
import type { EditorFile } from "../src"
import { resolveTscircuitEntrypoint } from "../fixtures-support/resolveTscircuitEntrypoint"
import { SuspenseRunFrame } from "../src/components/SuspenseRunFrame"
import {
WorkspaceCodeEditor,
type EditorFile,
} from "../src/components/WorkspaceCodeEditor"
import { WorkspaceCodeEditor } from "../src/components/WorkspaceCodeEditor"
import { useWorkspaceFiles } from "../src/hooks/useWorkspaceFiles"

const initialFiles: EditorFile[] = [
{
path: "index.tsx",
content: `export default () => (
<board width="10mm" height="10mm">
<resistor
resistance="1k"
footprint="0402"
name="R1"
/>
<capacitor
capacitance="1000pF"
footprint="0402"
name="C1"
connections={{ pin1: "R1.pin1" }}
/>
content: `import manualEdits from "./manual-edits.json"
import { MPL3115A2R1 } from "./MPL3115A2R1"
import { sel } from "tscircuit"

export default () => (
<board width="15.24mm" height="17.78mm" manualEdits={manualEdits}>
<group>
<capacitor
name="C1"
footprint="cap0402"
capacitance="0.1uF"
pcbX={1.27}
pcbRotation={-90}
/>
<resistor
name="R1"
resistance="1k"
footprint="0402"
pcbRotation={90}
connections={{ pin2: sel.net().SCL }}
/>
<MPL3115A2R1
name="U1"
pcbRotation={90}
pcbY={5.08}
connections={{
INT1: sel.net().INT1,
INT2: sel.net().INT2,
SCL: sel.net().SCL,
SDA: sel.net().SDA,
}}
/>
<netlabel
net="GND"
anchorSide="top"
connectsTo={[sel.C1.pin2]}
schY={2.1}
/>
</group>
</board>
)`,
)
`,
},
{
path: "MPL3115A2R1.tsx",
content: `import type { ChipProps } from "@tscircuit/props"

const pinLabels = {
pin1: ["VDD"],
pin2: ["CAP"],
pin3: ["GND"],
pin4: ["VDDIO"],
pin5: ["INT2"],
pin6: ["INT1"],
pin7: ["SDA"],
pin8: ["SCL"],
} as const

export const MPL3115A2R1 = (props: ChipProps<typeof pinLabels>) => {
return (
<chip
{...props}
schWidth={1.5}
pinLabels={pinLabels}
manufacturerPartNumber="MPL3115A2R1"
footprint={
<footprint>
<smtpad portHints={["pin1"]} pcbX="-1.875mm" pcbY="-1.30mm" width="0.60mm" height="1.50mm" shape="rect" />
<smtpad portHints={["pin2"]} pcbX="-0.625mm" pcbY="-1.30mm" width="0.60mm" height="1.50mm" shape="rect" />
<smtpad portHints={["pin3"]} pcbX="0.625mm" pcbY="-1.30mm" width="0.60mm" height="1.50mm" shape="rect" />
<smtpad portHints={["pin4"]} pcbX="1.875mm" pcbY="-1.30mm" width="0.60mm" height="1.50mm" shape="rect" />
<smtpad portHints={["pin5"]} pcbX="1.875mm" pcbY="1.30mm" width="0.60mm" height="1.50mm" shape="rect" />
<smtpad portHints={["pin6"]} pcbX="0.625mm" pcbY="1.30mm" width="0.60mm" height="1.50mm" shape="rect" />
<smtpad portHints={["pin7"]} pcbX="-0.625mm" pcbY="1.30mm" width="0.60mm" height="1.50mm" shape="rect" />
<smtpad portHints={["pin8"]} pcbX="-1.875mm" pcbY="1.30mm" width="0.60mm" height="1.50mm" shape="rect" />
</footprint>
}
/>
)
}
`,
},
{
path: "manual-edits.json",
content: `{
"pcb_placements": [],
"schematic_placements": [],
"edit_events": []
}
`,
},
]

Expand All @@ -36,7 +110,6 @@ export default function TscircuitWorkspaceWithRunframeFixture() {
const [lastRenderState, setLastRenderState] = useState<
"idle" | "running" | "finished"
>("idle")

const fsMap = useMemo(
() =>
workspace.files.reduce(
Expand All @@ -48,6 +121,12 @@ export default function TscircuitWorkspaceWithRunframeFixture() {
),
[workspace.files],
)
const mainComponentPath = useMemo(
() =>
resolveTscircuitEntrypoint(workspace.files, workspace.currentFile) ??
"index.tsx",
[workspace.files, workspace.currentFile],
)

return (
<div className="h-screen bg-white">
Expand All @@ -59,7 +138,7 @@ export default function TscircuitWorkspaceWithRunframeFixture() {
<SuspenseRunFrame
className="h-full flex-1"
fsMap={fsMap}
mainComponentPath="index.tsx"
mainComponentPath={mainComponentPath}
showFileMenu={false}
showRunButton
forceLatestEvalVersion
Expand Down
66 changes: 66 additions & 0 deletions fixtures-support/resolveTscircuitEntrypoint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type { EditorFile } from "../src"

// Fixture-only helper that mirrors tscircuit.com's entrypoint selection so the
// Cosmos runframe example runs the selected runnable file instead of always
// falling back to index.tsx. This stays outside src/ because it is not part of
// the public editor/workspace library API.
export const hasDefaultEntrypointExport = (code: string) => {
return (
/export default\s+\w+/.test(code) ||
/export default\s+function\s*(\w*)\s*\(/.test(code) ||
/export default\s*\(\s*\)\s*=>/.test(code) ||
/export default\s*\(.*?\)\s*=>/.test(code) ||
/export\s*\{\s*\w+\s+as\s+default\s*\}/.test(code)
)
}

const findConfiguredEntrypoint = (
files: EditorFile[],
field: "mainEntrypoint" | "previewComponentPath",
) => {
const configFile = files.find((file) => file.path === "tscircuit.config.json")
if (!configFile) return null

try {
const config = JSON.parse(configFile.content) as Record<string, unknown>
const configuredPath = config[field]
if (typeof configuredPath !== "string") return null

const normalizedPath = configuredPath.startsWith("./")
? configuredPath.slice(2)
: configuredPath

return files.find((file) => file.path === normalizedPath) ?? null
} catch {
return null
}
}

export const resolveTscircuitEntrypoint = (
files: EditorFile[],
currentFile: string | null,
) => {
const currentFileData = currentFile
? files.find((file) => file.path === currentFile)
: null

if (
currentFileData &&
/\.(ts|tsx)$/.test(currentFileData.path) &&
hasDefaultEntrypointExport(currentFileData.content)
) {
return currentFileData.path
}

return (
findConfiguredEntrypoint(files, "mainEntrypoint")?.path ??
findConfiguredEntrypoint(files, "previewComponentPath")?.path ??
files.find((file) => file.path === "index.tsx" || file.path === "index.ts")
?.path ??
files.find((file) => file.path.endsWith(".circuit.tsx"))?.path ??
files.find((file) => file.path === "main.tsx" || file.path === "main.ts")
?.path ??
files.find((file) => file.path.endsWith(".tsx"))?.path ??
null
)
}
43 changes: 33 additions & 10 deletions src/components/WorkspaceCodeEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import Editor, { type OnChange, type OnMount } from "@monaco-editor/react"
import { useEffect, useMemo, useRef, useState } from "react"
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"
import * as monaco from "monaco-editor"
import {
defaultCodeEditorOptions,
Expand Down Expand Up @@ -101,14 +101,15 @@ export function WorkspaceCodeEditor({
readOnly = false,
isSaving = false,
isStreaming = false,
isPriorityFileFetched = false,
isPriorityFileFetched,
showSidebar = true,
className,
height = "100%",
options,
}: WorkspaceCodeEditorProps) {
const isReady = useMonacoReady()
const [editorReady, setEditorReady] = useState(false)
const [workspaceModelsReady, setWorkspaceModelsReady] = useState(false)

const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null)
const managerRef = useRef<MonacoWorkspaceModelManager | null>(null)
Expand Down Expand Up @@ -136,6 +137,14 @@ export function WorkspaceCodeEditor({
const currentContentRef = useRef(currentContent)
currentContentRef.current = currentContent
const currentFileIsBinary = currentFileData?.isBinary === true
const isPriorityFilePending = isPriorityFileFetched === false
const workspaceFiles = useMemo(
() =>
files
.filter((file) => !file.isBinary)
.map((file) => ({ path: file.path, content: file.content })),
[files],
)

const editorOptions =
useMemo<monaco.editor.IStandaloneEditorConstructionOptions>(
Expand All @@ -155,14 +164,28 @@ export function WorkspaceCodeEditor({

// Keep a live model for every (text) file so the TypeScript language service
// can resolve cross-file imports and surface diagnostics project-wide.
useLayoutEffect(() => {
if (!isReady) {
setWorkspaceModelsReady(false)
return
}

managerRef.current?.syncFiles(workspaceFiles)
setWorkspaceModelsReady(true)
}, [isReady, workspaceFiles])

// Monaco's TS worker can occasionally compute diagnostics before it has seen
// the full workspace graph on the first load. Re-sync once after the editor
// mounts to force a fresh pass without requiring a manual page refresh.
useEffect(() => {
if (!isReady) return
managerRef.current?.syncFiles(
files
.filter((file) => !file.isBinary)
.map((file) => ({ path: file.path, content: file.content })),
)
}, [isReady, files])
if (!isReady || !editorReady) return

const timeoutId = window.setTimeout(() => {
managerRef.current?.syncFiles(workspaceFiles)
}, 0)

return () => window.clearTimeout(timeoutId)
}, [isReady, editorReady, workspaceFiles])

// Route cross-file "go to definition" through onFileSelect so navigation into
// another workspace file switches the active file instead of failing silently.
Expand Down Expand Up @@ -348,7 +371,7 @@ export function WorkspaceCodeEditor({
</pre>
) : currentFileIsBinary ? (
<BinaryFileNotice downloadUrl={currentFileData?.downloadUrl} />
) : !isReady || isPriorityFileFetched ? (
) : !isReady || !workspaceModelsReady || isPriorityFilePending ? (
<CenteredMessage>Loading editor…</CenteredMessage>
) : currentFile ? (
<Editor
Expand Down
22 changes: 13 additions & 9 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,5 @@
export { CodeEditor, defaultCodeEditorOptions } from "./components/CodeEditor"
export { WorkspaceCodeEditor } from "./components/WorkspaceCodeEditor"
export { useMonacoReady } from "./hooks/useMonacoReady"
export { useTscircuitTypeAcquisition } from "./hooks/useTscircuitTypeAcquisition"
export { useWorkspaceFiles } from "./hooks/useWorkspaceFiles"
export {
createMonacoWorkspaceModelManager,
MonacoWorkspaceModelManager,
} from "./monaco/monacoWorkspace"
export { acquireTscircuitTypes } from "./monaco/typeAcquisition"
export { configureMonacoTypeScript } from "./monaco/monacoTypeScript"

export type { CodeEditorProps } from "./components/CodeEditor"
export type {
Expand All @@ -21,10 +12,23 @@ export type {
RenameFileResult,
WorkspaceCodeEditorProps,
} from "./components/WorkspaceCodeEditor"

export { useMonacoReady } from "./hooks/useMonacoReady"
export { useTscircuitTypeAcquisition } from "./hooks/useTscircuitTypeAcquisition"
export { useWorkspaceFiles } from "./hooks/useWorkspaceFiles"

export type {
UseWorkspaceFilesOptions,
WorkspaceFilesController,
} from "./hooks/useWorkspaceFiles"

export { configureMonacoTypeScript } from "./monaco/monacoTypeScript"
export {
createMonacoWorkspaceModelManager,
MonacoWorkspaceModelManager,
} from "./monaco/monacoWorkspace"
export { acquireTscircuitTypes } from "./monaco/typeAcquisition"

export type {
MonacoWorkspaceModelManagerOptions,
WorkspaceFile,
Expand Down
2 changes: 1 addition & 1 deletion src/monaco/monacoWorkspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ function defaultInferLanguage(path: string) {
}

function defaultToUri(path: string) {
return monaco.Uri.parse(`file://${normalizePath(path)}`)
return monaco.Uri.file(normalizePath(path))
}

export class MonacoWorkspaceModelManager {
Expand Down
Loading