diff --git a/apps/zui/src/core/state-object.ts b/apps/zui/src/core/state-object.ts new file mode 100644 index 0000000000..29d55d34eb --- /dev/null +++ b/apps/zui/src/core/state-object.ts @@ -0,0 +1,15 @@ +import {useState} from "react" + +export function useStateObject(init: T) { + const [state, setState] = useState(init) + + return { + ...state, + set: setState, + setItem: (key: string, value: any) => { + setState((prev) => ({...prev, [key]: value})) + }, + } +} + +export type StateObject = S & ReturnType diff --git a/apps/zui/src/core/view-handler.ts b/apps/zui/src/core/view-handler.ts new file mode 100644 index 0000000000..71815e2832 --- /dev/null +++ b/apps/zui/src/core/view-handler.ts @@ -0,0 +1,27 @@ +import {Dispatch, State, Store} from "src/js/state/types" +import {ipc} from "src/modules/bullet/view" +import {invoke} from "./invoke" + +type Selector = (state: State, ...args: any) => any + +export class ViewHandler { + static store: Store + static invoke = invoke + protected invoke = invoke + + protected get store() { + return ViewHandler.store + } + + protected dispatch(action: Parameters[0]) { + return this.store.dispatch(action) + } + + protected select(selector: T): ReturnType { + return selector(this.store.getState()) + } + + protected request(path: string, params?: object) { + return ipc.request(path, params) + } +} diff --git a/apps/zui/src/domain/editor/operations.ts b/apps/zui/src/domain/editor/operations.ts index 47d23eb6b3..587ed158fe 100644 --- a/apps/zui/src/domain/editor/operations.ts +++ b/apps/zui/src/domain/editor/operations.ts @@ -2,6 +2,10 @@ import {createOperation} from "src/core/operations" import {lake} from "src/zui" export const parse = createOperation("editor.parse", async (ctx, string) => { - const resp = await lake.client.compile(string) - return resp.toJS() + try { + const resp = await lake.client.compile(string) + return resp.toJS() + } catch (error) { + return {error: error.toString()} + } }) diff --git a/apps/zui/src/domain/editor/parse.test.ts b/apps/zui/src/domain/editor/parse.test.ts index c7e7a438ae..98658f935e 100644 --- a/apps/zui/src/domain/editor/parse.test.ts +++ b/apps/zui/src/domain/editor/parse.test.ts @@ -12,8 +12,7 @@ test("editor.parse", async () => { }) test("editor.parse error", async () => { - await expect(parse("from source | ;;;(")).rejects.toHaveProperty( - "error", - expect.stringContaining("error parsing Zed at column 15") - ) + await expect(parse("from source | ;;;(")).resolves.toEqual({ + error: expect.stringContaining("error parsing Zed at column 15"), + }) }) diff --git a/apps/zui/src/domain/session/handlers/queries.ts b/apps/zui/src/domain/session/handlers/queries.ts index 565ef6b5a5..cee18ac17f 100644 --- a/apps/zui/src/domain/session/handlers/queries.ts +++ b/apps/zui/src/domain/session/handlers/queries.ts @@ -32,18 +32,8 @@ export const resetQuery = createHandler("session.resetQuery", () => { session.navigate(session.snapshot) }) -const fetchAst = createHandler(async ({invoke}, string) => { - let tree - try { - tree = await invoke("editor.parse", string) - } catch (error) { - tree = {error} - } - return tree -}) - -export const fetchQueryInfo = createHandler(async (_, query: string) => { - const tree = await fetchAst(query) +export const fetchQueryInfo = createHandler(async ({invoke}, query: string) => { + const tree = await invoke("editor.parse", query) const ast = new ZedAst(tree, tree.error) return { isSummarized: ast.isSummarized, diff --git a/apps/zui/src/js/initializers/initialize.ts b/apps/zui/src/js/initializers/initialize.ts index 9683aa975a..76a9bc212a 100644 --- a/apps/zui/src/js/initializers/initialize.ts +++ b/apps/zui/src/js/initializers/initialize.ts @@ -21,6 +21,7 @@ import {createWaitForSelector} from "src/app/core/state/create-wait-for-selector import {initAsyncTasks} from "./init-async-tasks" import {Renderer} from "src/core/renderer" import {initDomainModels} from "./init-domain-models" +import {ViewHandler} from "src/core/view-handler" const getWindowId = () => { const params = new URLSearchParams(window.location.search) @@ -62,6 +63,7 @@ export default async function initialize( initDomainModels({ store, }) + ViewHandler.store = store setMenuContext({select: (fn) => fn(store.getState()), api}) initDebugGlobals(store, api) initAutosave(store) diff --git a/apps/zui/src/js/state/QueryInfo/selectors.ts b/apps/zui/src/js/state/QueryInfo/selectors.ts index a0565cac7d..ef08af7958 100644 --- a/apps/zui/src/js/state/QueryInfo/selectors.ts +++ b/apps/zui/src/js/state/QueryInfo/selectors.ts @@ -5,5 +5,6 @@ export const get = activeTabSelect((tab) => { return tab.queryInfo }) +export const getParseError = createSelector(get, (info) => info.error) export const getIsParsed = createSelector(get, (info) => info.isParsed) export const getIsSummarized = createSelector(get, (info) => info.isSummarized) diff --git a/apps/zui/src/modules/bullet/main/application.ts b/apps/zui/src/modules/bullet/main/application.ts new file mode 100644 index 0000000000..99afb1385e --- /dev/null +++ b/apps/zui/src/modules/bullet/main/application.ts @@ -0,0 +1,15 @@ +import {ipc} from "./ipc" + +class Application { + controllers: any[] = [] + + config(fn) { + return fn(this) + } + + boot() { + ipc.listen() + } +} + +export const BulletApplication = new Application() diff --git a/apps/zui/src/modules/bullet/main/index.ts b/apps/zui/src/modules/bullet/main/index.ts new file mode 100644 index 0000000000..36cc5cbf31 --- /dev/null +++ b/apps/zui/src/modules/bullet/main/index.ts @@ -0,0 +1 @@ +export * from "./application" diff --git a/apps/zui/src/modules/bullet/main/ipc.ts b/apps/zui/src/modules/bullet/main/ipc.ts new file mode 100644 index 0000000000..86198a08ed --- /dev/null +++ b/apps/zui/src/modules/bullet/main/ipc.ts @@ -0,0 +1,20 @@ +import {ipcMain} from "electron" +import {camelCase, capitalize} from "lodash" +import {BulletApplication} from "./application" + +class MainIpc { + listen() { + ipcMain.handle("bullet:view-request", (e, controllerAction, params) => { + const [shortName, action] = controllerAction.split("#") + const name = capitalize(camelCase(shortName)) + "Controller" + const Controller = BulletApplication.controllers[name] + if (!Controller) { + throw new Error("ControllerNotFound: " + controllerAction) + } + const instance = new Controller() + return instance[action](params) + }) + } +} + +export const ipc = new MainIpc() diff --git a/apps/zui/src/modules/bullet/view/index.ts b/apps/zui/src/modules/bullet/view/index.ts new file mode 100644 index 0000000000..6b4058e7bd --- /dev/null +++ b/apps/zui/src/modules/bullet/view/index.ts @@ -0,0 +1 @@ +export * from "./ipc" diff --git a/apps/zui/src/modules/bullet/view/ipc.ts b/apps/zui/src/modules/bullet/view/ipc.ts new file mode 100644 index 0000000000..72caaade67 --- /dev/null +++ b/apps/zui/src/modules/bullet/view/ipc.ts @@ -0,0 +1,7 @@ +export class ViewIpc { + request(path: string, params: any) { + return global.zui.invoke("bullet:view-request", path, params) + } +} + +export const ipc = new ViewIpc() diff --git a/apps/zui/src/views/application/index.tsx b/apps/zui/src/views/application/index.tsx index b8a8591492..ec6cf9eb85 100644 --- a/apps/zui/src/views/application/index.tsx +++ b/apps/zui/src/views/application/index.tsx @@ -10,10 +10,10 @@ import {useAppMenu} from "./use-app-menu" import {WelcomePage} from "src/views/welcome-page" import {useReleaseNotes} from "./use-release-notes" import {InitPool, Show} from "src/views/pool-page" -import {SessionRoute} from "src/views/session-page/route" import Head from "next/head" import {useTabId} from "src/app/core/hooks/use-tab-id" import {NoTabsPane} from "src/views/no-tabs-pane" +import {SessionPage} from "../session-page" function AppRoutes() { return ( @@ -27,7 +27,7 @@ function AppRoutes() { - + diff --git a/apps/zui/src/views/results-pane/context.tsx b/apps/zui/src/views/results-pane/context.tsx index e09ac14365..71951e2c46 100644 --- a/apps/zui/src/views/results-pane/context.tsx +++ b/apps/zui/src/views/results-pane/context.tsx @@ -7,6 +7,7 @@ import Results from "src/js/state/Results" import {RESULTS_QUERY} from "src/views/results-pane/run-results-query" import {useDataTransition} from "src/util/hooks/use-data-transition" import useResizeObserver from "use-resize-observer" +import QueryInfo from "src/js/state/QueryInfo" function useContextValue(parentRef: React.RefObject) { const rect = useResizeObserver({ref: parentRef}) @@ -15,12 +16,12 @@ function useContextValue(parentRef: React.RefObject) { const r = useResults(RESULTS_QUERY) const results = useDataTransition(r, r.data.length === 0 && fetching) const shapes = useMemo(() => Object.values(results.shapes), [results.shapes]) - + const parseError = useSelector(QueryInfo.getParseError) return { width: rect.width ?? 1000, height: rect.height ?? 1000, view: useSelector(Layout.getResultsView), - error: results.error, + error: parseError || results.error, values: results.data, shapes, isSingleShape: shapes.length === 1, diff --git a/apps/zui/src/views/session-page/handler.ts b/apps/zui/src/views/session-page/handler.ts new file mode 100644 index 0000000000..467a9e709d --- /dev/null +++ b/apps/zui/src/views/session-page/handler.ts @@ -0,0 +1,83 @@ +import {ViewHandler} from "src/core/view-handler" +import {Active} from "src/models/active" +import QueryInfo from "src/js/state/QueryInfo" +import Tabs from "src/js/state/Tabs" +import Notice from "src/js/state/Notice" +import Editor from "src/js/state/Editor" +import {startTransition} from "react" +import { + runResultsCount, + runResultsMain, +} from "../results-pane/run-results-query" +import Layout from "src/js/state/Layout" +import {runHistogramQuery} from "../histogram-pane/run-query" +import {fetchQueryInfo} from "src/domain/session/handlers" +import Current from "src/js/state/Current" +import Pools from "src/js/state/Pools" +import {syncPool} from "src/app/core/pools/sync-pool" + +type Props = { + locationKey: string +} + +export class SessionPageHandler extends ViewHandler { + constructor(public props: Props) { + super() + } + + load() { + this.reset() + this.setEditorValues() + this.fetchResults() + this.parseQueryText() + } + + private reset() { + this.dispatch(QueryInfo.reset()) + this.dispatch(Tabs.loaded(this.props.locationKey)) + this.dispatch(Notice.dismiss()) // This may not be needed any more + } + + private setEditorValues() { + const snapshot = Active.session.snapshot + // Give editor a chance to update by scheduling this update + setTimeout(() => { + this.dispatch(Editor.setValue(snapshot.attrs.value ?? "")) + this.dispatch(Editor.setPins(snapshot.attrs.pins || [])) + }) + } + + private fetchResults() { + startTransition(() => { + runResultsMain() + runResultsCount() + if (this.histogramVisible) runHistogramQuery() + }) + } + + private get histogramVisible() { + return this.select(Layout.getShowHistogram) + } + + private async parseQueryText() { + const {session} = Active + const lakeId = this.select(Current.getLakeId) + const program = this.select(Current.getQueryText) + const history = this.select(Current.getHistory) + + fetchQueryInfo(program).then((info) => { + const {poolName, error} = info + const pool = this.select(Pools.getByName(lakeId, poolName)) + + this.dispatch(QueryInfo.set({isParsed: true, ...info})) + this.invoke("updatePluginSessionOp", {poolName, program}) + if (pool && !pool.hasSpan()) { + this.dispatch(syncPool(pool.id, lakeId)) + } + + if (!error && history.action === "PUSH") { + session.pushHistory() + } + }) + } +} diff --git a/apps/zui/src/views/session-page/index.tsx b/apps/zui/src/views/session-page/index.tsx index 1edc4295fb..a2e77b2d40 100644 --- a/apps/zui/src/views/session-page/index.tsx +++ b/apps/zui/src/views/session-page/index.tsx @@ -1,11 +1,26 @@ +import {useLayoutEffect} from "react" import {Editor} from "./editor" import {Footer} from "./footer" import {Grid} from "./grid" import {Pins} from "./pins" import {Results} from "./results" import {Toolbar} from "./toolbar" +import {useSelector} from "react-redux" +import Current from "src/js/state/Current" +import Tab from "src/js/state/Tab" +import {SessionPageHandler} from "./handler" export function SessionPage() { + const locationKey = useSelector(Current.getLocation).key + const tabKey = useSelector(Tab.getLastLocationKey) + const handler = new SessionPageHandler({locationKey}) + + useLayoutEffect(() => { + // When you switch tabs, the location key changes, but you don't want to reload + const tabHasLoaded = tabKey === locationKey + if (!tabHasLoaded) handler.load() + }, [locationKey, tabKey]) + return ( diff --git a/apps/zui/src/views/session-page/loader.ts b/apps/zui/src/views/session-page/loader.ts deleted file mode 100644 index 458c35a08f..0000000000 --- a/apps/zui/src/views/session-page/loader.ts +++ /dev/null @@ -1,68 +0,0 @@ -import Current from "src/js/state/Current" -import Editor from "src/js/state/Editor" -import {startTransition} from "react" -import Notice from "src/js/state/Notice" -import Tabs from "src/js/state/Tabs" -import {Location} from "history" -import Pools from "src/js/state/Pools" -import {invoke} from "src/core/invoke" -import {runHistogramQuery} from "src/views/histogram-pane/run-query" -import { - runResultsCount, - runResultsMain, -} from "src/views/results-pane/run-results-query" -import Layout from "src/js/state/Layout" -import {syncPool} from "src/app/core/pools/sync-pool" -import {fetchQueryInfo} from "src/domain/session/handlers" -import QueryInfo from "src/js/state/QueryInfo" -import {createHandler} from "src/core/handlers" -import {Active} from "src/models/active" - -export const loadRoute = createHandler( - async ({select, dispatch}, location: Location) => { - const history = select(Current.getHistory) - const lakeId = select(Current.getLakeId) - const version = select(Current.getVersion) - const program = select(Current.getQueryText) - const histogramVisible = select(Layout.getShowHistogram) - - dispatch(QueryInfo.reset()) - dispatch(Tabs.loaded(location.key)) - dispatch(Notice.dismiss()) - - // Give editor a chance to update by scheduling this update - setTimeout(() => { - dispatch(Editor.setValue(version?.value ?? "")) - dispatch(Editor.setPins(version?.pins || [])) - }) - - startTransition(() => { - if (version) { - runResultsMain() - runResultsCount() - if (histogramVisible) runHistogramQuery() - } - }) - - // We parse the query text on the server. In order to minimize - // latency, we run the query first, then get the query info. - // If you need to wait for the query info, use the waitForSelector - // function and look for QueryInfo.getIsParsed to be true. - const {session} = Active - - fetchQueryInfo(program).then((info) => { - const {poolName, error} = info - const pool = select(Pools.getByName(lakeId, poolName)) - - dispatch(QueryInfo.set({isParsed: true, ...info})) - invoke("updatePluginSessionOp", {poolName, program}) - if (pool && !pool.hasSpan()) { - dispatch(syncPool(pool.id, lakeId)) - } - - if (!error && history.action === "PUSH") { - session.pushHistory() - } - }) - } -) diff --git a/apps/zui/src/views/session-page/route.tsx b/apps/zui/src/views/session-page/route.tsx deleted file mode 100644 index 5e39d55e87..0000000000 --- a/apps/zui/src/views/session-page/route.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import React, {useLayoutEffect} from "react" -import {useSelector} from "react-redux" -import Current from "src/js/state/Current" -import Tab from "src/js/state/Tab" -import {loadRoute} from "./loader" -import {SessionPage} from "." - -// If this is a nice pattern for routes, -// we could make it a generic component. - -export function SessionRoute() { - const location = useSelector(Current.getLocation) - const lastKey = useSelector(Tab.getLastLocationKey) - - useLayoutEffect(() => { - if (lastKey !== location.key) { - loadRoute(location) - } - }, [location.key, lastKey]) - - return -}