diff --git a/package-lock.json b/package-lock.json index 902ef39e2..aeb022d99 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10928,6 +10928,7 @@ "@reatom/persist": "^3.3.0", "@reatom/persist-web-storage": "^3.2.3", "@reatom/primitives": "^3.2.1", + "@reatom/web": "^3.5.1", "stylerun": "^1.0.0" }, "devDependencies": { diff --git a/packages/logger/package.json b/packages/logger/package.json index 7acc549b6..6799cb9bf 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -33,6 +33,7 @@ "@reatom/persist": "^3.3.0", "@reatom/persist-web-storage": "^3.2.3", "@reatom/primitives": "^3.2.1", + "@reatom/web": "^3.5.1", "stylerun": "^1.0.0" }, "author": "artalar", diff --git a/packages/logger/src/devtools-button.ts b/packages/logger/src/devtools-button.ts new file mode 100644 index 000000000..370b0de91 --- /dev/null +++ b/packages/logger/src/devtools-button.ts @@ -0,0 +1,15 @@ +import styled from 'stylerun' + +export const buttonStyles = styled('')` + border: none; + font: inherit; + font-weight: bolder; + background: #24243a; + color: white; + border-radius: 0.25rem; + border: 1px solid #c5c6de88; + padding: 0.25rem; + width: 100%; +`.styled(':hover')` + background-color: #3B3B4E; +` diff --git a/packages/logger/src/devtools-filter-model.ts b/packages/logger/src/devtools-filter-model.ts new file mode 100644 index 000000000..44a29a61e --- /dev/null +++ b/packages/logger/src/devtools-filter-model.ts @@ -0,0 +1,122 @@ +import { atom, AtomCache, action, AtomMut } from '@reatom/core' +import { parseAtoms } from '@reatom/lens' +import { withLocalStorage } from '@reatom/persist-web-storage' +import { reatomArray } from '@reatom/primitives' + +export type FilterColor = (typeof FilterColors)[number] +export const FilterColors = [ + 'red', + 'green', + 'blue', + 'yellow', + 'gray', + 'black', +] as const + +export type FilterAction = (typeof FilterActions)[number] +export const FilterActions = ['hide', ...FilterColors] as const + +export type Filter = { + code: AtomMut + action: AtomMut + enabled: AtomMut +} + +const reatomFilter = (config: { + code: string + action: FilterAction + enabled: boolean +}): Filter => ({ + code: atom(config.code), + action: atom(config.action), + enabled: atom(config.enabled), +}) + +export const filters = reatomArray([] as Filter[], 'filters').pipe( + withLocalStorage({ + key: '@reatom/logger:filters', + toSnapshot: parseAtoms, + fromSnapshot: (ctx, snapshot = []) => + (snapshot as any[]).map((config) => reatomFilter(config)), + }), +) + +export const filterSplice = action( + (ctx, filter: Filter, ...replaceWith: Filter[]) => { + const index = ctx.get(filters).indexOf(filter) + filters(ctx, filters.toSpliced(ctx, index, 1, ...replaceWith)) + }, + 'filterSplice', +) + +export const filtersEnabledGet = atom( + (ctx) => ctx.spy(filters).some((filter) => ctx.spy(filter.enabled)), + 'filtersEnabledGet', +) + +export const filtersEnabledSet = action((ctx, next: boolean) => { + for (const filter of ctx.get(filters)) filter.enabled(ctx, next) +}, 'filtersEnabledSet') + +export const filtersEnabledIndeterminate = atom((ctx) => { + let some = false + let every = true + for (const filter of ctx.spy(filters)) { + if (ctx.spy(filter.enabled)) { + some = true + } else { + every = false + } + } + return some && !every +}, 'filtersEnabledSet') + +const filterFunctions = atom((ctx) => { + return ctx.spy(filters).map((filter) => { + // TODO execute in a worker + return new Function( + 'log', + `var proto = log.proto; var name = proto.name; return ${ctx.spy( + filter.code, + )}`, + ) as (node: AtomCache) => unknown + }) +}, 'filterFunctions') + +const filterRun = action((ctx, node: AtomCache) => { + const filtersState = ctx.get(filters) + return ctx.get(filterFunctions).map((fn, i) => { + try { + if (!fn(node)) return null + return ctx.get(filtersState[i]!.action) + } catch (error) { + // TODO report error in UI + console.error(error) + return null + } + }) +}) + +export const getNodeHidden = action((ctx, node: AtomCache) => { + return filterRun(ctx, node).some((action) => action === 'hide') +}) + +export const getNodeColor = action((ctx, node: AtomCache) => { + return filterRun(ctx, node).find((action) => action !== 'hide') as + | FilterColor + | undefined +}) +export const draftCode = atom('', 'draftCode') +export const draftAction = atom('hide' as FilterAction, 'draftAction') +export const draftCreate = action((ctx) => { + const filter = reatomFilter({ + code: ctx.get(draftCode), + action: ctx.get(draftAction), + enabled: true, + }) + + filters(ctx, (prev) => [...prev, filter]) + + draftCode(ctx, '') + draftAction(ctx, 'hide') +}, 'filterDraftCreate') diff --git a/packages/logger/src/devtools-filter.ts b/packages/logger/src/devtools-filter.ts new file mode 100644 index 000000000..deba882d6 --- /dev/null +++ b/packages/logger/src/devtools-filter.ts @@ -0,0 +1,181 @@ +import { + Action, + Atom, + AtomMaybe, + Ctx, + action, + atom, + isAtom, +} from '@reatom/core' +import { ctx, h } from '@reatom/jsx' +import styled from 'stylerun' +import * as model from './devtools-filter-model' +import { t } from './t' +import { match } from '@reatom/lens' +import { buttonStyles } from './devtools-button' +import { HTMLInputTypeAttribute } from 'react' +import { onConnect } from '@reatom/hooks' + +export const FilterMenu = () => { + return atom((ctx) => + t.div({ + ...menuStyles({}), + children: [ + FilterMenuDraft(), + match((ctx) => ctx.spy(model.filters).length).truthy( + t.div(draftHrStyles({})), + ), + ...ctx.spy(model.filters).map((filter) => { + return FilterView({ + checked: filter.enabled, + setChecked: filter.enabled, + checkedLabel: 'Filter enabled?', + action: filter.action, + setAction: filter.action, + code: filter.code, + setCode: filter.code, + buttonLabel: 'remove', + buttonClicked: action((ctx) => model.filterSplice(ctx, filter)), + }) + }), + ], + }), + ) +} + +const FilterMenuDraft = () => { + return FilterView({ + checkedIndeterminate: model.filtersEnabledIndeterminate, + action: model.draftAction, + setAction: model.draftAction, + code: model.draftCode, + setCode: model.draftCode, + checked: model.filtersEnabledGet, + setChecked: model.filtersEnabledSet, + checkedLabel: 'Filters enabled?', + buttonLabel: 'create', + buttonClicked: model.draftCreate, + }) +} + +const FilterView = ({ + checkedIndeterminate, + checked, + setChecked, + checkedLabel, + action: actionAtom, + setAction, + code, + setCode, + buttonLabel, + buttonClicked, +}: { + checkedIndeterminate?: Atom + checked: AtomMaybe + setChecked: (ctx: Ctx, next: boolean) => void + checkedLabel: string + action: Atom + setAction: (ctx: Ctx, next: model.FilterAction) => void + code: AtomMaybe + setCode: (ctx: Ctx, next: string) => void + buttonLabel: string + buttonClicked: (ctx: Ctx) => void +}) => { + const input = t.input({ + ...filterEnabledStyles({}), + type: 'checkbox', + checked: checked, + title: checkedLabel, + oninput: action((ctx, event) => + setChecked(ctx, event.target.checked), + ) as any, + }) as HTMLInputElement + + const viewAtom = atom((ctx) => + h('div', listItemStyles({}), [ + input, + FilterActionView({ + value: actionAtom, + setValue: action((ctx, action) => setAction(ctx, action)), + }), + t.input({ + ...filterCodeStyles({}), + type: 'string', + placeholder: 'filter code', + value: code, + min: 1, + minLength: 1, + size: 50, + // FIXME types + oninput: action((ctx, event) => + setCode(ctx, event.target.value), + ) as any, + }), + t.button({ + ...buttonStyles({}), + onclick: action((ctx) => buttonClicked(ctx)), + children: [buttonLabel], + }), + ]), + ) + + if (checkedIndeterminate) { + onConnect(viewAtom, (ctx) => { + return ctx.subscribe(checkedIndeterminate, (state) => { + input.indeterminate = state + }) + }) + } + + return viewAtom +} + +const FilterActionView = ({ + value, + setValue, +}: { + value: Atom + setValue: (ctx: Ctx, action: model.FilterAction) => void +}) => { + return t.select({ + ...buttonStyles({}), + value, + onchange: action((ctx, event) => + setValue(ctx, event.target.value as model.FilterAction), + ) as any, + children: model.FilterActions.map((action) => + t.option({ + value: action, + selected: atom((ctx) => ctx.spy(value) === action), + children: [action], + }), + ), + }) +} + +const menuStyles = styled('')` + display: flex; + flex-direction: column; + gap: calc(var(--rem) / 4); +` + +const draftHrStyles = styled('')` + width: 100%; + border-bottom: 1px solid #c5c6de88; + margin: 0.25rem 0; +` + +const listItemStyles = styled('')` + display: flex; + gap: 0.25rem; +` + +const filterEnabledStyles = styled('')`` + +const filterCodeStyles = styled('')` + font: var(--mono_fonts); +` + +const filterActionStyles = styled('')`` + +const filterButtonStyles = styled('')`` diff --git a/packages/logger/src/rld-inspect-model.ts b/packages/logger/src/devtools-inspect-model.ts similarity index 79% rename from packages/logger/src/rld-inspect-model.ts rename to packages/logger/src/devtools-inspect-model.ts index ed934be83..496a3de5a 100644 --- a/packages/logger/src/rld-inspect-model.ts +++ b/packages/logger/src/devtools-inspect-model.ts @@ -1,12 +1,11 @@ import { AtomCache, action, atom } from '@reatom/core' -import { caches } from './rld-model' +import { caches } from './devtools-model' import { - FilterAction, FilterColor, - filterColor, - filterHide, + getNodeColor, + getNodeHidden, filters, -} from './rld-filter-model' +} from './devtools-filter-model' import { parseAtoms } from '@reatom/lens' export type Log = { cache: AtomCache; color?: FilterColor } @@ -16,22 +15,20 @@ export const logSelected = atom(null as Log | null, 'logSelected') export const logSelect = action((ctx, log: Log) => { if (ctx.get(logSelected) === log) { logSelected(ctx, null) - return - } - logSelected(ctx, log) + } else logSelected(ctx, log) }, 'logSelect') export const logGroups = atom((ctx) => { const logs = ctx .spy(caches) - .map((cache) => ({ cache, color: filterColor(ctx, cache) })) + .map((cache) => ({ cache, color: getNodeColor(ctx, cache) })) const groups: LogGroup[] = [] // otherwise we lose reactivity because filters are used in actions parseAtoms(ctx, filters) for (const log of logs) { - if (filterHide(ctx, log.cache)) { + if (getNodeHidden(ctx, log.cache)) { let group = groups.at(-1) if (!group || !('hide' in group)) { group = { hide: [] } diff --git a/packages/logger/src/devtools-inspect.ts b/packages/logger/src/devtools-inspect.ts new file mode 100644 index 000000000..ae59a52ca --- /dev/null +++ b/packages/logger/src/devtools-inspect.ts @@ -0,0 +1,244 @@ +import { match } from '@reatom/lens' +import * as model from './devtools-inspect-model' +import { JSXElement, ctx } from '@reatom/jsx' +import { Atom, AtomCache, AtomMut, Ctx, action, atom } from '@reatom/core' +import styled from 'stylerun' +// @ts-expect-error +import { Inspector } from '@observablehq/inspector' +import { t } from './t' +import { onConnect } from '@reatom/hooks' +import { onEvent } from '@reatom/web' +import { DevtoolsPane } from './devtools-pane' + +export const Inspect = () => { + const height = atom(400, 'height') + const graphWidth = atom(300, 'graphWidth') + const stateWidth = atom(300, 'stateWidth') + + return t.div({ + ...inspectStyles({}), + children: [ + DevtoolsPane({ + height, + width: graphWidth, + children: [InspectGraph()], + }), + match(model.logSelected) + .truthy(() => + DevtoolsPane({ + height, + width: stateWidth, + children: [InspectState()], + }), + ) + .falsy('wtf???'), + ], + }) +} + +const InspectGraph = () => { + return atom((ctx) => + t.div({ + ...graphNodesStyles({}), + children: ctx + .spy(model.logGroups) + .map((group) => + 'hide' in group + ? InspectGraphHidden({ hide: group.hide }) + : InspectGraphLog({ log: group }), + ), + }), + ) +} + +const InspectGraphLog = ({ log }: { log: model.Log }) => { + return t.div({ + ...graphLogStyles({}), + role: 'button', + onclick: action((ctx) => model.logSelect(ctx, log)), + children: [ + t.span({ + ...graphLogMarkerStyles({ color: log.color! }), + }), + t.span([log.cache.proto.name]), + ], + }) +} + +const InspectGraphHidden = ({ hide }: { hide: model.Log[] }) => { + const opened = atom(false, 'opened') + return t.div({ + ...graphHiddenStyles({}), + children: [ + t.div({ + ...graphHiddenLabelStyles({}), + role: 'button', + onclick: action((ctx) => opened(ctx, (prev) => !prev)), + children: [`${hide.length} log${hide.length != 1 ? 's' : ''} hidden`], + }), + match(opened).truthy(() => + t.div({ + ...graphHiddenListStyles({}), + children: hide.map((log) => InspectGraphLog({ log })), + }), + ), + ], + }) +} + +model.logSelected.onChange((ctx, log) => console.log('selected log:', log)) + +const InspectState = () => { + return [ + InspectStateStyles(), + atom((ctx) => { + const inspectorRoot = t.div({ style: { height: '100%' } }) + const inspector = new Inspector(inspectorRoot) + inspector.fulfilled(ctx.spy(model.logSelected)?.cache.state) + return inspectorRoot + }), + ] +} + +function InspectStateStyles() { + const selectorBase = `#reatom-logger-devtools .observablehq` + const styles = ` + ${selectorBase}--expanded, + ${selectorBase}--collapsed, + ${selectorBase}--function, + ${selectorBase}--import, + ${selectorBase}--string:before, + ${selectorBase}--string:after, + ${selectorBase}--gray { + color: var(--syntax_normal); + } + + ${selectorBase}--collapsed, + ${selectorBase}--inspect a { + cursor: pointer; + } + + ${selectorBase}--field { + text-indent: -1em; + margin-left: 1em; + } + + ${selectorBase}--empty { + color: var(--syntax_comment); + } + + ${selectorBase}--keyword, + ${selectorBase}--blue { + color: #3182bd; + } + + ${selectorBase}--forbidden, + ${selectorBase}--pink { + color: #e377c2; + } + + ${selectorBase}--orange { + color: #e6550d; + } + + ${selectorBase}--null, + ${selectorBase}--undefined, + ${selectorBase}--boolean { + color: var(--syntax_atom); + } + + ${selectorBase}--number, + ${selectorBase}--bigint, + ${selectorBase}--date, + ${selectorBase}--regexp, + ${selectorBase}--symbol, + ${selectorBase}--green { + color: var(--syntax_number); + } + + ${selectorBase}--index, + ${selectorBase}--key { + color: var(--syntax_key); + } + + ${selectorBase}--prototype-key { + color: #aaa; + } + + ${selectorBase}--empty { + font-style: oblique; + } + + ${selectorBase}--string, + ${selectorBase}--purple { + color: var(--syntax_string); + } + + ${selectorBase}--error, + ${selectorBase}--red { + color: #e7040f; + } + + ${selectorBase}--inspect { + font: var(--mono_fonts); + overflow-x: auto; + height: 100%; + display: block; + white-space: pre; + } + + ${selectorBase}--error .observablehq--inspect { + word-break: break-all; + white-space: pre-wrap; + } + ` + + return t.style([styles]) +} + +const inspectStyles = styled('')` + display: flex; + gap: 0.25rem; +` + +const graphNodesStyles = styled('')` + display: flex; + flex-direction: column; + gap: 0.25rem; +` + +const graphLogStyles = styled('')` + cursor: pointer; + user-select: none; + display: flex; + align-items: center; + gap: 0.3rem; +` + +const graphLogMarkerStyles = styled('')` + border: 1px solid black; + display: inline-block; + width: 16px; + height: 16px; + border-radius: 50%; + background-color: ${({ color }: { color: string }) => color}; +` + +const graphHiddenStyles = styled('')` + display: flex; + flex-direction: column; + margin: 0.1rem 0; +` + +const graphHiddenLabelStyles = styled('')` + cursor: pointer; + user-select: none; + opacity: 0.6; + font-size: smaller; +` +const graphHiddenListStyles = styled('')` + padding: 0.1rem 1rem; + gap: 0.25rem; + display: flex; + flex-direction: column; +` diff --git a/packages/logger/src/rld-model.ts b/packages/logger/src/devtools-model.ts similarity index 100% rename from packages/logger/src/rld-model.ts rename to packages/logger/src/devtools-model.ts diff --git a/packages/logger/src/devtools-pane.ts b/packages/logger/src/devtools-pane.ts new file mode 100644 index 000000000..6885a864f --- /dev/null +++ b/packages/logger/src/devtools-pane.ts @@ -0,0 +1,98 @@ +import { AtomMut, Atom, Ctx, atom, action } from '@reatom/core' +import { onConnect } from '@reatom/hooks' +import { JSXElement, ctx } from '@reatom/jsx' +import { onEvent } from '@reatom/web' +import { t } from './t' +import styled from 'stylerun' +import { noop } from '@reatom/utils' + +const cursorX = atom(-1, 'cursorX') +const cursorY = atom(-1, 'cursorY') + +let mouseupHandler = noop + +window.addEventListener('mousemove', (event) => { + cursorX(ctx, event.pageX) + cursorY(ctx, event.pageY) +}) + +window.addEventListener('mouseup', () => mouseupHandler()) + +export const DevtoolsPane = ({ + height, + width, + children, +}: { + height: AtomMut + width: Atom + resizeLeft?: (ctx: Ctx, to: number) => void + resizeRight?: (ctx: Ctx, to: number) => void + children: JSXElement[] +}) => { + const topHandle = t.div({ + ...paneHandleTopStyles({}), + onmousedown: action((ctx) => { + mouseupHandler = cursorY.onChange((ctx, cursorYState) => { + height( + ctx, + Math.max( + pane.getBoundingClientRect().bottom - 20 - cursorYState, + 100, + ), + ) + }) + }), + }) + + const pane = t.div({ + ...paneStyles({}), + children: [ + topHandle, + t.div({ + $attrs: [ + atom((ctx) => { + return paneContentStyles({ + width: ctx.spy(width) + 'px', + height: ctx.spy(height) + 'px', + }) + }), + ], + children, + }), + ], + }) + + return pane +} + +const paneStyles = styled('')` + position: relative; + background: #fefefe; + font: var(--mono_fonts); + color: #111; + padding: 12px 4px; + border-radius: 4px; + flex-grow: 1; + overflow: auto; + height: 100%; + min-width: 100px; + min-height: 100px; +` + +const paneHandleTopStyles = styled('')` + height: 8px; + width: 100%; + position: absolute; + top: 0; + left: 0; + background-color: #ccc; +`.styled(':hover')` + background-color: #199dfc; + cursor: row-resize; +` + +const paneContentStyles = styled('')` + overflow-y: scroll; + width: ${({ width }: { width: string; height: string }) => width}; + height: ${({ height }: { width: string; height: string }) => height}; +` diff --git a/packages/logger/src/devtools.ts b/packages/logger/src/devtools.ts index df9ac0312..9b38c0f03 100644 --- a/packages/logger/src/devtools.ts +++ b/packages/logger/src/devtools.ts @@ -1,7 +1,14 @@ -import { Ctx } from '@reatom/core' +import { Ctx, action, atom } from '@reatom/core' import { noop } from '@reatom/utils' import { h, hf, mount } from '@reatom/jsx' -import { Rld } from './rld' +import * as model from './devtools-model' +import { onConnect } from '@reatom/hooks' +import { match } from '@reatom/lens' +import { FilterMenu } from './devtools-filter' +import { Inspect } from './devtools-inspect' +import { t } from './t' +import styled from 'stylerun' +import { buttonStyles } from './devtools-button' export function devtoolsCreate(app: Ctx) { if (typeof window === 'undefined') { @@ -12,10 +19,134 @@ export function devtoolsCreate(app: Ctx) { root.id = 'reatom-logger-devtools' document.body.appendChild(root) - mount(root, h(Rld, { app })) + mount(root, h(Devtools, { app })) return () => { mount(root, h(hf, {})) root.remove() } } + +type Tab = (typeof Tabs)[number] +const Tabs = ['inspect', 'filters'] as const + +const opened = atom(true, 'opened') +const currentTab = atom('inspect', 'currentTab') + +export const Devtools = ({ app }: { app: Ctx }) => { + const jsx = atom((ctx) => + t.div({ + ...devtoolsStyles({}), + children: [ + match(opened) + .truthy( + t.div({ + ...devtoolsContentStyles({}), + children: [ + DevtoolsTabs(), + match(currentTab) + .is('inspect', Inspect()) + .is('filters', FilterMenu()) + .default('Unknown tab'), + t.button({ + ...buttonStyles({}), + children: ['Close Devtools'], + onclick: action((ctx) => opened(ctx, false)), + }), + ], + }), + ) + .falsy( + t.button({ + ...buttonStyles({}), + children: ['Reatom Devtools'], + onclick: action((ctx) => opened(ctx, true)), + }), + ), + ], + }), + ) + + onConnect(jsx, (ctx) => { + const unsub = app.subscribe((logs) => + // execute in the microtask to prevent 'cause collision' error + queueMicrotask(() => model.cachesAdd(ctx, logs)), + ) + return unsub + }) + + return jsx +} + +const DevtoolsTabs = () => { + return t.div({ + ...tabsStyles({}), + children: Tabs.map((tab) => + atom((ctx) => + t.button({ + ...tabStyles({}, {}, tab === ctx.spy(currentTab) ? 'active' : ''), + children: [tab], + onclick: action((ctx) => currentTab(ctx, tab)), + }), + ), + ), + }) +} + +const devtoolsStyles = styled('')` + --syntax_normal: #1b1e23; + --syntax_comment: #a9b0bc; + --syntax_number: #20a5ba; + --syntax_keyword: #c30771; + --syntax_atom: #10a778; + --syntax_string: #008ec4; + --syntax_error: #ffbedc; + --syntax_unknown_variable: #838383; + --syntax_known_variable: #005f87; + --syntax_matchbracket: #20bbfc; + --syntax_key: #6636b4; + --mono_fonts: 82%/1.5 Menlo, Consolas, monospace; + + position: absolute; + left: 1rem; + bottom: 1rem; + background: #171721; + color: #fff; + border-radius: 4px; +` + +const devtoolsContentStyles = styled('')` + padding: 8px; + display: flex; + flex-direction: column; + gap: 8px; +` + +const tabsStyles = styled('')` + margin-top: -8px; + margin-bottom: 0px; + margin-left: -8px; + margin-right: -8px; + width: calc(100% + 16px); + display: flex; +` + +const tabStyles = styled('')` + border: none; + font: inherit; + font-weight: bolder; + background: #24243a; + color: white; + padding: 4px; + flex-grow: 1; +`.styled(':hover')` + background-color: #3B3B4E; +`.styled(':first-child')` + border-top-left-radius: 0.25rem; +`.styled(':last-child')` + border-top-right-radius: 0.25rem; +`.styled('.active')` + background-color: #f9364d; +`.styled('.active:hover')` + background-color: #d32e41; +` diff --git a/packages/logger/src/rld-filter-model.ts b/packages/logger/src/rld-filter-model.ts deleted file mode 100644 index 49e193c33..000000000 --- a/packages/logger/src/rld-filter-model.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { atom, AtomCache, Ctx, action, AtomMut } from '@reatom/core' -import { parseAtoms } from '@reatom/lens' -import { WithPersistOptions } from '@reatom/persist' -import { - withSessionStorage, - withLocalStorage, -} from '@reatom/persist-web-storage' -import { reatomArray, ArrayAtom } from '@reatom/primitives' - -export type FilterColor = (typeof FilterColors)[number] -export const FilterColors = [ - 'red', - 'green', - 'blue', - 'yellow', - 'gray', - 'black', -] as const - -export type FilterAction = (typeof FilterActions)[number] -export const FilterActions = ['hide', ...FilterColors] as const - -export type Filter = { - code: AtomMut - action: AtomMut -} - -const filterMake = (code: string, action: FilterAction) => ({ - code: atom(code), - action: atom(action), -}) - -// FIXME https://t.me/reatom_ru/49316 -const filterPersistOptions: Partial> = { - toSnapshot: parseAtoms, - fromSnapshot: (ctx, snapshot = []) => - (snapshot as any[]).map((filter) => filterMake(filter.code, filter.action)), -} - -export const filtersSession = reatomArray( - [] as Filter[], - 'filtersSession', -).pipe( - withSessionStorage({ - key: 'rld/filtersSession', - ...filterPersistOptions, - }), -) - -export const filtersLocal = reatomArray([] as Filter[], 'rldFiltersLocal').pipe( - withLocalStorage({ - key: 'rld/filterLocal', - ...filterPersistOptions, - }), -) - -// TODO preserve order -export const filters = atom( - (ctx) => [...ctx.spy(filtersSession), ...ctx.spy(filtersLocal)], - 'filters', -) - -export const filterFunctions = atom((ctx) => { - return ctx.spy(filters).map((filter) => { - // TODO execute in a worker - return new Function( - 'log', - `var proto = log.proto; var name = proto.name; return ${ctx.spy( - filter.code, - )}`, - ) as (node: AtomCache) => unknown - }) -}, 'filterFunctions') - -const filterRun = action((ctx, node: AtomCache) => { - return ctx.get(filterFunctions).map((fn, i) => { - try { - if (fn(node)) return ctx.get(ctx.get(filters).at(i)!.action) - } catch (error) { - console.error(error) - // TODO report error in UI - } - }) -}) - -export const filterHide = action((ctx, node: AtomCache) => - filterRun(ctx, node).some((action) => action === 'hide'), -) - -export const filterColor = action( - (ctx, node: AtomCache) => - filterRun(ctx, node).find((action) => action !== 'hide') as - | FilterColor - | undefined, -) - -export function filterList(ctx: Ctx, filter: Filter) { - let list: ArrayAtom - - if (ctx.get(filtersSession).includes(filter)) list = filtersSession - else if (ctx.get(filtersLocal).includes(filter)) list = filtersLocal - else throw new Error('Unknown filter') - - return list -} - -export const filterSplice = action( - (ctx, filter: Filter, ...replaceWith: Filter[]) => { - const list = filterList(ctx, filter) - const index = ctx.get(list).indexOf(filter) - list(ctx, list.toSpliced(ctx, index, 1, ...replaceWith)) - }, - 'filterSplice', -) - -export const filterPersistSet = action( - (ctx, filter: Filter, persist: boolean) => { - const nextList = persist ? filtersLocal : filtersSession - filterSplice(ctx, filter) - nextList(ctx, (prev) => [...prev, filter]) - }, - 'filterPersistSet', -) - -export const draftCode = atom('', 'draftCode') -export const draftAction = atom('hide' as FilterAction, 'filterDraftAction') -export const draftPersist = atom(false, 'filterDraftPersist') -export const draftCreate = action((ctx) => { - const filter: Filter = { - code: atom(ctx.get(draftCode)), - action: atom(ctx.get(draftAction)), - } - - const list = ctx.get(draftPersist) ? filtersLocal : filtersSession - list(ctx, (prev) => [...prev, filter]) - - draftCode(ctx, '') - draftAction(ctx, 'hide') - draftPersist(ctx, false) -}, 'filterDraftCreate') diff --git a/packages/logger/src/rld-filter.ts b/packages/logger/src/rld-filter.ts deleted file mode 100644 index 7ae8792d3..000000000 --- a/packages/logger/src/rld-filter.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { Action, Atom, AtomMaybe, Ctx, action, atom } from '@reatom/core' -import { h } from '@reatom/jsx' -import styled from 'stylerun' -import * as model from './rld-filter-model' -import { t } from './t' -import { match } from '@reatom/lens' -import { buttonStyles } from './rld' - -export function RldFilterMenu() { - return atom((ctx) => - t.div({ - ...menuStyles({}), - children: [ - RldFilterMenuDraft(), - match(ctx.get(model.filters).length, t.div(draftHrStyles({}))), - ...ctx.spy(model.filters).map((filter) => - FilterView({ - persist: atom((ctx) => - ctx.spy(model.filtersLocal).includes(filter), - ), - setPersist: action((ctx, next) => - model.filterPersistSet(ctx, filter, next), - ), - action: filter.action, - setAction: filter.action, - code: filter.code, - setCode: filter.code, - buttonLabel: 'remove', - buttonClicked: action((ctx) => model.filterSplice(ctx, filter)), - }), - ), - ], - }), - ) -} - -function RldFilterMenuDraft() { - return FilterView({ - action: model.draftAction, - setAction: model.draftAction, - code: model.draftCode, - setCode: model.draftCode, - persist: model.draftPersist, - setPersist: model.draftPersist, - buttonLabel: 'create', - buttonClicked: model.draftCreate, - }) -} - -function FilterView({ - persist, - setPersist, - action: actionAtom, - setAction, - code, - setCode, - buttonLabel, - buttonClicked, -}: { - persist: AtomMaybe - setPersist: (ctx: Ctx, next: boolean) => void - action: Atom - setAction: (ctx: Ctx, next: model.FilterAction) => void - code: AtomMaybe - setCode: (ctx: Ctx, next: string) => void - buttonLabel: string - buttonClicked: (ctx: Ctx) => void -}) { - return h('div', listItemStyles({}), [ - t.input({ - ...filterPersistStyles({}), - type: 'checkbox', - title: 'Persist filter?', - checked: persist, - oninput: action((ctx, event) => - setPersist(ctx, event.target.checked), - ) as any, - }), - FilterActionView({ - value: actionAtom, - setValue: action((ctx, action) => setAction(ctx, action)), - }), - t.input({ - ...filterCodeStyles({}), - type: 'string', - placeholder: 'filter code', - value: code, - size: 50, - // FIXME types - oninput: action((ctx, event) => setCode(ctx, event.target.value)) as any, - }), - t.button({ - ...buttonStyles({}), - onclick: action((ctx) => buttonClicked(ctx)), - children: [buttonLabel], - }), - ]) -} - -function FilterActionView({ - value, - setValue, -}: { - value: Atom - setValue: (ctx: Ctx, action: model.FilterAction) => void -}) { - return t.select({ - ...buttonStyles({}), - value, - onchange: action((ctx, event) => - setValue(ctx, event.target.value as model.FilterAction), - ) as any, - children: model.FilterActions.map((action) => - t.option({ - value: action, - selected: atom((ctx) => ctx.spy(value) === action), - children: [action], - }), - ), - }) -} - -const menuStyles = styled('')` - display: flex; - flex-direction: column; - gap: 0.25rem; -` - -const draftHrStyles = styled('')` - width: 100%; - border-bottom: 1px solid #c5c6de88; - margin: 0.25rem 0; -` - -const listItemStyles = styled('')` - display: flex; - gap: 0.25rem; -` - -const filterPersistStyles = styled('')`` - -const filterCodeStyles = styled('')` - font: var(--mono_fonts); -` - -const filterActionStyles = styled('')`` - -const filterButtonStyles = styled('')`` diff --git a/packages/logger/src/rld-inspect.ts b/packages/logger/src/rld-inspect.ts deleted file mode 100644 index 498120a59..000000000 --- a/packages/logger/src/rld-inspect.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { match } from '@reatom/lens' -import * as model from './rld-inspect-model' -import { JSXElement } from '@reatom/jsx' -import { Atom, AtomCache, AtomMut, Ctx, action, atom } from '@reatom/core' -import styled from 'stylerun' -// @ts-expect-error -import { Inspector } from '@observablehq/inspector' -import { t } from './t' - -export function RldInspect() { - const height = atom(400, 'height') - const graphWidth = atom(300, 'graphWidth') - const stateWidth = atom(300, 'stateWidth') - - return t.div({ - ...inspectStyles({}), - children: [ - InspectView({ - height, - width: graphWidth, - children: [InspectGraph()], - }), - match( - model.logSelected, - InspectView({ - height, - width: stateWidth, - children: [InspectState()], - }), - t.div([]), - ), - ], - }) -} - -function InspectView({ - height, - width, - children, -}: { - height: AtomMut - width: Atom - resizeLeft?: (ctx: Ctx, to: number) => void - resizeRight?: (ctx: Ctx, to: number) => void - children: JSXElement[] -}) { - return atom((ctx) => - t.div({ - ...viewStyles({}), - children: [ - t.div({ - ...viewHandleTopStyles({}), - children: [ - t.div({ - ...viewHorStyles({}), - children: [ - t.div({ - ...viewContentStyles({}), - style: `width: ${ctx.spy(width)}px; height: ${ctx.spy( - height, - )}px;`, - children, - }), - ], - }), - ], - }), - ], - }), - ) -} - -function InspectGraph() { - return atom((ctx) => - t.div({ - ...graphNodesStyles({}), - children: ctx - .spy(model.logGroups) - .map((group) => - 'hide' in group - ? InspectGraphHidden({ hide: group.hide }) - : InspectGraphLog({ log: group }), - ), - }), - ) -} - -function InspectGraphLog({ log }: { log: model.Log }) { - return t.div({ - ...graphLogStyles({}), - role: 'button', - onclick: action((ctx) => model.logSelect(ctx, log)), - children: [ - t.span({ - ...graphLogMarkerStyles({} /* { color: log.color! } */), - style: `background-color: ${log.color!}`, - }), - t.span([log.cache.proto.name]), - ], - }) -} - -function InspectGraphHidden({ hide }: { hide: model.Log[] }) { - const opened = atom(false, 'opened') - return t.div({ - ...graphHiddenStyles({}), - children: [ - t.div({ - ...graphHiddenLabelStyles({}), - role: 'button', - onclick: action((ctx) => opened(ctx, (prev) => !prev)), - children: [`${hide.length} log${hide.length != 1 ? 's' : ''} hidden`], - }), - match( - opened, - atom((ctx) => - t.div({ - ...graphHiddenListStyles({}), - children: hide.map((log) => InspectGraphLog({ log })), - }), - ), - ), - ], - }) -} - -function InspectState() { - const target = t.div() - const inspector = new Inspector(target) - inspector.pending() - model.logSelected.onChange((ctx, log) => { - if (!log) return - return inspector.fulfilled(log.cache.state) - }) - return t.div([target]) -} - -const inspectStyles = styled('')` - display: flex; - gap: 0.25rem; -` - -const viewStyles = styled('')` - background: #fefefe; - font: var(--mono_fonts); - color: #111; - padding: 0.25rem; - border-radius: 0.25rem; - flex-grow: 1; -` - -const viewHorStyles = styled('')` - /* */ -` - -const viewHandleTopStyles = styled('')`` - -const viewHandleSideStyles = styled('')`` - -const viewContentStyles = styled('')` - overflow-y: scroll; -` - -const graphNodesStyles = styled('')` - display: flex; - flex-direction: column; - gap: 0.25rem; -` - -const graphLogStyles = styled('')` - cursor: pointer; - user-select: none; - display: flex; - align-items: center; - gap: 0.3rem; -` - -const graphLogMarkerStyles = styled('')` - border: 1px solid black; - display: inline-block; - width: 16px; - height: 16px; - border-radius: 50%; -` - -const graphHiddenStyles = styled('')` - display: flex; - flex-direction: column; - margin: 0.1rem 0; -` - -const graphHiddenLabelStyles = styled('')` - cursor: pointer; - user-select: none; - opacity: 0.6; - font-size: smaller; -` -const graphHiddenListStyles = styled('')` - padding: 0.1rem 1rem; - gap: 0.25rem; - display: flex; - flex-direction: column; -` diff --git a/packages/logger/src/rld.ts b/packages/logger/src/rld.ts deleted file mode 100644 index 2ea897499..000000000 --- a/packages/logger/src/rld.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { Ctx, action, atom } from '@reatom/core' -import { onConnect } from '@reatom/hooks' -import { match } from '@reatom/lens' -import styled from 'stylerun' -import * as model from './rld-model' -import { RldInspect } from './rld-inspect' -import { RldFilterMenu } from './rld-filter' -import { t } from './t' - -type Tab = (typeof Tabs)[number] -const Tabs = ['inspect', 'filters'] as const - -export function Rld({ app }: { app: Ctx }) { - const opened = atom(true, 'opened') - const currentTab = atom('inspect', 'currentTab') - - const jsx = atom((ctx) => - t.div({ - ...devtoolsStyles({}), - children: [ - match( - opened, - t.div({ - ...devtoolsContentStyles({}), - children: [ - t.div({ - ...tabsStyles({}), - children: Tabs.map((tab) => - t.button({ - ...tabStyles( - {}, - {}, - tab === ctx.spy(currentTab) ? 'active' : '', - ), - children: [tab], - onclick: action((ctx) => currentTab(ctx, tab)), - }), - ), - }), - atom( - (ctx) => - ({ - inspect: RldInspect(), - filters: RldFilterMenu(), - })[ctx.spy(currentTab)], - ), - t.button({ - ...buttonStyles({}), - children: ['Close Devtools'], - onclick: action((ctx) => opened(ctx, false)), - }), - ], - }), - t.button({ - ...buttonStyles({}), - children: ['Reatom Devtools'], - onclick: action((ctx) => opened(ctx, true)), - }), - ), - ], - }), - ) - - onConnect(jsx, (ctx) => { - const unsub = app.subscribe((logs) => - // execute in the microtask to prevent 'cause collision' error - queueMicrotask(() => model.cachesAdd(ctx, logs)), - ) - return unsub - }) - - return jsx -} - -const devtoolsStyles = styled('')` - position: absolute; - left: 1rem; - bottom: 1rem; - background: #171721; - color: #fff; - border-radius: 0.25rem; - --syntax_normal: #1b1e23; - --syntax_comment: #a9b0bc; - --syntax_number: #20a5ba; - --syntax_keyword: #c30771; - --syntax_atom: #10a778; - --syntax_string: #008ec4; - --syntax_error: #ffbedc; - --syntax_unknown_variable: #838383; - --syntax_known_variable: #005f87; - --syntax_matchbracket: #20bbfc; - --syntax_key: #6636b4; - --mono_fonts: 82%/1.5 Menlo, Consolas, monospace; -` - -const devtoolsContentStyles = styled('')` - padding: 0.25rem; - display: flex; - flex-direction: column; - gap: 0.5rem; -` - -export const buttonStyles = styled('')` - border: none; - font: inherit; - font-weight: bolder; - background: #24243a; - color: white; - border-radius: 0.25rem; - border: 1px solid #c5c6de88; - padding: 0.25rem; - width: 100%; -`.styled(':hover')` - background-color: #3B3B4E; -` - -const tabsStyles = styled('')` - margin: -0.25rem; - width: calc(100% + 0.5rem); - display: flex; -` - -const tabStyles = styled('')` - border: none; - font: inherit; - font-weight: bolder; - background: #24243a; - color: white; - padding: 0.25rem; - flex-grow: 1; -`.styled(':hover')` - background-color: #3B3B4E; -`.styled(':first-child')` - border-top-left-radius: 0.25rem; -`.styled(':last-child')` - border-top-right-radius: 0.25rem; -`.styled('.active')` - background-color: #f9364d; -`.styled('.active:hover')` - background-color: #d32e41; -`