diff --git a/apps/zui/scripts/start.js b/apps/zui/scripts/start.js index 1e5314b506..2d90f55f4d 100644 --- a/apps/zui/scripts/start.js +++ b/apps/zui/scripts/start.js @@ -10,7 +10,7 @@ async function start() { const renderer = sub("yarn", `start:renderer`) await Promise.all([ main.waitForOutput(/watching for changes/), - renderer.waitForOutput(/compiled client and server successfully/), + renderer.waitForOutput(/started server on/), ]) log("Launching...") sub("yarn", `start:electron ${electronArgs}`).p.on("exit", () => { diff --git a/apps/zui/src/app/features/right-pane/history/history-item.tsx b/apps/zui/src/app/features/right-pane/history/history-item.tsx index 7606e748a6..89db93d39b 100644 --- a/apps/zui/src/app/features/right-pane/history/history-item.tsx +++ b/apps/zui/src/app/features/right-pane/history/history-item.tsx @@ -36,6 +36,7 @@ const BG = styled.div` const Text = styled.p` font-family: var(--mono-font); + font-size: var(--step--1); font-weight: 500; white-space: nowrap; overflow: hidden; diff --git a/apps/zui/src/app/features/sidebar/item.tsx b/apps/zui/src/app/features/sidebar/item.tsx index a5c48d1f96..95723f837f 100644 --- a/apps/zui/src/app/features/sidebar/item.tsx +++ b/apps/zui/src/app/features/sidebar/item.tsx @@ -68,7 +68,7 @@ const BG = styled.div` &[aria-selected="true"] { border-radius: 0; outline: none; - box-shadow: var(--shadow-small); + box-shadow: var(--shadow-s); background: var(--selected-bg); svg { diff --git a/apps/zui/src/app/features/sidebar/lake-picker.tsx b/apps/zui/src/app/features/sidebar/lake-picker.tsx index 6926eea01d..36d0948fb7 100644 --- a/apps/zui/src/app/features/sidebar/lake-picker.tsx +++ b/apps/zui/src/app/features/sidebar/lake-picker.tsx @@ -21,32 +21,12 @@ const LakeNameGroup = styled.div` border-radius: 6px; min-width: 0; position: relative; - + transition: all var(--dur-s); &:hover { - background: var(--sidebar-item-hover); + background: var(--emphasis-bg); } &:active { - background: var(--sidebar-item-active); - box-shadow: var(--sidebar-item-active-shadow); - } -` - -const NameColumn = styled.div` - overflow: hidden; - label { - display: block; - font-size: 15px; - font-weight: bold; - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; - } - - label:last-child { - font-size: 12px; - font-weight: normal; - font-family: var(--mono-font); - opacity: 0.5; + background: var(--emphasis-bg-more); } ` @@ -85,10 +65,10 @@ export default function LakePicker() { return ( dispatch(showLakeSelectMenu())}> - - - - +
+

{`${current?.name}`}

+

{`${current?.getAddress()}`}

+
) } diff --git a/apps/zui/src/app/features/sidebar/queries-section/empty.tsx b/apps/zui/src/app/features/sidebar/queries-section/empty.tsx deleted file mode 100644 index a9f8485dec..0000000000 --- a/apps/zui/src/app/features/sidebar/queries-section/empty.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import React from "react" -import {Icon} from "src/components/icon" -import EmptySection from "src/js/components/common/EmptySection" -import styled from "styled-components" - -export const Empty = styled(EmptySection).attrs({ - icon: , -})`` diff --git a/apps/zui/src/app/features/sidebar/queries-section/index.tsx b/apps/zui/src/app/features/sidebar/queries-section/index.tsx index f8fc789b34..aab06dfca0 100644 --- a/apps/zui/src/app/features/sidebar/queries-section/index.tsx +++ b/apps/zui/src/app/features/sidebar/queries-section/index.tsx @@ -19,6 +19,7 @@ export function QueriesSection() { } else { - return + return ( + + ) } } @@ -54,7 +56,9 @@ function RemoteQueriesTree({searchTerm}) { if (queries.length) { return } else { - return + return ( + + ) } } diff --git a/apps/zui/src/app/router/routes.ts b/apps/zui/src/app/router/routes.ts index aa16057a2a..5d38d39a5b 100644 --- a/apps/zui/src/app/router/routes.ts +++ b/apps/zui/src/app/router/routes.ts @@ -8,51 +8,53 @@ import {IconName} from "../../components/icon" */ export const root: Route = { + name: "root", path: "/", title: "Zui", } export const poolShow: Route = { + name: "poolShow", title: "", path: `/pools/:poolId`, icon: "pool", } export const query: Route = { + name: "querySession", title: "", path: `/queries/:queryId`, icon: "query", } + export const queryVersion: Route = { + name: "querySession", title: "", path: `${query.path}/versions/:version`, icon: "query", } -export const lakeReleaseNotes: Route = { +export const releaseNotes: Route = { + name: "releaseNotes", title: "Release Notes", path: `/release-notes`, icon: "doc_plain", } -export const releaseNotes: Route = { - title: "Release Notes", - path: "/release-notes", -} - export const welcome: Route = { + name: "welcome", title: "Welcome to Zui", path: "/welcome", icon: "zui", } type Route = { + name: string title: string path: string icon?: IconName } export const allRoutes: Route[] = [ - lakeReleaseNotes, poolShow, query, queryVersion, diff --git a/apps/zui/src/app/routes/app-wrapper/app-modals.tsx b/apps/zui/src/app/routes/app-wrapper/app-modals.tsx index 9a13b033c1..40c4bf9c08 100644 --- a/apps/zui/src/app/routes/app-wrapper/app-modals.tsx +++ b/apps/zui/src/app/routes/app-wrapper/app-modals.tsx @@ -4,19 +4,15 @@ import {Tooltip} from "src/components/tooltip" import ErrorNotice from "src/js/components/ErrorNotice" import HTMLContextMenu from "src/js/components/HTMLContextMenu" import {Modals} from "src/js/components/Modals" -import {PreferencesModal} from "src/views/preferences-modal" -import {LoadPane} from "src/views/load-pane" export function AppModals() { return ( <> - - ) } diff --git a/apps/zui/src/app/routes/app-wrapper/main-area.tsx b/apps/zui/src/app/routes/app-wrapper/main-area.tsx index a92117d60d..6c34d6dc62 100644 --- a/apps/zui/src/app/routes/app-wrapper/main-area.tsx +++ b/apps/zui/src/app/routes/app-wrapper/main-area.tsx @@ -18,7 +18,7 @@ const BG = styled.main` margin: 10px; margin-top: 0; border-radius: 6px; - box-shadow: var(--shadow-small); + box-shadow: var(--shadow-s); border: 1px solid var(--border-color); @media (prefers-color-scheme: light) { diff --git a/apps/zui/src/components/case.tsx b/apps/zui/src/components/case.tsx new file mode 100644 index 0000000000..23b142f71c --- /dev/null +++ b/apps/zui/src/components/case.tsx @@ -0,0 +1,7 @@ +export function Case(props: {if: boolean; true: any; false: any}) { + if (props.if) { + return props.true + } else { + return props.false + } +} diff --git a/apps/zui/src/components/dialog/index.tsx b/apps/zui/src/components/dialog/index.tsx index 0cd9fc1c6f..4de0d212fa 100644 --- a/apps/zui/src/components/dialog/index.tsx +++ b/apps/zui/src/components/dialog/index.tsx @@ -1,15 +1,17 @@ -import {HTMLAttributes, MouseEventHandler} from "react" +import {MouseEventHandler, forwardRef} from "react" import {useOpener} from "./use-opener" import {useOutsideClick} from "./use-outside-click" import useCallbackRef from "src/js/components/hooks/useCallbackRef" import {omit} from "lodash" -import {call} from "src/util/call" import {useFixedPosition} from "src/util/hooks/use-fixed-position" +import mergeRefs from "src/app/core/utils/merge-refs" +import useListener from "src/js/components/hooks/useListener" +import {call} from "src/util/call" export type DialogProps = { isOpen: boolean - onClose?: () => void - onCancel?: () => void + onClose?: (e: any) => any + onCancel?: (e: any) => any modal?: boolean onOutsideClick?: (e: globalThis.MouseEvent) => void onClick?: MouseEventHandler @@ -20,7 +22,7 @@ export type DialogProps = { dialogPoint?: string dialogMargin?: string keepOnScreen?: boolean -} & HTMLAttributes +} const nonHTMLProps: (keyof DialogProps)[] = [ "isOpen", @@ -34,10 +36,12 @@ const nonHTMLProps: (keyof DialogProps)[] = [ "keepOnScreen", ] -export function Dialog(props: DialogProps) { +export const Dialog = forwardRef(function Dialog(props: DialogProps, ref) { const [node, setNode] = useCallbackRef() useOpener(node, props) // Make sure we open it before positioning it useOutsideClick(node, props) + + // Get this out of here const style = useFixedPosition({ anchor: props.anchor, anchorPoint: props.anchorPoint, @@ -46,27 +50,23 @@ export function Dialog(props: DialogProps) { targetMargin: props.dialogMargin, }) - function onClose(e) { - e.preventDefault() - call(props.onClose) - } + // When you click escape, "cancel" is fired, then close + useListener(node, "cancel", (e) => { + call(props.onCancel, e) + }) - function onCancel(e) { - e.preventDefault() - call(props.onCancel) - call(props.onClose) - } + // When you call .close() "close" fires + useListener(node, "close", (e) => { + call(props.onClose, e) + }) return ( {props.children} ) -} +}) diff --git a/apps/zui/src/components/drag-anchor.tsx b/apps/zui/src/components/drag-anchor.tsx index d07c21e93a..d1c8cf92de 100644 --- a/apps/zui/src/components/drag-anchor.tsx +++ b/apps/zui/src/components/drag-anchor.tsx @@ -82,20 +82,37 @@ type Props = { export default class DragAnchor extends React.Component { private startX: number private startY: number + private cleanup = () => {} componentWillUnmount() { this.up() } down = (e: React.MouseEvent) => { + const body = document.body + const el = e.currentTarget this.startX = e.clientX this.startY = e.clientY - const body = document.body body.style.cursor = this.getCursor() body.style.userSelect = "none" body.classList.add("is-dragging") + el.classList.add("is-dragging") document.addEventListener("mousemove", this.move) document.addEventListener("mouseup", this.up) + + this.cleanup = () => { + document.removeEventListener("mousemove", this.move) + document.removeEventListener("mouseup", this.up) + if (body) { + body.style.cursor = "" + body.style.userSelect = "" + body.classList.remove("is-dragging") + } + if (el) { + el.classList.remove("is-dragging") + } + } + call(this.props.onStart, e) } @@ -106,14 +123,7 @@ export default class DragAnchor extends React.Component { } up = () => { - const body = document.body - if (body) { - body.style.cursor = "" - body.style.userSelect = "" - body.classList.remove("is-dragging") - document.removeEventListener("mousemove", this.move) - document.removeEventListener("mouseup", this.up) - } + this.cleanup() call(this.props.onEnd) } diff --git a/apps/zui/src/components/forms.module.css b/apps/zui/src/components/forms.module.css index 9e4e5b344c..8a187facb5 100644 --- a/apps/zui/src/components/forms.module.css +++ b/apps/zui/src/components/forms.module.css @@ -1,27 +1,19 @@ -.form label { - display: block; - font-weight: bold; - padding-left: var(--form-border-radius); -} - .form input, .form textarea, .form select { display: block; width: 100%; max-width: 100%; - overflow: hidden; - height: var(--form-height); border-radius: var(--form-border-radius); background: var(--form-bg-color); border: var(--form-border); line-height: var(--form-line-height); - padding: var(--form-padding); + padding: var(--s-2); } -.form input:focus-visible { - outline: none; - border-color: var(--primary-color); +.form select { + width: auto; + padding-inline-end: var(--s3); } .form input:disabled { @@ -156,16 +148,42 @@ } .form .radioInput { - display: grid; - grid-template-columns: 1.1em auto; + display: inline-flex; align-items: center; - padding-top: 0; - padding: var(--form-padding); - height: 1.6em; + cursor: pointer; + padding-inline: var(--s-4); + border-radius: var(--form-border-radius); + transition: all 200ms; +} + +.form .radioInput:hover:has(input:not(:checked)) { + background: var(--form-bg-color); +} + +.form .radioInput:hover label { + color: var(--fg-color); +} + +.form .radioInput input { + min-height: auto; + height: var(--s1); + width: var(--s1); + cursor: pointer; } .form .radioInput label { - font-weight: 400; + font-size: var(--s0); + text-transform: none; + color: var(--fg-color-less); + letter-spacing: 0; + font-weight: 700; + cursor: pointer; + user-select: none; + padding: var(--s-2); +} + +.form .radioInput:has(input:checked) label { + color: var(--fg-color); } .form .submission { @@ -183,8 +201,3 @@ flex-direction: column; gap: 10px; } - -.form input[readonly] { - border: none; - padding: 0 var(--form-border-radius); -} diff --git a/apps/zui/src/components/full-modal.tsx b/apps/zui/src/components/full-modal.tsx new file mode 100644 index 0000000000..2aa6602f54 --- /dev/null +++ b/apps/zui/src/components/full-modal.tsx @@ -0,0 +1,43 @@ +import {Ref, forwardRef} from "react" +import {useDialog} from "./use-dialog" +import {useDebut} from "src/modules/debut/react" +import {hideModal} from "src/domain/window/handlers" + +export function useFullModal() { + const debut = useDebut("full-modal") + const dialog = useDialog({ + onMount: async () => { + dialog.showModal() + debut.enter() + }, + beforeClose: () => debut.exit(), + onClose: () => hideModal(), + }) + return dialog +} + +type Props = { + children: any +} + +export const FullModal = forwardRef(function FullModal( + props: Props, + ref: Ref +) { + return ( + + {props.children} + + ) +}) diff --git a/apps/zui/src/components/icon-button.tsx b/apps/zui/src/components/icon-button.tsx index b541183a1a..df87f38827 100644 --- a/apps/zui/src/components/icon-button.tsx +++ b/apps/zui/src/components/icon-button.tsx @@ -49,7 +49,6 @@ const BG = styled.button` padding: 0 8px; border: 1px solid var(--border-color-more); font-weight: 500; - font-size: 14px; height: 28px; } ` diff --git a/apps/zui/src/components/modal.tsx b/apps/zui/src/components/modal.tsx deleted file mode 100644 index 894db0485a..0000000000 --- a/apps/zui/src/components/modal.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import styles from "./modals.module.css" -import {Debut, useDebut} from "src/components/debut" -import {Dialog} from "src/components/dialog" - -export function Modal(props: {children: any; onClose: () => any}) { - const debut = useDebut({afterExit: props.onClose}) - - return ( - - debut.exit()} - dialogPoint="center center" - isOpen={true} - className={styles.modal} - modal - > - {props.children} - - - ) -} diff --git a/apps/zui/src/components/modals.module.css b/apps/zui/src/components/modals.module.css deleted file mode 100644 index 55c4f45e58..0000000000 --- a/apps/zui/src/components/modals.module.css +++ /dev/null @@ -1,51 +0,0 @@ -.modal { - background: var(--bg-color); - box-shadow: 0 20px 50px 10px rgb(0 0 0 /0.25); - border-radius: 8px; - border: none; - padding: 0; - will-change: opacity, transform; - color: var(--fg-color); -} - -.modal::backdrop { - background-color: black; - opacity: 0.25; - will-change: opacity; -} - -@media (prefers-color-scheme: dark) { - .modal::backdrop { - opacity: 0.8; - } - - .modal { - border: 1px solid var(--border-color); - } -} - -.modal .form { - padding: 0 30px 30px 30px; - width: 360px; -} - -.modal .fields { - display: flex; - flex-direction: column; - gap: 18px; -} - -.modal .submission { - margin-top: calc(var(--form-field-gap) * 4); -} - -.modal .title { - text-align: center; - margin: 20px 0 30px 0; -} - -.modal .pre { - overflow-x: visible; - overflow-y: visible; - padding: 12px var(--form-border-radius); -} diff --git a/apps/zui/src/components/popover-modal.tsx b/apps/zui/src/components/popover-modal.tsx new file mode 100644 index 0000000000..df12d6c18b --- /dev/null +++ b/apps/zui/src/components/popover-modal.tsx @@ -0,0 +1,48 @@ +import {CSSProperties, Ref, forwardRef} from "react" +import {hideModal} from "src/domain/window/handlers" +import {useDialog} from "./use-dialog" +import {useDebut} from "src/modules/debut/react" +import classNames from "classnames" + +export function usePopoverModal() { + const debut = useDebut("popover") + const dialog = useDialog({ + onMount: async () => { + dialog.showModal() + debut.enter() + }, + beforeClose: () => debut.exit(), + onClose: () => hideModal(), + }) + return dialog +} + +type Props = { + children: any + className?: string + style?: CSSProperties +} + +export const PopoverModal = forwardRef(function PopoverModal( + props: Props, + ref: Ref +) { + return ( + +
+ {props.children} +
+
+ ) +}) diff --git a/apps/zui/src/components/show.tsx b/apps/zui/src/components/show.tsx new file mode 100644 index 0000000000..d7b38605e8 --- /dev/null +++ b/apps/zui/src/components/show.tsx @@ -0,0 +1,18 @@ +import {useEffect, useState} from "react" + +export function Show(props: {when: boolean; children: any; delay?: number}) { + const [show, setShow] = useState(props.when) + useEffect(() => { + let id = null + if (props.when !== show) { + id = setTimeout(() => { + setShow(props.when) + }, props.delay) + } + return () => { + clearTimeout(id) + } + }, [props.when, props.delay, show]) + if (show) return props.children + else return null +} diff --git a/apps/zui/src/components/toolbar-tabs.module.css b/apps/zui/src/components/toolbar-tabs.module.css index 41c18a2a70..c57749261d 100644 --- a/apps/zui/src/components/toolbar-tabs.module.css +++ b/apps/zui/src/components/toolbar-tabs.module.css @@ -1,7 +1,6 @@ .tabs { - height: 26px; - display: flex; - align-items: center; + min-height: 26px; + display: inline-flex; position: relative; background: var(--chrome-color); border-radius: 6px; @@ -12,7 +11,7 @@ align-items: center; min-width: 0; padding: 2px; - height: 100%; + min-height: 100%; gap: 0.25rem; } diff --git a/apps/zui/src/components/toolbar-tabs.tsx b/apps/zui/src/components/toolbar-tabs.tsx index 440f265fef..fbe941542d 100644 --- a/apps/zui/src/components/toolbar-tabs.tsx +++ b/apps/zui/src/components/toolbar-tabs.tsx @@ -1,13 +1,43 @@ -import React, {useLayoutEffect, useRef, useState} from "react" +import React, { + CSSProperties, + Fragment, + useLayoutEffect, + useRef, + useState, +} from "react" import {MenuItem} from "src/core/menu" import styles from "./toolbar-tabs.module.css" import {Icon} from "src/components/icon" import {call} from "src/util/call" +function isScaled(el) { + const diff = el.clientWidth - el.getBoundingClientRect().width + return Math.floor(Math.abs(diff)) !== 0 +} + +let time = null +function startTimer() { + if (time === null) time = new Date() +} + +function cancelTimer() { + time = null +} + +function checkTimer() { + const ellapsed = new Date().getTime() - time.getTime() + console.log(ellapsed) + if (ellapsed > 10 * 1000) { + throw new Error("Expected Scaling to Be Finished After 10 Seconds") + } +} + export function ToolbarTabs(props: { onlyIcon?: boolean labelClassName?: string + name: string options: MenuItem[] + style?: CSSProperties }) { const changeCount = useRef(0) const ref = useRef() @@ -17,6 +47,16 @@ export function ToolbarTabs(props: { function run() { const el = ref.current if (el) { + if (isScaled(el)) { + // Warn the developer if this remains scaled for longer than 10s + startTimer() + checkTimer() + // We're being scaled, run this again after the transition completes + requestAnimationFrame(run) + } else { + cancelTimer() + } + const parent = el.getBoundingClientRect() const pressed = el.querySelector(`[aria-pressed="true"]`) if (pressed) { @@ -31,27 +71,38 @@ export function ToolbarTabs(props: { useLayoutEffect(run, [pressedIndex, props.onlyIcon]) return ( -
+
{ const node = findAncestor(e.target, needsTooltip) if (node) set(node) - else debut.exit() + else if (!debut.isExiting) debut.exit() }) const style = useFixedPosition({ diff --git a/apps/zui/src/components/use-dialog.ts b/apps/zui/src/components/use-dialog.ts new file mode 100644 index 0000000000..b212887e65 --- /dev/null +++ b/apps/zui/src/components/use-dialog.ts @@ -0,0 +1,52 @@ +import {useCallback, useLayoutEffect, useRef} from "react" +import {call} from "src/util/call" +import {useDocListener} from "src/util/hooks/use-doc-listener" +import {useRefListener} from "src/util/hooks/use-ref-listener" + +type Options = { + onMount?: () => any + beforeClose?: () => any + onClose?: (e: CloseEvent) => any + onCancel?: () => any +} + +export function useDialog(opts: Options = {}) { + const ref = useRef() + + async function close() { + const result = await call(opts.beforeClose) + if (result === false) return + ref.current?.close() + } + + useLayoutEffect(() => { + call(opts.onMount) + }, []) + + useRefListener( + ref, + "close", + useCallback((e) => call(opts.onClose, e), [opts.onClose]) + ) + + useDocListener( + "keydown", + useCallback((e: any) => { + // The "cancel" does not fire when the dialog is not focused + // The "keydown" event also doesn't fire if the dialog is not focused + // Listening for the escape key is more reliable + if (e.key === "Escape") { + e.preventDefault() + call(opts.onCancel) + close() + } + }, []) + ) + + return { + ref, + close, + showModal: () => ref.current?.showModal(), + show: () => ref.current?.show(), + } +} diff --git a/apps/zui/src/core/loader/types.ts b/apps/zui/src/core/loader/types.ts deleted file mode 100644 index 797f8d7a33..0000000000 --- a/apps/zui/src/core/loader/types.ts +++ /dev/null @@ -1,19 +0,0 @@ -import {LoadFormat} from "@brimdata/zed-js" - -export type LoadOptions = { - windowId: string - lakeId: string - poolId: string - branch: string - files: string[] - shaper: string - format?: LoadFormat - author: string - body: string -} - -export interface Loader { - when(): PromiseLike | boolean - run(): PromiseLike | void - rollback?(): PromiseLike | void -} diff --git a/apps/zui/src/css/_about-window.scss b/apps/zui/src/css/_about-window.scss index 6746e3eeca..335d29becb 100644 --- a/apps/zui/src/css/_about-window.scss +++ b/apps/zui/src/css/_about-window.scss @@ -16,16 +16,19 @@ margin-bottom: 12px; } + .about-grid { + display: grid; + grid-template-columns: auto auto; + column-gap: var(--s1); + row-gap: var(--s-2); + } + .about-content { display: flex; flex-direction: column; padding-top: 24px; @include label-normal; - p { - margin: 0; - } - a { color: var(--primary-color); cursor: pointer; @@ -36,13 +39,6 @@ padding: 3px; } - label { - margin-right: 12px; - @include label-bold; - user-select: none; - width: 60px; - } - hr { border: none; box-shadow: 0 0 0 0.5px var(--border-color); diff --git a/apps/zui/src/css/_blocks.scss b/apps/zui/src/css/_blocks.scss index f71a1e52fa..f28d61b060 100644 --- a/apps/zui/src/css/_blocks.scss +++ b/apps/zui/src/css/_blocks.scss @@ -1,11 +1,37 @@ .field { &>*+* { - margin-block-start: var(--s-3); + margin-block-start: var(--space-2xs); } } .field-stack { &>*+* { - margin-block-start: var(--s1); + margin-block-start: var(--space-s); + } +} + +.modal { + background: var(--bg-color); + box-shadow: 0 20px 50px 10px rgb(0 0 0 /0.25); + border-radius: 8px; + border: none; + padding: 0; + will-change: opacity, transform; + color: var(--fg-color); +} + +.modal::backdrop { + background-color: black; + opacity: 0.25; + will-change: opacity; +} + +@media (prefers-color-scheme: dark) { + .modal::backdrop { + opacity: 0.8; + } + + .modal { + border: 1px solid var(--border-color); } } diff --git a/apps/zui/src/css/_global.scss b/apps/zui/src/css/_global.scss index e87eeefb53..f35091db39 100644 --- a/apps/zui/src/css/_global.scss +++ b/apps/zui/src/css/_global.scss @@ -9,6 +9,12 @@ margin: 0; } +*:focus-visible { + outline-offset: 4px; + outline-color: var(--primary-color); + outline-width: 2px; +} + html, body { height: 100%; @@ -17,16 +23,17 @@ body { body { line-height: 1.5; + font-size: var(--step-0); -webkit-font-smoothing: antialiased; font-family: var(--body-font); color: var(--fg-color); - font-size: 14px; &:not(.is-mac) { background-color: var(--window-color); } } + input, button, textarea, @@ -45,6 +52,24 @@ h6 { overflow-wrap: break-word; } +h4 { + font-size: var(--step-1); +} + +h3 { + font-size: var(--step-2); +} + +h2 { + font-size: var(--step-3); +} + +h1 { + font-size: var(--step-4); +} + + + pre, code { border-radius: 3px; @@ -103,3 +128,34 @@ dt { dd { font-family: var(--mono-font); } + +.is-dragging { + user-select: none; +} + +label { + display: block; + font-weight: bold; + color: var(--fg-color-less); + opacity: 0.9; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.8px; + font-size: var(--step--1); +} + +input { + font-size: inherit; +} + +dialog { + color: var(--fg-color); + max-height: none; + max-width: none; + border: none; + padding: 0; + + &::backdrop { + background: transparent; + } +} diff --git a/apps/zui/src/css/_histogram-tooltip.scss b/apps/zui/src/css/_histogram-tooltip.scss index 10c19033a2..593dfcc655 100644 --- a/apps/zui/src/css/_histogram-tooltip.scss +++ b/apps/zui/src/css/_histogram-tooltip.scss @@ -12,7 +12,7 @@ pointer-events: none; color: var(--fg-color); background: var(--bg-color); - box-shadow: var(--shadow-small); + box-shadow: var(--shadow-s); font-family: var(--mono-font); font-size: 11px; border-radius: 8px; diff --git a/apps/zui/src/css/_layouts.scss b/apps/zui/src/css/_layouts.scss index 7d83ba628d..eb57894726 100644 --- a/apps/zui/src/css/_layouts.scss +++ b/apps/zui/src/css/_layouts.scss @@ -115,6 +115,10 @@ margin-block-start: var(--s8); } +.box-s { + padding: var(--s1); +} + .box-1 { padding: var(--s1); } @@ -126,3 +130,10 @@ .flex { display: flex; } + +.with-popover { + display: flex; + align-items: flex-start; + justify-content: center; + padding-block-start: 10vh; +} diff --git a/apps/zui/src/css/_list-item.scss b/apps/zui/src/css/_list-item.scss index 7a04dd5c91..d8916a1b75 100644 --- a/apps/zui/src/css/_list-item.scss +++ b/apps/zui/src/css/_list-item.scss @@ -28,7 +28,6 @@ width: 100%; height: 100%; border-radius: 6px; - font-size: 15px; &:hover:not(.dragging) { background: rgb(0 0 0 / 0.03); diff --git a/apps/zui/src/css/_progress-indicator.scss b/apps/zui/src/css/_progress-indicator.scss index f46e662237..11caba4eb0 100644 --- a/apps/zui/src/css/_progress-indicator.scss +++ b/apps/zui/src/css/_progress-indicator.scss @@ -2,17 +2,6 @@ display: flex; align-items: center; width: 100%; - - .close-button { - margin-left: 8px; - width: 12px; - height: 12px; - - svg { - width: 8px; - height: 8px; - } - } } .progress-track { @@ -32,48 +21,25 @@ transition: width 1000ms; } -@keyframes fake-loading { - from { - width: 5%; - } - - 10% { - width: 8%; - } - - 20% { - width: 30%; - } - - 30% { - width: 32%; - } - - 40% { - width: 38%; - } - - 50% { - width: 45%; - } - - 60% { - width: 55%; - } - 70% { - width: 89%; - } +.progress-indeterminate { + width: 30%; + animation-name: indeterminate; + animation-duration: 2s; + animation-iteration-count: infinite; + animation-timing-function: ease-in-out; + box-sizing: content-box; + animation-direction: alternate; - 80% { - width: 90%; - } + transform: translateX(-80%); +} - 90% { - width: 98%; +@keyframes indeterminate { + from { + transform: translateX(-80%); } to { - width: 100%; + transform: translateX(310%); } } diff --git a/apps/zui/src/css/_transitions.scss b/apps/zui/src/css/_transitions.scss new file mode 100644 index 0000000000..be27d197da --- /dev/null +++ b/apps/zui/src/css/_transitions.scss @@ -0,0 +1,190 @@ +/** + * Overlay content with transparent bg-color + */ +.overlay--before-enter { + background-color: rgba(0, 0, 0, 0) +} + +.overlay--enter { + transition: background-color 300ms; + background-color: rgba(0, 0, 0, 0.4); +} + +.overlay--after-enter { + background-color: rgba(0, 0, 0, 0.4); +} + +.overlay--before-exit { + background-color: rgba(0, 0, 0, 0.4); +} + +.overlay--exit { + transition: background-color 300ms; + background-color: rgba(0, 0, 0, 0); +} + +.overlay--after-exit { + background-color: rgba(0, 0, 0, 0); +} + +/** + * Fade in content with opacity + */ +.fade { + --dur: 300ms; + --ease: ease-out; + --enter-delay: 0s; + --exit-delay: 0s; +} + +.fade--before-enter { + opacity: 0; +} + +.fade--enter { + transition: all var(--dur); + transition-delay: var(--enter-delay); + opacity: 1; +} + +.fade--after-enter { + opacity: 1; +} + +.fade--before-exit { + opacity: 1; +} + +.fade--exit { + transition: all var(--dur); + transition-delay: var(--exit-delay); + opacity: 0; +} + +.fade--after-exit { + opacity: 0; +} + +/** + * Modal Transitions + */ + +.drop-in { + --scale: 0.9; + --t-start: translateY(-100px) scale(1); + transition-timing-function: var(--emphasis-easing); + transition-duration: 800ms; + transition-property: none; +} + +.drop-in--before-enter { + transform: var(--t-start); + opacity: 0; +} + +.drop-in--enter { + transition-property: all; + opacity: 1; +} + +.drop-in--exit { + transition-property: all; + transition-duration: 300ms; + transform: var(--t-start); + opacity: 0; +} + +.drop-in--after-exit { + transform: var(--t-start); + opacity: 0; +} + +/* Shrink In */ + +.shrink-in { + --dur: 500ms; + --ease: var(--pop-easing); + --enter-delay: 0s; + --exit-delay: 0s; + --scale-from: 1.5; + --scale-to: 1; + --fade-from: 0; + --fade-to: 1; +} + +.shrink-in--before-enter { + transform: scale(var(--scale-from)); + opacity: var(--fade-from); +} + +.shrink-in--enter { + transition: all var(--dur) var(--ease) var(--enter-delay); + transform: scale(var(--scale-to)); + opacity: var(--fade-to); +} + +.shrink-in--after-enter { + transform: scale(var(--scale-to)); + opacity: var(--fade-to); +} + +.shrink-in--before-exit { + transform: scale(var(--scale-to)); + opacity: var(--fade-to); +} + +.shrink-in--exit { + transition: all var(--dur) var(--ease) var(--exit-delay); + transform: scale(var(--scale-from)); + opacity: var(--fade-from); +} + +.shrink-in--after-exit { + transform: scale(var(--scale-from)); + opacity: var(--fade-from); +} + +/* Shrink Out */ + +.shrink-out { + --dur: var(--dur-l); + --ease: ease-out; + --enter-delay: 0s; + --exit-delay: 0s; + --scale-from: 1; + --scale-to: 0.5; + --fade-from: 1; + --fade-to: 0; +} + +.shrink-out--before-enter { + transform: scale(var(--scale-from)); + opacity: var(--fade-from); +} + +.shrink-out--enter { + transition: all var(--dur) var(--ease) var(--enter-delay); + transform: scale(var(--scale-to)); + opacity: var(--fade-to); +} + +.shrink-out--after-enter { + transform: scale(var(--scale-to)); + opacity: var(--fade-to); +} + +.shrink-out--before-exit { + transform: scale(var(--scale-to)); + opacity: var(--fade-to); +} + +.shrink-out--exit { + transition: all var(--dur) var(--ease) var(--exit-delay); + transform: scale(var(--scale-from)); + opacity: var(--fade-from); +} + +.shrink-out--after-exit { + transform: scale(var(--scale-from)); + opacity: var(--fade-from); +} diff --git a/apps/zui/src/css/_utilities.scss b/apps/zui/src/css/_utilities.scss index dfd80adc07..97bda19d89 100644 --- a/apps/zui/src/css/_utilities.scss +++ b/apps/zui/src/css/_utilities.scss @@ -14,14 +14,35 @@ align-items: center; } +.align\:start { + align-items: flex-start; +} + .width\:viewport { width: 100vw; } +.width\:full { + width: 100%; +} + +.width\:fit { + width: fit-content; +} + +.width\:max-content { + width: max-content; +} + .height\:viewport { height: 100vh; } +.size\:viewport { + width: 100vw; + height: 100vh; +} + .z\:1 { z-index: 1; } @@ -30,7 +51,8 @@ z-index: 2; } -.bg\:bg { + +.bg\:normal { background-color: var(--bg-color); } @@ -46,6 +68,10 @@ inline-size: fit-content; } +.max-width\:fit { + max-inline-size: fit-content; +} + .max-height\:viewport { max-block-size: 100vh; } @@ -70,6 +96,72 @@ border-bottom: 1px solid var(--border-color); } +.border\:solid { + border: 1px solid var(--border-color); +} + .min-height\:full { min-height: 100%; } + +.display\:none { + display: none; +} + +.shadow\:s { + box-shadow: var(--shadow-s); +} + +.shadow\:m { + box-shadow: var(--shadow-m); +} + +.shadow\:l { + box-shadow: var(--shadow-l); +} + +.radius\:s { + border-radius: var(--radius-s); +} + +.radius\:m { + border-radius: var(--radius-m); +} + +.radius\:l { + border-radius: var(--radius-l); +} + +.overflow\:ellipsis { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; +} + +.overflow\:hidden { + overflow: hidden; +} + +.font\:mono { + font-family: var(--mono-font); +} + +.color\:fg-less { + color: var(--fg-color-less); +} + +.font-size\:-2 { + font-size: var(--step--2); +} + +.font-size\:-1 { + font-size: var(--step--1); +} + +.font-size\:0 { + font-size: var(--step-0); +} + +.weight\:bold { + font-weight: bold; +} diff --git a/apps/zui/src/css/main.scss b/apps/zui/src/css/main.scss index 49a0fb8b08..13ec08df37 100644 --- a/apps/zui/src/css/main.scss +++ b/apps/zui/src/css/main.scss @@ -77,3 +77,4 @@ @import "layouts"; @import "blocks"; @import "utilities"; +@import "transitions"; diff --git a/apps/zui/src/css/settings/_colors.scss b/apps/zui/src/css/settings/_colors.scss index 66cfeac140..65d83967a3 100644 --- a/apps/zui/src/css/settings/_colors.scss +++ b/apps/zui/src/css/settings/_colors.scss @@ -1,3 +1,40 @@ +/* @link https://utopia.fyi/type/calculator?c=320,13,1.067,1240,15,1.125,5,2,&s=0.75|0.5|0.25,1.5|2|3|4|6,s-l&g=s,l,xl,12 */ + +:root { + --step--2: clamp(0.7137rem, 0.7042rem + 0.0471vi, 0.7407rem); + --step--1: clamp(0.7615rem, 0.7365rem + 0.125vi, 0.8333rem); + --step-0: clamp(0.8125rem, 0.769rem + 0.2174vi, 0.9375rem); + --step-1: clamp(0.8669rem, 0.8016rem + 0.3265vi, 1.0547rem); + --step-2: clamp(0.925rem, 0.8341rem + 0.4548vi, 1.1865rem); + --step-3: clamp(0.987rem, 0.866rem + 0.6049vi, 1.3348rem); + --step-4: clamp(1.0531rem, 0.8971rem + 0.7801vi, 1.5017rem); + --step-5: clamp(1.1237rem, 0.9269rem + 0.9839vi, 1.6894rem); +} + +/* @link https://utopia.fyi/space/calculator?c=320,14,1.2,1240,16,1.25,5,2,&s=0.75|0.5|0.25,1.5|2|3|4|6,&g=s,l,xl,12 */ + +:root { + --space-3xs: clamp(0.25rem, 0.25rem + 0vi, 0.25rem); + --space-2xs: clamp(0.4375rem, 0.4158rem + 0.1087vi, 0.5rem); + --space-xs: clamp(0.6875rem, 0.6658rem + 0.1087vi, 0.75rem); + --space-s: clamp(0.875rem, 0.8315rem + 0.2174vi, 1rem); + --space-m: clamp(1.3125rem, 1.2473rem + 0.3261vi, 1.5rem); + --space-l: clamp(1.75rem, 1.663rem + 0.4348vi, 2rem); + --space-xl: clamp(2.625rem, 2.4946rem + 0.6522vi, 3rem); + --space-2xl: clamp(3.5rem, 3.3261rem + 0.8696vi, 4rem); + --space-3xl: clamp(5.25rem, 4.9891rem + 1.3043vi, 6rem); + + /* One-up pairs */ + --space-3xs-2xs: clamp(0.25rem, 0.163rem + 0.4348vi, 0.5rem); + --space-2xs-xs: clamp(0.4375rem, 0.3288rem + 0.5435vi, 0.75rem); + --space-xs-s: clamp(0.6875rem, 0.5788rem + 0.5435vi, 1rem); + --space-s-m: clamp(0.875rem, 0.6576rem + 1.087vi, 1.5rem); + --space-m-l: clamp(1.3125rem, 1.0734rem + 1.1957vi, 2rem); + --space-l-xl: clamp(1.75rem, 1.3152rem + 2.1739vi, 3rem); + --space-xl-2xl: clamp(2.625rem, 2.1467rem + 2.3913vi, 4rem); + --space-2xl-3xl: clamp(3.5rem, 2.6304rem + 4.3478vi, 6rem); +} + /** * Constants */ @@ -14,15 +51,29 @@ --form-line-height: 20px; --form-border-radius: 6px; --form-padding: 3px 10px; - --form-height: 2rem; + --form-height: var(--s3); --form-field-gap: 12px; /* Easings */ --pop-easing: cubic-bezier(0.16, 1, 0.3, 1); + --spring-easing: linear(0, 0.0022, 0.0087 1.01%, 0.0346 2.07%, 0.0782 3.2%, 0.1407 4.43%, + 0.2809 6.65%, 0.7229 12.91%, 0.9383 16.72%, 1.0168, 1.0774 20.43%, + 1.1213 22.33%, 1.1493 24.31%, 1.159 25.6%, 1.1629 26.95%, 1.1611 28.38%, + 1.1536 29.93%, 1.1289 32.78%, 1.0506 39.61%, 1.0168 43.09%, 0.9906 46.97%, + 0.9766 51%, 0.9735 53.84%, 0.9749 57.06%, 0.9966 69.83%, 1.0033 76.88%, + 1.0042 83.81%, 1); + --emphasis-easing: linear(0, 0.0025 1.75%, 0.0099 3.55%, 0.0341 6.4%, 0.0738 9.06%, 0.1281 11.41%, + 0.194 13.37%, 0.2714 14.97%, 0.344 16.05%, 0.5443 18.34%, 0.6069 19.39%, + 0.6604 20.61%, 0.7167 22.4%, 0.7653 24.59%, 0.8083 27.28%, 0.8448 30.42%, + 0.8833 35.06%, 0.9155 40.62%, 0.9421 47.24%, 0.9633 54.99%, 0.9795 64.01%, + 0.9909 74.43%, 0.9977 86.37%, 1); + --dur-s: 150ms; + --dur-m: 300ms; + --dur-l: 500ms; + --quick: 150ms; --medium: 300ms; --slow: 450ms; - --measure: 45ch; } @@ -36,10 +87,12 @@ --window-color: #F3F3F3; + @media (prefers-color-scheme: dark) { --fg-color: white; --fg-color-less: rgba(255, 255, 255, 0.7); --bg-color: hsl(44deg 8% 10%); + --backdrop-color: rgba(0, 0, 0, 0.5); --window-color: #303231; } } @@ -122,10 +175,9 @@ :root { --editor-background: white; --input-background: hsl(240, 15%, 92%); - --form-bg-color: white; + --form-bg-color: hsl(0 0% 93%); --form-bg-color-dark: #f8f8f8; --form-bg-color-darker: #e6e6e6; - --form-border: 1px solid var(--border-color); @media (prefers-color-scheme: dark) { --form-bg-color: var(--emphasis-bg); @@ -138,11 +190,21 @@ * Shadows */ :root { - --shadow-small: 0px 0px 3px 0px rgb(0 0 0 / 30%); - --shadow-medium: 0px 3px 3px 6px rgb(0 0 0 / 30%); + --shadow-s: 0px 0px 3px 0px rgb(0 0 0 / 30%); + --shadow-m: 0px 3px 3px 6px rgb(0 0 0 / 30%); + --shadow-l: 12px 12px 48px rgba(0, 0, 0, 0.45); } +/** + * Radius + */ +:root { + --radius-s: 3px; + --radius-m: 6px; + --radius-l: 12px; +} + /** * Selection @@ -167,3 +229,14 @@ --table-stripe-bg: rgba(255, 255, 255, 0.01); } } + +/** + * Backdrops + */ +*::backdrop { + --backdrop-color: rgba(0, 0, 0, 0.6); + + @media (prefers-color-scheme: dark) { + --backdrop-color: rgba(0, 0, 0, 0.7); + } +} diff --git a/apps/zui/src/domain/handlers.ts b/apps/zui/src/domain/handlers.ts index 33add04859..5d1a46bbf9 100644 --- a/apps/zui/src/domain/handlers.ts +++ b/apps/zui/src/domain/handlers.ts @@ -1,7 +1,7 @@ // Entry point to fire up all the handlers // This gets imported in an initializer -import "./results/handlers" +import "./results/handlers/view" import "./panes/handlers" import "./window/handlers" import "./session/handlers/navigation" diff --git a/apps/zui/src/domain/legacy-ops/messages.ts b/apps/zui/src/domain/legacy-ops/messages.ts index 53abf8637f..ced84c05af 100644 --- a/apps/zui/src/domain/legacy-ops/messages.ts +++ b/apps/zui/src/domain/legacy-ops/messages.ts @@ -6,7 +6,6 @@ import {Config} from "../configurations/plugin-api" import {CompiledCorrelation} from "../correlations/plugin-api" import {MenuItem} from "src/core/menu" import {AnyAction} from "@reduxjs/toolkit" -import {LoadOptions} from "src/core/loader/types" import {MainArgs} from "src/electron/run-main/args" import { MenuItemConstructorOptions, @@ -20,6 +19,7 @@ import { import {SearchAppMenuState} from "src/electron/windows/search/app-menu" import {Pool} from "src/app/core/pools/pool" import {Command} from "src/app/commands/command" +import {LoadOptions} from "../loads/types" export type LegacyOperations = { autosaveOp: (windowId: string, windowState: State) => void diff --git a/apps/zui/src/domain/loads/default-loaders.ts b/apps/zui/src/domain/loads/default-loaders.ts new file mode 100644 index 0000000000..1298b8bd0f --- /dev/null +++ b/apps/zui/src/domain/loads/default-loaders.ts @@ -0,0 +1,15 @@ +import {FileLoader} from "./file-loader" +import {LoadContext} from "./load-context" +import {QueryLoader} from "./query-loader" +import {LoaderRef} from "./types" + +export const DEFAULT_LOADERS = [ + { + name: "fileLoader", + initialize: (ctx: LoadContext) => new FileLoader(ctx), + }, + { + name: "queryLoader", + initialize: (ctx: LoadContext) => new QueryLoader(ctx), + }, +] as LoaderRef[] diff --git a/apps/zui/src/domain/loads/default-loader.ts b/apps/zui/src/domain/loads/file-loader.ts similarity index 95% rename from apps/zui/src/domain/loads/default-loader.ts rename to apps/zui/src/domain/loads/file-loader.ts index 89c20ce1d1..220eb96de9 100644 --- a/apps/zui/src/domain/loads/default-loader.ts +++ b/apps/zui/src/domain/loads/file-loader.ts @@ -6,11 +6,11 @@ import {createReadableStream} from "src/core/zq" import {throttle} from "lodash" import {errorToString} from "src/util/error-to-string" -export class DefaultLoader implements Loader { +export class FileLoader implements Loader { constructor(private ctx: LoadContext) {} when() { - return true + return this.ctx.files.length > 0 } async run() { diff --git a/apps/zui/src/domain/loads/handlers/preview-load-files.ts b/apps/zui/src/domain/loads/handlers/preview-load-files.ts index 0824b18a8b..66f8fa16d8 100644 --- a/apps/zui/src/domain/loads/handlers/preview-load-files.ts +++ b/apps/zui/src/domain/loads/handlers/preview-load-files.ts @@ -3,6 +3,7 @@ import Current from "src/js/state/Current" import LoadDataForm from "src/js/state/LoadDataForm" import Pools from "src/js/state/Pools" import {quickLoadFiles} from "./quick-load-files" +import Modal from "src/js/state/Modal" export const previewLoadFiles = createHandler( "loads.previewLoadFiles", @@ -23,8 +24,8 @@ export const previewLoadFiles = createHandler( dispatch(LoadDataForm.addFiles(opts.files)) } else { dispatch(LoadDataForm.setFiles(opts.files)) - dispatch(LoadDataForm.setShow(true)) dispatch(LoadDataForm.setPoolId(poolId)) + dispatch(Modal.show("preview-load")) } } } diff --git a/apps/zui/src/domain/loads/load-context.ts b/apps/zui/src/domain/loads/load-context.ts index a764262dfd..1fd5fdc3dc 100644 --- a/apps/zui/src/domain/loads/load-context.ts +++ b/apps/zui/src/domain/loads/load-context.ts @@ -26,7 +26,14 @@ export class LoadContext { this.window.loadsInProgress++ this.main.abortables.add({id: this.id, abort: () => this.ctl.abort()}) this.main.dispatch( - Loads.create(createLoadRef(this.id, this.opts.poolId, this.opts.files)) + Loads.create( + createLoadRef( + this.id, + this.opts.poolId, + this.opts.files, + this.opts.query + ) + ) ) } @@ -74,7 +81,11 @@ export class LoadContext { } get files() { - return this.opts.files + return this.opts.files || [] + } + + get query() { + return this.opts.query } get lakeId() { diff --git a/apps/zui/src/domain/loads/load-model.ts b/apps/zui/src/domain/loads/load-model.ts index 5e5c67f372..cd72b0b7a6 100644 --- a/apps/zui/src/domain/loads/load-model.ts +++ b/apps/zui/src/domain/loads/load-model.ts @@ -8,7 +8,13 @@ export class LoadModel { return this.ref.id } + get title() { + if (this.ref.files.length) return this.humanizeFiles + else return this.ref.query + } + get humanizeFiles() { + if (!this.ref.files) return "No files" return this.ref.files.map(basename).join(", ") } diff --git a/apps/zui/src/domain/loads/load-ref.ts b/apps/zui/src/domain/loads/load-ref.ts index d0d27796c8..5ddc976fb7 100644 --- a/apps/zui/src/domain/loads/load-ref.ts +++ b/apps/zui/src/domain/loads/load-ref.ts @@ -3,12 +3,14 @@ import {LoadReference} from "src/js/state/Loads/types" export function createLoadRef( id: string, poolId: string, - files: string[] + files: string[], + query: string ): LoadReference { return { id, poolId, progress: 0, + query, files, startedAt: new Date().toISOString(), finishedAt: null, diff --git a/apps/zui/src/domain/loads/messages.ts b/apps/zui/src/domain/loads/messages.ts index 3a705c1ce2..0e7d33fe0d 100644 --- a/apps/zui/src/domain/loads/messages.ts +++ b/apps/zui/src/domain/loads/messages.ts @@ -8,15 +8,16 @@ export type LoadFormData = { name?: string | null key?: string | null order?: "asc" | "desc" | null + query?: string + shaper?: string + format?: LoadFormat files: string[] author: string body: string - shaper?: string - format?: LoadFormat } export type LoadsOperations = { - "loads.create": typeof ops.submit + "loads.create": typeof ops.create "loads.preview": typeof ops.preview "loads.getFileTypes": typeof ops.getFileTypes "loads.abortPreview": typeof ops.abortPreview diff --git a/apps/zui/src/domain/loads/operations/cancel.ts b/apps/zui/src/domain/loads/operations/cancel.ts index a010e46922..7220ad71b7 100644 --- a/apps/zui/src/domain/loads/operations/cancel.ts +++ b/apps/zui/src/domain/loads/operations/cancel.ts @@ -4,7 +4,7 @@ import {createOperation} from "src/core/operations" export const cancel = createOperation( "loads.cancel", - (ctx, poolId: string, files: string[]) => { - loads.emit("abort", createLoadRef("new", poolId, files)) + (ctx, poolId: string, files: string[], query: string) => { + loads.emit("abort", createLoadRef("new", poolId, files, query)) } ) diff --git a/apps/zui/src/domain/loads/operations/create.ts b/apps/zui/src/domain/loads/operations/create.ts index 90627de839..2a55cc4039 100644 --- a/apps/zui/src/domain/loads/operations/create.ts +++ b/apps/zui/src/domain/loads/operations/create.ts @@ -9,7 +9,7 @@ import {poolPath} from "src/app/router/utils/paths" import {isAbortError} from "src/util/is-abort-error" /* Called when the user submits the preview & load form */ -export const submit = createOperation( +export const create = createOperation( "loads.create", async (ctx, data: LoadFormData) => { const pool = await createPool(data) @@ -23,6 +23,7 @@ export const submit = createOperation( poolId: pool.id, lakeId: zui.window.lakeId, branch: "main", + query: data.query, files: data.files, shaper: script.isEmpty() ? "*" : data.shaper, author: data.author, @@ -33,6 +34,7 @@ export const submit = createOperation( }) .catch((e) => { if (isAbortError(e)) return + console.log(e) zui.window.showErrorMessage("Load error " + errorToString(e)) }) @@ -45,7 +47,7 @@ async function createPool(data: LoadFormData): Promise { const poolNames = zui.pools.all.map((pool) => pool.name) const derivedName = await deriveName(data.files, poolNames) const name = data.name?.trim() || derivedName - const key = data.key + const key = data.key?.trim() || "ts" const order = data.order return zui.pools.create(name, {key, order}) } else { diff --git a/apps/zui/src/domain/loads/plugin-api.ts b/apps/zui/src/domain/loads/plugin-api.ts index 8f66bc6b0d..7d39bc4d7a 100644 --- a/apps/zui/src/domain/loads/plugin-api.ts +++ b/apps/zui/src/domain/loads/plugin-api.ts @@ -1,20 +1,11 @@ -import {DefaultLoader} from "./default-loader" import {LoadContext} from "./load-context" -import {Loader} from "src/core/loader/types" import Loads from "src/js/state/Loads" import {select} from "src/core/main/select" import {TypedEmitter} from "src/util/typed-emitter" -import {LoadReference} from "src/js/state/Loads/types" +import {DEFAULT_LOADERS} from "./default-loaders" +import {LoadEvents, Loader, LoaderRef} from "./types" -type Events = { - success: (load: LoadReference) => void - abort: (load: LoadReference) => void - error: (load: LoadReference) => void -} - -type LoaderRef = {name: string; initialize: (ctx: LoadContext) => Loader} - -export class LoadsApi extends TypedEmitter { +export class LoadsApi extends TypedEmitter { private list: LoaderRef[] = [] addLoader(name: string, initialize: (ctx: LoadContext) => Loader) { @@ -22,11 +13,15 @@ export class LoadsApi extends TypedEmitter { } async initialize(context: LoadContext) { - for (const ref of this.list) { - const customLoader = ref.initialize(context) - if (await customLoader.when()) return customLoader + for (const {initialize} of this.loaders) { + const loader = initialize(context) + if (await loader.when()) return loader } - return new DefaultLoader(context) + throw new Error("Loader not found") + } + + private get loaders() { + return [...this.list, ...DEFAULT_LOADERS] } get all() { diff --git a/apps/zui/src/domain/loads/query-loader.ts b/apps/zui/src/domain/loads/query-loader.ts new file mode 100644 index 0000000000..9904fa7b83 --- /dev/null +++ b/apps/zui/src/domain/loads/query-loader.ts @@ -0,0 +1,35 @@ +import {LoadContext} from "./load-context" +import {Loader} from "./types" + +export class QueryLoader implements Loader { + constructor(private ctx: LoadContext) {} + + when() { + return this.ctx.files.length === 0 && !!this.ctx.query + } + + async run() { + this.ctx.setProgress(Infinity) + const client = await this.ctx.createClient() + await client + .query(this.loadQuery, {signal: this.ctx.signal}) + .then((r) => r.js()) + this.ctx.setProgress(1) + } + + private get loadQuery() { + // This is the load op syntax + // load [@] [author ] [message ] [meta ] + return [ + this.ctx.query, + "| load", + this.ctx.poolId + "@" + this.ctx.branch, + "author " + JSON.stringify(this.ctx.author), + "message " + JSON.stringify(this.ctx.body), + ].join(" ") + } +} + +export function addLoad(query: string, poolId) { + return query + " | load " + poolId +} diff --git a/apps/zui/src/domain/loads/types.ts b/apps/zui/src/domain/loads/types.ts index bc66980f9e..483445f999 100644 --- a/apps/zui/src/domain/loads/types.ts +++ b/apps/zui/src/domain/loads/types.ts @@ -1,20 +1,33 @@ import {LoadFormat} from "@brimdata/zed-js" import {LoadContext} from "./load-context" +import {LoadReference} from "src/js/state/Loads/types" export type LoadOptions = { windowId: string lakeId: string poolId: string - branch: string + branch?: string files: string[] - shaper: string + query?: string + shaper?: string format?: LoadFormat author: string body: string } export interface Loader { - when(context: LoadContext): PromiseLike | boolean - run(context: LoadContext): PromiseLike | void - rollback(context: LoadContext): PromiseLike | void + when(): PromiseLike | boolean + run(): PromiseLike | void + rollback?(): PromiseLike | void +} + +export type LoadEvents = { + success: (load: LoadReference) => void + abort: (load: LoadReference) => void + error: (load: LoadReference) => void +} + +export type LoaderRef = { + name: string + initialize: (ctx: LoadContext) => Loader } diff --git a/apps/zui/src/domain/pools/handlers.ts b/apps/zui/src/domain/pools/handlers.ts index 0346ae43d6..87109a15e1 100644 --- a/apps/zui/src/domain/pools/handlers.ts +++ b/apps/zui/src/domain/pools/handlers.ts @@ -4,3 +4,22 @@ import Modal from "src/js/state/Modal" export const newPool = createHandler("pools.new", ({dispatch}) => { dispatch(Modal.show("new-pool")) }) + +type PoolFormData = { + poolId: string + name?: string + key?: string + order?: string +} +export const getOrCreatePool = createHandler( + async ({invoke}, data: PoolFormData) => { + if (data.poolId === "new") { + return await invoke("pools.create", data.name, { + key: data.key, + order: data.order as "desc" | "asc", + }) + } else { + return data.poolId + } + } +) diff --git a/apps/zui/src/domain/pools/operations.ts b/apps/zui/src/domain/pools/operations.ts index b0f6de93cf..47c717064e 100644 --- a/apps/zui/src/domain/pools/operations.ts +++ b/apps/zui/src/domain/pools/operations.ts @@ -11,15 +11,10 @@ import {Update} from "@reduxjs/toolkit" export const create = createOperation( "pools.create", - async ( - {main}, - lakeId: string, - name: string, - opts: Partial = {} - ) => { - const client = await main.createClient(lakeId) - const {pool} = await client.createPool(name, opts) - main.dispatch(Pools.setData({lakeId, data: pool})) + async ({main}, name: string, opts: Partial = {}) => { + if (name.trim().length === 0) throw new Error("Pool name missing") + const {pool} = await lake.client.createPool(name, opts) + main.dispatch(Pools.setData({lakeId: lake.id, data: pool})) pools.emit("create", {pool}) return pool.id as string } diff --git a/apps/zui/src/domain/pools/plugin-api.ts b/apps/zui/src/domain/pools/plugin-api.ts index 96474f9c58..109becd642 100644 --- a/apps/zui/src/domain/pools/plugin-api.ts +++ b/apps/zui/src/domain/pools/plugin-api.ts @@ -6,10 +6,11 @@ import {loads, window} from "src/zui" import * as ops from "./operations" import {LoadContext} from "src/domain/loads/load-context" import {syncPoolOp} from "src/electron/ops/sync-pool-op" -import {LoadOptions} from "src/core/loader/types" import {getMainObject} from "src/core/main" import {TypedEmitter} from "src/util/typed-emitter" import {call} from "src/util/call" +import {LoadOptions} from "../loads/types" +import {debug} from "src/core/log" type Events = { create: (event: {pool: Pool}) => void @@ -29,7 +30,7 @@ export class PoolsApi extends TypedEmitter { } async create(name: string, opts: Partial = {}) { - const id = await ops.create(window.lakeId, name, opts) + const id = await ops.create(name, opts) return this.get(id) } @@ -41,6 +42,7 @@ export class PoolsApi extends TypedEmitter { const main = getMainObject() const context = new LoadContext(main, opts) const loader = await loads.initialize(context) + debug("Using Loader", loader) try { await context.setup() await loader.run() diff --git a/apps/zui/src/domain/results/handlers/export.ts b/apps/zui/src/domain/results/handlers/export.ts new file mode 100644 index 0000000000..58e4b253f0 --- /dev/null +++ b/apps/zui/src/domain/results/handlers/export.ts @@ -0,0 +1,82 @@ +import {addFuse, cutColumns} from "../utils" +import Results from "src/js/state/Results" +import {RESULTS_QUERY} from "src/views/results-pane/run-results-query" +import {ResponseFormat} from "@brimdata/zed-js" +import {errorToString} from "src/util/error-to-string" +import {createHandler} from "src/core/handlers" +import Layout from "src/js/state/Layout" +import Table from "src/js/state/Table" + +export const exportToPool = createHandler(async ({invoke, toast}, data) => { + const query = getExportQuery(null) + + try { + await invoke("loads.create", { + query, + poolId: data.poolId, + name: data.name, + order: data.order, + key: data.key, + windowId: globalThis.windowId, + files: [], + author: "Zui", + body: "Export to Pool", + }) + } catch (e) { + toast.error(errorToString(e)) + } +}) + +export const exportToFile = createHandler( + async (ctx, format: ResponseFormat) => { + const {canceled, filePath} = await ctx.invoke("showSaveDialogOp", { + title: `Export Results as ${format.toUpperCase()}`, + buttonLabel: "Export", + defaultPath: `results.${format}`, + properties: ["createDirectory"], + showsTagField: false, + }) + if (canceled) return false + + const query = getExportQuery(format) + const promise = ctx.invoke("results.exportToFile", query, format, filePath) + ctx.toast + .promise(promise, { + loading: "Exporting...", + success: "Export Completed: " + filePath, + error: "Error Exporting", + }) + .catch((e) => { + console.error(e) + }) + return true + } +) + +export const exportToClipboard = createHandler( + async (ctx, format: ResponseFormat) => { + const query = getExportQuery(format) + const promise = ctx.invoke("results.copyToClipboard", query, format) + return ctx.toast.promise(promise, { + loading: "Copying...", + error: errorToString, + success: (result) => { + if (result === "success") return `Copied ${format} data to clipboard.` + return "Copy Cancelled" + }, + }) + } +) + +export const getExportQuery = createHandler((ctx, format: ResponseFormat) => { + const formatNeedsFuse = ["csv", "tsv", "arrows"] + const query = ctx.select(Results.getQuery(RESULTS_QUERY)) + const isTable = ctx.select(Layout.getEffectiveResultsView) == "TABLE" + const hiddenColCount = ctx.select(Table.getHiddenColumnCount) + const columns = ctx.select(Table.getVisibleColumns).map((c) => c.name) + + let q = query + if (isTable && hiddenColCount > 0) q = cutColumns(q, columns) + if (formatNeedsFuse.includes(format)) q = addFuse(q) + return q +}) diff --git a/apps/zui/src/domain/results/handlers/index.ts b/apps/zui/src/domain/results/handlers/index.ts new file mode 100644 index 0000000000..9e3a9b2a92 --- /dev/null +++ b/apps/zui/src/domain/results/handlers/index.ts @@ -0,0 +1,2 @@ +export * from "./export" +export * from "./view" diff --git a/apps/zui/src/domain/results/handlers.ts b/apps/zui/src/domain/results/handlers/view.ts similarity index 100% rename from apps/zui/src/domain/results/handlers.ts rename to apps/zui/src/domain/results/handlers/view.ts diff --git a/apps/zui/src/domain/results/messages.ts b/apps/zui/src/domain/results/messages.ts index 0e1d3d1cdd..dce21c99c3 100644 --- a/apps/zui/src/domain/results/messages.ts +++ b/apps/zui/src/domain/results/messages.ts @@ -1,10 +1,15 @@ +import * as ops from "./operations" +import * as hands from "./handlers/view" + export type ResultsHandlers = { - "results.expandAll": () => void - "results.collapseAll": () => void - "results.showExportDialog": () => void - "results.toggleHistogram": () => void + "results.expandAll": typeof hands.expandAllHandler + "results.collapseAll": typeof hands.collapseAllHandler + "results.showExportDialog": typeof hands.showExportDialog + "results.toggleHistogram": typeof hands.toggleHistogram } export type ResultsOperations = { - "results.export": (query: string, format: string, filePath: string) => string + "results.exportToFile": typeof ops.exportToFile + "results.copyToClipboard": typeof ops.copyToClipboard + "results.cancelCopyToClipboard": typeof ops.cancelCopyToClipboard } diff --git a/apps/zui/src/domain/results/operations.ts b/apps/zui/src/domain/results/operations.ts index c4d6984721..20ed641c1a 100644 --- a/apps/zui/src/domain/results/operations.ts +++ b/apps/zui/src/domain/results/operations.ts @@ -1,15 +1,17 @@ import {createOperation} from "src/core/operations" -import {QueryFormat} from "@brimdata/zed-js" +import {ResponseFormat} from "@brimdata/zed-js" import fs from "fs" import {pipeline} from "stream" import util from "util" import {lake} from "src/zui" +import {clipboard} from "electron" +import {isAbortError} from "src/util/is-abort-error" const pipe = util.promisify(pipeline) -export const resultsExport = createOperation( - "results.export", - async (ctx, query: string, format: QueryFormat, outPath: string) => { +export const exportToFile = createOperation( + "results.exportToFile", + async (ctx, query: string, format: ResponseFormat, outPath: string) => { const res = await lake.query(query, { format, controlMessages: false, @@ -27,3 +29,32 @@ export const resultsExport = createOperation( return outPath } ) + +const CLIPBOARD_ID = "copy-to-clipboard" +export const copyToClipboard = createOperation( + "results.copyToClipboard", + async (ctx, query: string, format: ResponseFormat) => { + try { + const ctl = ctx.main.abortables.create(CLIPBOARD_ID) + const res = await lake.query(query, { + format, + controlMessages: false, + timeout: Infinity, + signal: ctl.signal, + }) + const result = await res.resp.text() + clipboard.writeText(result) + return "success" + } catch (e) { + if (isAbortError(e)) return "aborted" + throw e + } + } +) + +export const cancelCopyToClipboard = createOperation( + "results.cancelCopyToClipboard", + (ctx) => { + ctx.main.abortables.abort({id: CLIPBOARD_ID}) + } +) diff --git a/apps/zui/src/domain/results/utils.ts b/apps/zui/src/domain/results/utils.ts new file mode 100644 index 0000000000..1b358a52be --- /dev/null +++ b/apps/zui/src/domain/results/utils.ts @@ -0,0 +1,11 @@ +import program from "src/js/models/program" + +export function cutColumns(query: string, names: string[]) { + return program(query) + .quietCut(...names) + .string() +} + +export function addFuse(query: string) { + return query + " | fuse" +} diff --git a/apps/zui/src/domain/results/utils/prep-export-query.ts b/apps/zui/src/domain/results/utils/prep-export-query.ts deleted file mode 100644 index 27b5a1ce3d..0000000000 --- a/apps/zui/src/domain/results/utils/prep-export-query.ts +++ /dev/null @@ -1,28 +0,0 @@ -import ZuiApi from "src/js/api/zui-api" -import program from "src/js/models/program" -import Results from "src/js/state/Results" -import {RESULTS_QUERY} from "src/views/results-pane/run-results-query" - -export function prepExportQuery(api: ZuiApi, format: string) { - let query = Results.getQuery(RESULTS_QUERY)(api.getState()) - query = cutColumns(query, api) - query = maybeFuse(query, format) - return query -} - -function cutColumns(query: string, api: ZuiApi) { - if (api.table && api.table.hiddenColumnCount > 0) { - const names = api.table.columns.map((c) => c.columnDef.header as string) - return program(query) - .quietCut(...names) - .string() - } else { - return query - } -} - -function maybeFuse(query: string, format: string) { - if (format === "csv" || format == "tsv" || format === "arrows") - query += " | fuse" - return query -} diff --git a/apps/zui/src/domain/window/handlers.ts b/apps/zui/src/domain/window/handlers.ts index 0e199d6766..1b12c3675b 100644 --- a/apps/zui/src/domain/window/handlers.ts +++ b/apps/zui/src/domain/window/handlers.ts @@ -6,6 +6,7 @@ import {QueryParams} from "src/js/api/queries/types" import {PaneName} from "src/js/state/Layout/types" import Appearance from "src/js/state/Appearance" import Layout from "src/js/state/Layout" +import Modal from "src/js/state/Modal" export const showErrorMessage = createHandler( "window.showErrorMessage", @@ -69,3 +70,7 @@ export const togglePane = createHandler( } } ) + +export const hideModal = createHandler(({dispatch}) => { + dispatch(Modal.hide()) +}) diff --git a/apps/zui/src/electron/ops/update-plugin-lake-op.ts b/apps/zui/src/electron/ops/update-plugin-lake-op.ts index 48e6e8038f..1e1e98c5ee 100644 --- a/apps/zui/src/electron/ops/update-plugin-lake-op.ts +++ b/apps/zui/src/electron/ops/update-plugin-lake-op.ts @@ -4,6 +4,7 @@ import {createOperation} from "../../core/operations" export const updatePluginLakeOp = createOperation( "updatePluginLakeOp", async ({main}, state: {lakeId: string}) => { + lake.id = state.lakeId lake.client = await main.createClient(state.lakeId) } ) diff --git a/apps/zui/src/electron/windows/search/app-menu.ts b/apps/zui/src/electron/windows/search/app-menu.ts index 22a1661b00..de1503538c 100644 --- a/apps/zui/src/electron/windows/search/app-menu.ts +++ b/apps/zui/src/electron/windows/search/app-menu.ts @@ -18,6 +18,7 @@ export const defaultAppMenuState = () => ({ showRightPane: true, showLeftPane: true, showHistogram: true, + routeName: null as null | string, }) export type SearchAppMenuState = ReturnType @@ -27,6 +28,7 @@ export function compileTemplate( state: SearchAppMenuState = defaultAppMenuState() ) { const mac = env.isMac + const querySessionActive = state.routeName === "querySession" const __: MenuItemConstructorOptions = {type: "separator"} const newWindow: MenuItemConstructorOptions = { @@ -70,7 +72,9 @@ export function compileTemplate( const exportResults: MenuItemConstructorOptions = { label: "Export Results As...", + accelerator: "CmdOrCtrl+Shift+E", click: () => window.send("showExportResults"), + enabled: querySessionActive, } const checkForUpdates: MenuItemConstructorOptions = { diff --git a/apps/zui/src/js/api/pools/pools-api.ts b/apps/zui/src/js/api/pools/pools-api.ts index e3b3473678..93e0103578 100644 --- a/apps/zui/src/js/api/pools/pools-api.ts +++ b/apps/zui/src/js/api/pools/pools-api.ts @@ -48,7 +48,7 @@ export class PoolsApi extends ApiDomain { } async create(name: string, opts: Partial = {}) { - const id = await invoke("pools.create", this.lakeId, name, opts) + const id = await invoke("pools.create", name, opts) await this.sync(id) return id } diff --git a/apps/zui/src/js/components/AboutWindow.tsx b/apps/zui/src/js/components/AboutWindow.tsx index 585e4da03d..429e426e68 100644 --- a/apps/zui/src/js/components/AboutWindow.tsx +++ b/apps/zui/src/js/components/AboutWindow.tsx @@ -23,22 +23,18 @@ export function Content(props: EnvAboutApp) {
-
- +
+

Version

{props.version}

-
-
- +

Website

invoke("openLinkOp", props.website)}> {props.website} -
-
- +

Source

invoke("openLinkOp", props.repository)}> {props.repository} -
+