@tanstack/devtools
Version:
TanStack Devtools is a set of tools for building advanced devtools for your application.
176 lines (158 loc) • 5.93 kB
text/typescript
import {
createMemo,
createSignal,
useContext as getContext,
onCleanup,
} from 'solid-js'
import { MAX_ACTIVE_PLUGINS } from '../utils/constants.js'
import { appendPane, closeTab, flattenTabs } from '../utils/layout-tree.js'
import { DevtoolsContext } from './devtools-context.jsx'
import type { LayoutNode } from '../utils/layout-tree.js'
import type { DevtoolsStore } from './devtools-store.js'
const createDevtoolsContext = () => {
const context = getContext(DevtoolsContext)
if (context === undefined) {
throw new Error(
'createDevtoolsContext must be used within a ShellContextProvider',
)
}
return context
}
export function createTheme() {
const { settings, setSettings } = createDevtoolsSettings()
const theme = createMemo(() => settings().theme)
return {
theme,
setTheme: (theme: DevtoolsStore['settings']['theme']) =>
setSettings({ theme }),
}
}
export const createPlugins = () => {
const { store, setStore } = createDevtoolsContext()
const plugins = createMemo(() => store.plugins)
/**
* Derived, never stored. The layout tree is the only record of what is open,
* so this cannot drift out of step with it.
*
* Compared by contents, not identity: `flattenTabs` builds a fresh array every
* time, and without this every unrelated store write would look like a change
* and re-run each plugin's `render`.
*/
const activePlugins = createMemo(() => flattenTabs(store.state.layout), [], {
equals: (a, b) => a.length === b.length && a.every((id, i) => id === b[i]),
})
const layout = createMemo(() => store.state.layout)
const setLayout = (next: LayoutNode | null) => {
setStore((previous) => ({
...previous,
state: { ...previous.state, layout: next },
}))
}
const toggleActivePlugins = (pluginId: string) => {
const current = store.state.layout
const isActive = flattenTabs(current).includes(pluginId)
if (isActive) {
// `destroy` is deliberately not called here. It hangs off the pane's own
// teardown in `PluginWorkspace`, so it fires exactly once however the pane
// was closed — this toggle, a tab's close button, or a whole group going
// away — and it fires before the mount node is detached.
setLayout(closeTab(current, pluginId))
return
}
if (flattenTabs(current).length >= MAX_ACTIVE_PLUGINS) return
// Opening from the strip adds a pane alongside the others at an equal share,
// which is the side-by-side behaviour the strip has always had. Dragging
// decides placement for itself.
setLayout(appendPane(current, pluginId))
}
return { plugins, toggleActivePlugins, activePlugins, layout, setLayout }
}
export const createDevtoolsState = () => {
const { store, setStore } = createDevtoolsContext()
const state = createMemo(() => store.state)
const setState = (newState: Partial<DevtoolsStore['state']>) => {
setStore((previous) => ({
...previous,
state: {
...previous.state,
...newState,
},
}))
}
return { state, setState }
}
export const createDevtoolsSettings = () => {
const { store, setStore } = createDevtoolsContext()
const settings = createMemo(() => store.settings)
const setSettings = (newSettings: Partial<DevtoolsStore['settings']>) => {
setStore((previous) => ({
...previous,
settings: {
...previous.settings,
...newSettings,
},
}))
}
return { setSettings, settings }
}
export const createPersistOpen = () => {
const { state, setState } = createDevtoolsState()
const persistOpen = createMemo(() => state().persistOpen)
const setPersistOpen = (value: boolean) => {
setState({ persistOpen: value })
}
return { persistOpen, setPersistOpen }
}
/**
* Whether the strip and destination content are collapsed behind the main
* header. Deliberately not part of the persisted store: it is a momentary view
* state, and a reload should bring the panel back in full.
*
* The shell mounts once per document, so one module-level signal is enough and
* saves threading the toggle through every strip that hosts the control.
*/
const [collapsed, setCollapsed] = createSignal(false)
export const createCollapsed = () => ({
isCollapsed: collapsed,
toggleCollapsed: () => setCollapsed((previous) => !previous),
setCollapsed,
})
/**
* Handing a drag from the Plugins strip over to the workspace.
*
* The strip and the workspace are siblings, and only the workspace knows where
* the panes are, so the workspace registers a handler here and the strip calls it
* the moment a press becomes a drag.
*
* A **direct call**, not a signal: an effect reacting to a signal runs a
* microtask later, and every pointer move in the meantime is lost — a quick flick
* from the strip into a pane would land with no drop target ever computed.
*/
type StripDragHandler = (
pluginId: string,
point: { x: number; y: number },
) => void
export const createStripDrag = () => {
const { paneDragBridge } = createDevtoolsContext()
return {
/** Called by the workspace during setup; cleared when it goes away. */
acceptStripDrags: (handler: StripDragHandler) => {
paneDragBridge.handler = handler
onCleanup(() => {
// Only clear our own, so a replacement that registered first survives.
if (paneDragBridge.handler === handler) paneDragBridge.handler = null
})
},
/** Called by the strip once a press has been held long enough to be a drag. */
beginStripDrag: (pluginId: string, point: { x: number; y: number }) =>
paneDragBridge.handler?.(pluginId, point),
}
}
export const createHeight = () => {
const { state, setState } = createDevtoolsState()
const height = createMemo(() => state().height)
const setHeight = (newHeight: number) => {
setState({ height: newHeight })
}
return { height, setHeight }
}