From 535f6e9bf8519c33d7ad34267cf5d128bb83bbb2 Mon Sep 17 00:00:00 2001 From: James Kerr Date: Tue, 19 Dec 2023 16:41:54 -0800 Subject: [PATCH] Copy Paste Data (#2928) Allow copy and pasting into Zui. --- apps/zui/package.json | 1 + .../app-wrapper/data-dropzone-controller.tsx | 35 +++++ .../app-wrapper/data-dropzone.module.css | 34 +++-- .../app/routes/app-wrapper/data-dropzone.tsx | 72 +++------- .../dialog/{dialog.tsx => index.tsx} | 28 +++- .../zui/src/components/dialog/parse-margin.ts | 27 ---- apps/zui/src/components/dialog/parse-point.ts | 14 -- apps/zui/src/components/dialog/use-opener.ts | 2 +- .../components/dialog/use-outside-click.ts | 2 +- .../zui/src/components/dialog/use-position.ts | 135 ------------------ apps/zui/src/components/modal.tsx | 2 +- apps/zui/src/core/main/main-object.ts | 2 + apps/zui/src/domain/app/plugin-api.ts | 7 + .../loads/handlers/preview-load-files.ts | 12 +- apps/zui/src/domain/loads/load-context.ts | 17 +-- apps/zui/src/domain/loads/load-model.ts | 4 +- apps/zui/src/domain/loads/load-ref.ts | 18 +++ apps/zui/src/domain/loads/messages.ts | 3 + .../zui/src/domain/loads/operations/cancel.ts | 10 ++ apps/zui/src/domain/loads/operations/index.ts | 2 + apps/zui/src/domain/loads/operations/paste.ts | 26 ++++ apps/zui/src/domain/loads/plugin-api.ts | 10 +- apps/zui/src/domain/loads/temp-file-holder.ts | 39 +++++ apps/zui/src/domain/plugin-api.ts | 2 + apps/zui/src/domain/pools/plugin-api.ts | 22 +-- apps/zui/src/domain/pools/utils.ts | 11 +- .../src/electron/windows/search/app-menu.ts | 19 ++- apps/zui/src/js/components/Modals.tsx | 2 +- apps/zui/src/util/basename.ts | 3 + .../fixed-positioner.test.ts} | 2 +- apps/zui/src/util/fixed-positioner.ts | 117 +++++++++++++++ apps/zui/src/util/get-uniq-name.ts | 9 ++ apps/zui/src/util/hooks/use-fixed-position.ts | 24 ++++ apps/zui/src/util/typed-emitter.ts | 22 +++ .../views/histogram-pane/settings-button.tsx | 2 +- apps/zui/src/views/load-pane/form.tsx | 21 ++- apps/zui/src/views/load-pane/index.tsx | 22 +-- apps/zui/src/views/load-pane/sidebar.tsx | 8 +- .../zui/src/views/preferences-modal/index.tsx | 2 +- packages/zui-player/helpers/test-app.ts | 27 +++- packages/zui-player/package.json | 5 +- packages/zui-player/project.json | 1 - packages/zui-player/tests/copy-paste.spec.ts | 39 +++++ yarn.lock | 13 ++ 44 files changed, 551 insertions(+), 324 deletions(-) create mode 100644 apps/zui/src/app/routes/app-wrapper/data-dropzone-controller.tsx rename apps/zui/src/components/dialog/{dialog.tsx => index.tsx} (65%) delete mode 100644 apps/zui/src/components/dialog/parse-margin.ts delete mode 100644 apps/zui/src/components/dialog/parse-point.ts delete mode 100644 apps/zui/src/components/dialog/use-position.ts create mode 100644 apps/zui/src/domain/app/plugin-api.ts create mode 100644 apps/zui/src/domain/loads/load-ref.ts create mode 100644 apps/zui/src/domain/loads/operations/cancel.ts create mode 100644 apps/zui/src/domain/loads/operations/paste.ts create mode 100644 apps/zui/src/domain/loads/temp-file-holder.ts create mode 100644 apps/zui/src/util/basename.ts rename apps/zui/src/{components/dialog/parse-point.test.ts => util/fixed-positioner.test.ts} (92%) create mode 100644 apps/zui/src/util/fixed-positioner.ts create mode 100644 apps/zui/src/util/get-uniq-name.ts create mode 100644 apps/zui/src/util/hooks/use-fixed-position.ts create mode 100644 apps/zui/src/util/typed-emitter.ts create mode 100644 packages/zui-player/tests/copy-paste.spec.ts diff --git a/apps/zui/package.json b/apps/zui/package.json index c8a9530852..db0092352e 100644 --- a/apps/zui/package.json +++ b/apps/zui/package.json @@ -16,6 +16,7 @@ "start:main": "yarn build:main --watch", "start:renderer": "next dev -p 4567", "start:electron": "nodemon --watch dist ../../node_modules/electron/cli.js .", + "start:production": "node ../../node_modules/electron/cli.js .", "watch-code": "run-p start:main start:renderer", "build": "run-p -l 'build:**'", "build:main": "node scripts/esbuild.mjs", diff --git a/apps/zui/src/app/routes/app-wrapper/data-dropzone-controller.tsx b/apps/zui/src/app/routes/app-wrapper/data-dropzone-controller.tsx new file mode 100644 index 0000000000..6a23481602 --- /dev/null +++ b/apps/zui/src/app/routes/app-wrapper/data-dropzone-controller.tsx @@ -0,0 +1,35 @@ +import {previewLoadFiles, quickLoadFiles} from "src/domain/loads/handlers" + +export class DataDropzoneController { + constructor( + private shift: boolean, + private previewing: boolean, + private poolId: string + ) {} + + // prettier-ignore + get title() { + if (this.previewing) return (<>Add Files) + if (this.shift) return (<>Quick Load Data) + return <>Preview & Load Data + } + + // prettier-ignore + get note() { + if (this.previewing) return null + if (this.shift) return <>Release {"Shift"} to preview data first. + return <>Hold {"Shift"} to quick load into new pool with defaults. + } + + onDrop(fileObjects: File[]) { + const files = fileObjects.map((f) => f.path) + const poolId = this.poolId + if (this.previewing) { + previewLoadFiles({files, poolId}) + } else if (this.shift) { + quickLoadFiles({files}) + } else { + previewLoadFiles({files, poolId}) + } + } +} diff --git a/apps/zui/src/app/routes/app-wrapper/data-dropzone.module.css b/apps/zui/src/app/routes/app-wrapper/data-dropzone.module.css index 1ba1055e1b..5d37752bac 100644 --- a/apps/zui/src/app/routes/app-wrapper/data-dropzone.module.css +++ b/apps/zui/src/app/routes/app-wrapper/data-dropzone.module.css @@ -1,17 +1,19 @@ .dropzone { - position: relative; + position: relative; } .overlay { - width: 100%; - height: 100%; - z-index: 3; + width: 100vw; + height: 100vh; + max-width: none; + max-height: none; + border: none; background: var(--orange); - position: absolute; + position: fixed; top: 0; - left:0; - right:0; - bottom:0; + left: 0; + right: 0; + bottom: 0; display: flex; align-items: center; justify-content: center; @@ -24,8 +26,8 @@ flex-direction: column; gap: 1em; animation: - 700ms popup cubic-bezier(0.16, 1, 0.3, 1) forwards, - 300ms fade-opacity ease-in forwards; + 700ms popup cubic-bezier(0.16, 1, 0.3, 1) forwards, + 300ms fade-opacity ease-in forwards; } .title { @@ -33,7 +35,7 @@ font-weight: 900; text-align: center; color: white; - + margin: 0; } @@ -58,11 +60,12 @@ border-radius: 3px; margin: 0 0.2em; border: 1px solid rgba(255, 255, 255, 0.2); - box-shadow: 0 1px 1px 0 rgba(0,0,0,0.1); + box-shadow: 0 1px 1px 0 rgba(0, 0, 0, 0.1); text-transform: uppercase; font-size: 0.85em; font-weight: 500; - letter-spacing: 1px;; + letter-spacing: 1px; + ; } @@ -72,7 +75,6 @@ height: 3rem; margin: 0; animation: 700ms margin-in cubic-bezier(0.16, 1, 0.3, 1) forwards - } .hair div:first-child { @@ -119,6 +121,7 @@ from { opacity: 0; } + to { opacity: 1; } @@ -137,7 +140,8 @@ @keyframes margin-in { from { margin: 0; - } + } + to { margin: 10vmin; } diff --git a/apps/zui/src/app/routes/app-wrapper/data-dropzone.tsx b/apps/zui/src/app/routes/app-wrapper/data-dropzone.tsx index d16f875440..3a773f3dc9 100644 --- a/apps/zui/src/app/routes/app-wrapper/data-dropzone.tsx +++ b/apps/zui/src/app/routes/app-wrapper/data-dropzone.tsx @@ -1,27 +1,22 @@ import styles from "./data-dropzone.module.css" import {useFilesDrop} from "src/util/hooks/use-files-drop" import usePoolId from "src/app/router/hooks/use-pool-id" -import {previewLoadFiles, quickLoadFiles} from "src/domain/loads/handlers" import useListener from "src/js/components/hooks/useListener" import {useEffect, useState} from "react" +import {Dialog} from "src/components/dialog" +import LoadDataForm from "src/js/state/LoadDataForm" +import {useSelector} from "react-redux" +import {DataDropzoneController} from "./data-dropzone-controller" export function DataDropzone({children}) { const poolId = usePoolId() const [shiftKey, setShiftKey] = useState(false) - const onDrop = async (webFiles: File[]) => { - const files = webFiles.map((f) => f.path) - if (shiftKey) { - quickLoadFiles({files}) - } else { - previewLoadFiles({files, poolId}) - } - } - - let [props, ref] = useFilesDrop({onDrop}) + const previewing = useSelector(LoadDataForm.getShow) + const dropzone = new DataDropzoneController(shiftKey, previewing, poolId) + let [props, ref] = useFilesDrop({onDrop: (files) => dropzone.onDrop(files)}) useListener(document.body, "dragover", (e: KeyboardEvent) => { - if (!props.isOver) return - setShiftKey(e.shiftKey) + if (props.isOver) setShiftKey(e.shiftKey) }) useEffect(() => { @@ -32,49 +27,18 @@ export function DataDropzone({children}) {
{children} {props.isOver && ( -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ + {[1, 2, 3, 4].map((i) => ( +
+
+
+
+ ))}
-

- {shiftKey ? ( - <> - Quick Load Data - - ) : ( - <> - Preview & Load Data - - )} -

-

- {shiftKey ? ( - <> - Release {"Shift"} to preview data first. - - ) : ( - <> - Hold {"Shift"} to quick load into new pool with - defaults. - - )} -

+

{dropzone.title}

+

{dropzone.note}

-
+
)}
) diff --git a/apps/zui/src/components/dialog/dialog.tsx b/apps/zui/src/components/dialog/index.tsx similarity index 65% rename from apps/zui/src/components/dialog/dialog.tsx rename to apps/zui/src/components/dialog/index.tsx index be8224bf02..bd7678245e 100644 --- a/apps/zui/src/components/dialog/dialog.tsx +++ b/apps/zui/src/components/dialog/index.tsx @@ -1,13 +1,15 @@ import {HTMLAttributes, MouseEventHandler} from "react" -import {usePosition} from "./use-position" 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" export type DialogProps = { isOpen: boolean - onClose: () => void + onClose?: () => void + onCancel?: () => void modal?: boolean onOutsideClick?: (e: globalThis.MouseEvent) => void onClick?: MouseEventHandler @@ -34,21 +36,33 @@ const nonHTMLProps: (keyof DialogProps)[] = [ export function Dialog(props: DialogProps) { const [node, setNode] = useCallbackRef() - const style = usePosition(node, props) - - useOpener(node, props) + useOpener(node, props) // Make sure we open it before positioning it useOutsideClick(node, props) + const style = useFixedPosition({ + anchor: props.anchor, + anchorPoint: props.anchorPoint, + target: node && props.isOpen ? node : null, + targetPoint: props.dialogPoint, + targetMargin: props.dialogMargin, + keepOnScreen: props.keepOnScreen, + }) function onClose(e) { e.preventDefault() - props.onClose() + call(props.onClose) + } + + function onCancel(e) { + e.preventDefault() + call(props.onCancel) + call(props.onClose) } return ( s.trim()) - .map(toPixels) - if (parts.length === 0) throw new Error("Invalid margin") - if (parts.length === 1) { - return {left: parts[0], right: parts[0], top: parts[0], bottom: parts[0]} - } - if (parts.length === 2) { - return {top: parts[0], bottom: parts[0], left: parts[1], right: parts[1]} - } - if (parts.length === 3) { - return {top: parts[0], right: parts[1], left: parts[1], bottom: parts[2]} - } - if (parts.length === 4) { - return {top: parts[0], right: parts[1], left: parts[2], bottom: parts[3]} - } - throw new Error("Invalid margin") -} - -function toPixels(s: string) { - if (s === "0") return 0 - if (/\d+px/.test(s)) return parseInt(s) - - throw new Error("Only pixel values accepted") -} diff --git a/apps/zui/src/components/dialog/parse-point.ts b/apps/zui/src/components/dialog/parse-point.ts deleted file mode 100644 index 69ddb51361..0000000000 --- a/apps/zui/src/components/dialog/parse-point.ts +++ /dev/null @@ -1,14 +0,0 @@ -export function parsePoint(point: string): [string, string] { - const words = point.split(/\s+/).map((s) => s.trim()) - if (words.length === 0) throw new Error("No words passed to point") - if (words.length === 1) throw new Error("Must pass two words to point") - if (words.length > 2) throw new Error("Too many words passed to point") - - return words.sort((a, b) => { - if (a === "left" || a === "right") return -1 - if (a === "top" || a === "bottom") return 1 - if (b === "top" || b === "bottom") return -1 - if (b === "left" || b === "right") return 1 - return 0 - }) as [string, string] -} diff --git a/apps/zui/src/components/dialog/use-opener.ts b/apps/zui/src/components/dialog/use-opener.ts index ae015e5584..dd65d3cb53 100644 --- a/apps/zui/src/components/dialog/use-opener.ts +++ b/apps/zui/src/components/dialog/use-opener.ts @@ -1,5 +1,5 @@ import {useLayoutEffect} from "react" -import {DialogProps} from "./dialog" +import {DialogProps} from "." export function useOpener(dialog: HTMLDialogElement, props: DialogProps) { useLayoutEffect(() => { diff --git a/apps/zui/src/components/dialog/use-outside-click.ts b/apps/zui/src/components/dialog/use-outside-click.ts index 86c43f49ca..3efca899fb 100644 --- a/apps/zui/src/components/dialog/use-outside-click.ts +++ b/apps/zui/src/components/dialog/use-outside-click.ts @@ -1,6 +1,6 @@ import {useEffect, useRef} from "react" import {clickIsWithinElement} from "./click-is-within-element" -import {DialogProps} from "./dialog" +import {DialogProps} from "." export function useOutsideClick(dialog: HTMLDialogElement, props: DialogProps) { const callback = useRef<(e: globalThis.MouseEvent) => void>(() => {}) diff --git a/apps/zui/src/components/dialog/use-position.ts b/apps/zui/src/components/dialog/use-position.ts deleted file mode 100644 index c872e1ebbb..0000000000 --- a/apps/zui/src/components/dialog/use-position.ts +++ /dev/null @@ -1,135 +0,0 @@ -import {useLayoutEffect, useState} from "react" -import useListener from "src/js/components/hooks/useListener" -import {DialogProps} from "./dialog" -import {CSSProperties} from "react" -import {parsePoint} from "./parse-point" -import {parseMargin} from "./parse-margin" - -export function usePosition(dialog: HTMLDialogElement, props: DialogProps) { - const doc = document.documentElement - const anchor = props.anchor ?? doc - const anchorPoint = props.anchorPoint ?? "center center" - const dialogPoint = props.dialogPoint ?? "center center" - const dialogMargin = props.dialogMargin ?? "0 0 0 0" - const keepOnScreen = props.keepOnScreen ?? true - const [position, setPosition] = useState({ - top: 0, - left: 0, - position: "fixed", - }) - - const run = () => { - if (!props.isOpen || !dialog) return - const anchorRect = anchor.getBoundingClientRect() - const dialogRect = dialog.getBoundingClientRect() - dialogRect.width = dialog.clientWidth - dialogRect.height = dialog.clientHeight - let left = anchorRect.left - let top = anchorRect.top - const leftMin = 0 - const leftMax = doc.clientWidth - leftMin - const topMin = 0 - const topMax = doc.clientHeight - topMin - const [anchorX, anchorY] = parsePoint(anchorPoint) - const [dialogX, dialogY] = parsePoint(dialogPoint) - const margin = parseMargin(dialogMargin) - - // first we line of the top left to where we want it on the anchor. - // then we adjust the dialog to that point. - // then we make sure we don't overflow the window - switch (anchorX) { - case "center": - left = anchorRect.left + anchorRect.width / 2 - break - case "left": - left = left + 0 - break - case "right": - left = left + anchorRect.width - break - default: - break - } - - switch (anchorY) { - case "center": - top = anchorRect.top + anchorRect.height / 2 - break - case "top": - top = top + 0 - break - case "bottom": - top = top + anchorRect.height - break - } - - // Adjust dialog - switch (dialogX) { - case "center": - left = left - dialogRect.width / 2 - break - case "left": - left = left + 0 + margin.left - break - case "right": - left = left - dialogRect.width - margin.right - break - default: - // handle % and px heres - break - } - - switch (dialogY) { - case "center": - top = top - dialogRect.height / 2 - break - case "top": - top = top + 0 + margin.top - break - case "bottom": - top = top - dialogRect.height - margin.bottom - break - default: - // handle % and px here - break - } - - if (keepOnScreen) { - const {width, height} = dialogRect - if (left + width > leftMax) { - const diff = left + width - leftMax - left -= diff - } - // then If you overflow to the left, set at left limit - if (left < leftMin) { - left = leftMin - } - // If you overflow on the bottom, back up - if (top + height > topMax) { - const diff = top + height - topMax - top -= diff - } - // then If you overflow on the top, set at top limit - if (top < topMin) { - top = topMin - } - } - setPosition((s) => ({...s, left, top})) - } - - useLayoutEffect(() => { - run() - }, [ - dialog && dialog.open, - anchor, - anchorPoint, - dialogPoint, - dialogMargin, - props.isOpen, - keepOnScreen, - ]) - - useListener(global.window, "resize", run) - - return position -} diff --git a/apps/zui/src/components/modal.tsx b/apps/zui/src/components/modal.tsx index cc17eaeb38..894db0485a 100644 --- a/apps/zui/src/components/modal.tsx +++ b/apps/zui/src/components/modal.tsx @@ -1,6 +1,6 @@ import styles from "./modals.module.css" import {Debut, useDebut} from "src/components/debut" -import {Dialog} from "src/components/dialog/dialog" +import {Dialog} from "src/components/dialog" export function Modal(props: {children: any; onClose: () => any}) { const debut = useDebut({afterExit: props.onClose}) diff --git a/apps/zui/src/core/main/main-object.ts b/apps/zui/src/core/main/main-object.ts index 13f6565b05..1387de199b 100644 --- a/apps/zui/src/core/main/main-object.ts +++ b/apps/zui/src/core/main/main-object.ts @@ -28,6 +28,7 @@ import {PathName, getPath} from "../../js/api/core/get-path" import createLake from "src/js/models/lake" import {getAuthToken} from "../../js/api/core/get-zealot" import {Abortables} from "src/app/core/models/abortables" +import * as zui from "src/zui" export class MainObject { public isQuitting = false @@ -92,6 +93,7 @@ export class MainObject { onBeforeQuit() { if (this.isQuitting) return + zui.app.emit("quit") this.saveSession() this.isQuitting = true } diff --git a/apps/zui/src/domain/app/plugin-api.ts b/apps/zui/src/domain/app/plugin-api.ts new file mode 100644 index 0000000000..7c557f0974 --- /dev/null +++ b/apps/zui/src/domain/app/plugin-api.ts @@ -0,0 +1,7 @@ +import {TypedEmitter} from "src/util/typed-emitter" + +type Events = { + quit: () => void +} + +export class AppApi extends TypedEmitter {} 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 00aed02002..0824b18a8b 100644 --- a/apps/zui/src/domain/loads/handlers/preview-load-files.ts +++ b/apps/zui/src/domain/loads/handlers/preview-load-files.ts @@ -5,6 +5,7 @@ import Pools from "src/js/state/Pools" import {quickLoadFiles} from "./quick-load-files" export const previewLoadFiles = createHandler( + "loads.previewLoadFiles", async ( {dispatch, invoke, select}, opts: {files: string[]; poolId?: string} @@ -17,9 +18,14 @@ export const previewLoadFiles = createHandler( if (files.length === 1 && files[0].type === "pcap") { quickLoadFiles({files: files.map((f) => f.path), poolId}) } else { - dispatch(LoadDataForm.setPoolId(poolId)) - dispatch(LoadDataForm.setFiles(opts.files)) - dispatch(LoadDataForm.setShow(true)) + if (select(LoadDataForm.getShow)) { + // The preview load is already opened + dispatch(LoadDataForm.addFiles(opts.files)) + } else { + dispatch(LoadDataForm.setFiles(opts.files)) + dispatch(LoadDataForm.setShow(true)) + dispatch(LoadDataForm.setPoolId(poolId)) + } } } ) diff --git a/apps/zui/src/domain/loads/load-context.ts b/apps/zui/src/domain/loads/load-context.ts index f9dea2cb9d..77a24e7c09 100644 --- a/apps/zui/src/domain/loads/load-context.ts +++ b/apps/zui/src/domain/loads/load-context.ts @@ -4,6 +4,8 @@ import Loads from "src/js/state/Loads" import {syncPoolOp} from "src/electron/ops/sync-pool-op" import {SearchWindow} from "src/electron/windows/search/search-window" import {MainObject} from "../../core/main/main-object" +import {createLoadRef} from "./load-ref" +import {select} from "src/core/main/select" export class LoadContext { private ctl = new AbortController() @@ -23,16 +25,7 @@ export class LoadContext { this.window.loadsInProgress++ this.main.abortables.add({id: this.id, abort: () => this.ctl.abort()}) this.main.dispatch( - Loads.create({ - id: this.id, - poolId: this.opts.poolId, - progress: 0, - files: this.opts.files, - startedAt: new Date().toISOString(), - finishedAt: null, - abortedAt: null, - errors: [], - }) + Loads.create(createLoadRef(this.id, this.opts.poolId, this.opts.files)) ) } @@ -66,6 +59,10 @@ export class LoadContext { this.ctl.abort() } + get ref() { + return select((s) => Loads.find(s, this.id)) + } + get signal() { return this.ctl.signal } diff --git a/apps/zui/src/domain/loads/load-model.ts b/apps/zui/src/domain/loads/load-model.ts index 9f028eda4f..5e5c67f372 100644 --- a/apps/zui/src/domain/loads/load-model.ts +++ b/apps/zui/src/domain/loads/load-model.ts @@ -1,4 +1,5 @@ import {LoadReference} from "src/js/state/Loads/types" +import {basename} from "src/util/basename" export class LoadModel { constructor(private ref: LoadReference) {} @@ -8,8 +9,7 @@ export class LoadModel { } get humanizeFiles() { - // basename - return this.ref.files.join(", ") + return this.ref.files.map(basename).join(", ") } get status() { diff --git a/apps/zui/src/domain/loads/load-ref.ts b/apps/zui/src/domain/loads/load-ref.ts new file mode 100644 index 0000000000..d0d27796c8 --- /dev/null +++ b/apps/zui/src/domain/loads/load-ref.ts @@ -0,0 +1,18 @@ +import {LoadReference} from "src/js/state/Loads/types" + +export function createLoadRef( + id: string, + poolId: string, + files: string[] +): LoadReference { + return { + id, + poolId, + progress: 0, + files, + startedAt: new Date().toISOString(), + finishedAt: null, + abortedAt: null, + errors: [], + } +} diff --git a/apps/zui/src/domain/loads/messages.ts b/apps/zui/src/domain/loads/messages.ts index 7de843963f..3a705c1ce2 100644 --- a/apps/zui/src/domain/loads/messages.ts +++ b/apps/zui/src/domain/loads/messages.ts @@ -21,8 +21,11 @@ export type LoadsOperations = { "loads.getFileTypes": typeof ops.getFileTypes "loads.abortPreview": typeof ops.abortPreview "loads.abort": typeof ops.abort + "loads.paste": typeof ops.paste + "loads.cancel": typeof ops.cancel } export type LoadsHandlers = { "loads.chooseFiles": typeof handlers.chooseFiles + "loads.previewLoadFiles": typeof handlers.previewLoadFiles } diff --git a/apps/zui/src/domain/loads/operations/cancel.ts b/apps/zui/src/domain/loads/operations/cancel.ts new file mode 100644 index 0000000000..a010e46922 --- /dev/null +++ b/apps/zui/src/domain/loads/operations/cancel.ts @@ -0,0 +1,10 @@ +import {loads} from "src/zui" +import {createLoadRef} from "../load-ref" +import {createOperation} from "src/core/operations" + +export const cancel = createOperation( + "loads.cancel", + (ctx, poolId: string, files: string[]) => { + loads.emit("abort", createLoadRef("new", poolId, files)) + } +) diff --git a/apps/zui/src/domain/loads/operations/index.ts b/apps/zui/src/domain/loads/operations/index.ts index 508f809550..87f1038cc0 100644 --- a/apps/zui/src/domain/loads/operations/index.ts +++ b/apps/zui/src/domain/loads/operations/index.ts @@ -2,3 +2,5 @@ export * from "./get-file-types" export * from "./preview" export * from "./create" export * from "./abort" +export * from "./paste" +export * from "./cancel" diff --git a/apps/zui/src/domain/loads/operations/paste.ts b/apps/zui/src/domain/loads/operations/paste.ts new file mode 100644 index 0000000000..f7e2c8fe30 --- /dev/null +++ b/apps/zui/src/domain/loads/operations/paste.ts @@ -0,0 +1,26 @@ +import {clipboard} from "electron" +import {createOperation} from "src/core/operations" +import * as zui from "src/zui" +import {sendToFocusedWindow} from "src/core/ipc" +import {TempFileHolder} from "../temp-file-holder" +import {join} from "path" +import os from "os" + +const pasteDirPrefix = join(os.tmpdir(), "zui_pastes_") +const pastes = new TempFileHolder(pasteDirPrefix) + +function removeFiles(loadFiles: string[]) { + for (let file of loadFiles) if (pastes.has(file)) pastes.removeFile(file) +} + +zui.loads.on("error", (load) => removeFiles(load.files)) +zui.loads.on("abort", (load) => removeFiles(load.files)) +zui.loads.on("success", (load) => removeFiles(load.files)) +zui.app.on("quit", () => pastes.destroy()) + +export const paste = createOperation("loads.paste", () => { + const data = clipboard.readText() + const file = pastes.createFile("paste", data) + sendToFocusedWindow("loads.previewLoadFiles", {files: [file]}) + return file +}) diff --git a/apps/zui/src/domain/loads/plugin-api.ts b/apps/zui/src/domain/loads/plugin-api.ts index 2ff44ddb2b..7f561e04f4 100644 --- a/apps/zui/src/domain/loads/plugin-api.ts +++ b/apps/zui/src/domain/loads/plugin-api.ts @@ -3,8 +3,16 @@ 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" -export class LoadsApi { +type Events = { + success: (load: LoadReference) => void + abort: (load: LoadReference) => void + error: (load: LoadReference) => void +} + +export class LoadsApi extends TypedEmitter { private list: LoaderApi[] = [] // Don't use this...or rename to addLoader diff --git a/apps/zui/src/domain/loads/temp-file-holder.ts b/apps/zui/src/domain/loads/temp-file-holder.ts new file mode 100644 index 0000000000..fcd5cfee97 --- /dev/null +++ b/apps/zui/src/domain/loads/temp-file-holder.ts @@ -0,0 +1,39 @@ +import path from "path" +import * as fs from "fs-extra" +import {getUniqName} from "src/util/get-uniq-name" + +export class TempFileHolder { + dir: string + + constructor(namespace: string) { + this.dir = fs.mkdtempSync(namespace) + } + + createFile(prefix: string, data: string) { + const file = this.nextFile(prefix) + fs.writeFileSync(file, data) + return file + } + + removeFile(filePath: string) { + fs.removeSync(filePath) + } + + has(filePath: string) { + const dir = path.dirname(filePath) + const name = path.basename(filePath) + return this.dir === dir && this.fileNames.includes(name) + } + + destroy() { + fs.removeSync(this.dir) + } + + private nextFile(prefix: string) { + return path.join(this.dir, getUniqName(prefix, this.fileNames)) + } + + private get fileNames() { + return fs.readdirSync(this.dir) + } +} diff --git a/apps/zui/src/domain/plugin-api.ts b/apps/zui/src/domain/plugin-api.ts index d4ec913fed..ea6d965a77 100644 --- a/apps/zui/src/domain/plugin-api.ts +++ b/apps/zui/src/domain/plugin-api.ts @@ -1,3 +1,4 @@ +import {AppApi} from "./app/plugin-api" import {ConfigurationsApi} from "./configurations/plugin-api" import {CorrelationsApi} from "./correlations/plugin-api" import {EnvApi} from "./env/plugin-api" @@ -19,3 +20,4 @@ export const session = new SessionApi() export const correlations = new CorrelationsApi() export const configurations = new ConfigurationsApi() export const pools = new PoolsApi() +export const app = new AppApi() diff --git a/apps/zui/src/domain/pools/plugin-api.ts b/apps/zui/src/domain/pools/plugin-api.ts index 2b54e0a7a7..f0b8241292 100644 --- a/apps/zui/src/domain/pools/plugin-api.ts +++ b/apps/zui/src/domain/pools/plugin-api.ts @@ -1,4 +1,3 @@ -import {EventEmitter} from "events" import {CreatePoolOpts, Pool} from "@brimdata/zed-js" import {updateSettings} from "./operations" import Pools from "src/js/state/Pools" @@ -9,13 +8,13 @@ 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" type Events = { create: (event: {pool: Pool}) => void } -export class PoolsApi { - private emitter = new EventEmitter() +export class PoolsApi extends TypedEmitter { configure(poolId: string) { return new PoolConfiguration(poolId) } @@ -45,28 +44,15 @@ export class PoolsApi { await context.setup() await loader.run(context) await waitForPoolStats(context) + loads.emit("success", context.ref) } catch (e) { await loader.rollback(context) + loads.emit("error", context.ref) throw e } finally { context.teardown() } } - - on(name: K, handler: Events[K]) { - this.emitter.on(name, handler) - } - - emit( - name: K, - ...args: Parameters - ) { - this.emitter.emit(name, ...args) - } - - _teardown() { - this.emitter.removeAllListeners() - } } type ConfigMap = { diff --git a/apps/zui/src/domain/pools/utils.ts b/apps/zui/src/domain/pools/utils.ts index 81ca550f72..8628d56b7e 100644 --- a/apps/zui/src/domain/pools/utils.ts +++ b/apps/zui/src/domain/pools/utils.ts @@ -1,4 +1,5 @@ import * as path from "path" +import {getUniqName} from "src/util/get-uniq-name" export function deriveName(files: string[], existingNames: string[]) { let name: string @@ -22,13 +23,3 @@ function inSameDir(paths: string[]) { } return true } - -function join(name: string, num: number) { - return num === 0 ? name : [name, num.toString()].join("_") -} - -function getUniqName(proposal: string, existing: string[]) { - let i = 0 - while (existing.includes(join(proposal, i))) i++ - return join(proposal, i) -} diff --git a/apps/zui/src/electron/windows/search/app-menu.ts b/apps/zui/src/electron/windows/search/app-menu.ts index d03b793384..f499fafd6f 100644 --- a/apps/zui/src/electron/windows/search/app-menu.ts +++ b/apps/zui/src/electron/windows/search/app-menu.ts @@ -13,6 +13,7 @@ import {showReleaseNotesOp} from "../../ops/show-release-notes-op" import {SearchWindow} from "./search-window" import {sendToFocusedWindow} from "src/core/ipc" import {open as openUpdateWindow} from "src/domain/updates/operations" +import {paste} from "src/domain/loads/operations" export const defaultAppMenuState = () => ({ showRightPane: true, @@ -85,6 +86,12 @@ export function compileTemplate( accelerator: "CmdOrCtrl+O", } + const pasteData: MenuItemConstructorOptions = { + label: "Paste Data...", + click: paste, + accelerator: "CmdOrCtrl+Shift+V", + } + const appNameMenu: MenuItemConstructorOptions = { label: app.getName(), submenu: [ @@ -104,12 +111,22 @@ export function compileTemplate( function fileSubmenu(): MenuItemConstructorOptions[] { if (mac) { - return [newWindow, __, openFile, exportResults, __, closeTab, closeWindow] + return [ + newWindow, + __, + openFile, + pasteData, + exportResults, + __, + closeTab, + closeWindow, + ] } else { return [ newWindow, __, openFile, + pasteData, exportResults, __, settings, diff --git a/apps/zui/src/js/components/Modals.tsx b/apps/zui/src/js/components/Modals.tsx index 8d99259929..bad489bc40 100644 --- a/apps/zui/src/js/components/Modals.tsx +++ b/apps/zui/src/js/components/Modals.tsx @@ -7,7 +7,7 @@ import ViewLakeModal from "./LakeModals/ViewLakeModal" import ExportModal from "./ExportModal" import {NewPoolModal} from "src/views/new-pool-modal" import {Debut, useDebut} from "src/components/debut" -import {Dialog} from "src/components/dialog/dialog" +import {Dialog} from "src/components/dialog" import modalStyle from "src/components/modals.module.css" const MODALS = { diff --git a/apps/zui/src/util/basename.ts b/apps/zui/src/util/basename.ts new file mode 100644 index 0000000000..a0702b40c0 --- /dev/null +++ b/apps/zui/src/util/basename.ts @@ -0,0 +1,3 @@ +export function basename(fullPath: string) { + return fullPath.replace(/^.*[\\/]/, "") +} diff --git a/apps/zui/src/components/dialog/parse-point.test.ts b/apps/zui/src/util/fixed-positioner.test.ts similarity index 92% rename from apps/zui/src/components/dialog/parse-point.test.ts rename to apps/zui/src/util/fixed-positioner.test.ts index 6b7b9a0470..72f01fcc2c 100644 --- a/apps/zui/src/components/dialog/parse-point.test.ts +++ b/apps/zui/src/util/fixed-positioner.test.ts @@ -1,4 +1,4 @@ -import {parsePoint} from "./parse-point" +import {parsePoint} from "./fixed-positioner" test("left top", () => { expect(parsePoint("left top")).toEqual(["left", "top"]) diff --git a/apps/zui/src/util/fixed-positioner.ts b/apps/zui/src/util/fixed-positioner.ts new file mode 100644 index 0000000000..eaefa853ea --- /dev/null +++ b/apps/zui/src/util/fixed-positioner.ts @@ -0,0 +1,117 @@ +export function fixedPositioner(props: { + target: HTMLElement + anchor?: HTMLElement + targetPoint?: string + anchorPoint?: string + targetMargin?: string + keepOnScreen?: boolean +}) { + /* Default Fallbacks */ + const doc = document.documentElement + const target = props.target + const anchor = props.anchor ?? doc + const anchorPoint = props.anchorPoint ?? "center center" + const targetPoint = props.targetPoint ?? "center center" + const targetMargin = props.targetMargin ?? "0 0 0 0" + const keepOnScreen = props.keepOnScreen ?? true + + /* Set Up Variables */ + const anchorRect = anchor.getBoundingClientRect() + const targetRect = target.getBoundingClientRect() + const leftMin = 0 + const leftMax = doc.clientWidth - leftMin + const topMin = 0 + const topMax = doc.clientHeight - topMin + const [anchorX, anchorY] = parsePoint(anchorPoint) + const [targetX, targetY] = parsePoint(targetPoint) + const margin = parseMargin(targetMargin) + + /* 1. Start with the anchor's top left position */ + let left = anchorRect.left + let top = anchorRect.top + + /* 2. Move target's top left corner to the anchor's point */ + if (anchorX === "center") left = anchorRect.left + anchorRect.width / 2 + if (anchorX === "left") left = left + 0 + if (anchorX === "right") left = left + anchorRect.width + if (anchorY === "center") top = anchorRect.top + anchorRect.height / 2 + if (anchorY === "top") top = top + 0 + if (anchorY === "bottom") top = top + anchorRect.height + + /* 3. Move the target so that the targetPoint is on top of the anchorPoint */ + if (targetX === "center") left = left - targetRect.width / 2 + if (targetX === "left") left = left + 0 + margin.left + if (targetX === "right") left = left - targetRect.width - margin.right + if (targetY === "center") top = top - targetRect.height / 2 + if (targetY === "top") top = top + 0 + margin.top + if (targetY === "bottom") top = top - targetRect.height - margin.bottom + + /* 4. Try to keep the target on the screen */ + if (keepOnScreen) { + const {width, height} = targetRect + if (left + width > leftMax) { + const diff = left + width - leftMax + left -= diff + } + // then If you overflow to the left, set at left limit + if (left < leftMin) { + left = leftMin + } + // If you overflow on the bottom, back up + if (top + height > topMax) { + const diff = top + height - topMax + top -= diff + } + // then If you overflow on the top, set at top limit + if (top < topMin) { + top = topMin + } + } + + return {top, left} +} + +/* Private Functions */ + +export function parsePoint(point: string): [string, string] { + const words = point.split(/\s+/).map((s) => s.trim()) + if (words.length === 0) throw new Error("No words passed to point") + if (words.length === 1) throw new Error("Must pass two words to point") + if (words.length > 2) throw new Error("Too many words passed to point") + + return words.sort((a, b) => { + if (a === "left" || a === "right") return -1 + if (a === "top" || a === "bottom") return 1 + if (b === "top" || b === "bottom") return -1 + if (b === "left" || b === "right") return 1 + return 0 + }) as [string, string] +} + +function parseMargin(s: string) { + const parts = s + .split(/\s+/) + .map((s) => s.trim()) + .map(toPixels) + if (parts.length === 0) throw new Error("Invalid margin") + if (parts.length === 1) { + return {left: parts[0], right: parts[0], top: parts[0], bottom: parts[0]} + } + if (parts.length === 2) { + return {top: parts[0], bottom: parts[0], left: parts[1], right: parts[1]} + } + if (parts.length === 3) { + return {top: parts[0], right: parts[1], left: parts[1], bottom: parts[2]} + } + if (parts.length === 4) { + return {top: parts[0], right: parts[1], left: parts[2], bottom: parts[3]} + } + throw new Error("Invalid margin") +} + +function toPixels(s: string) { + if (s === "0") return 0 + if (/\d+px/.test(s)) return parseInt(s) + + throw new Error("Only pixel values accepted") +} diff --git a/apps/zui/src/util/get-uniq-name.ts b/apps/zui/src/util/get-uniq-name.ts new file mode 100644 index 0000000000..3a0a7d6053 --- /dev/null +++ b/apps/zui/src/util/get-uniq-name.ts @@ -0,0 +1,9 @@ +function join(name: string, num: number) { + return num === 0 ? name : [name, num.toString()].join("_") +} + +export function getUniqName(proposal: string, existing: string[]) { + let i = 0 + while (existing.includes(join(proposal, i))) i++ + return join(proposal, i) +} diff --git a/apps/zui/src/util/hooks/use-fixed-position.ts b/apps/zui/src/util/hooks/use-fixed-position.ts new file mode 100644 index 0000000000..89968a5662 --- /dev/null +++ b/apps/zui/src/util/hooks/use-fixed-position.ts @@ -0,0 +1,24 @@ +import {useLayoutEffect, useState} from "react" +import useListener from "src/js/components/hooks/useListener" +import {CSSProperties} from "react" +import {fixedPositioner} from "../fixed-positioner" + +type Props = Parameters[0] + +export function useFixedPosition(props: Props) { + const [style, setStyle] = useState({ + top: 0, + left: 0, + position: "fixed", + }) + + function run() { + if (!props.target) return + setStyle((prev) => ({...prev, ...fixedPositioner(props)})) + } + + useLayoutEffect(run, Object.values(props)) + useListener(global.window, "resize", run) + + return style +} diff --git a/apps/zui/src/util/typed-emitter.ts b/apps/zui/src/util/typed-emitter.ts new file mode 100644 index 0000000000..845ba22121 --- /dev/null +++ b/apps/zui/src/util/typed-emitter.ts @@ -0,0 +1,22 @@ +import EventEmitter from "events" + +type EventMap = {[name: string]: (...args: any[]) => void} + +export class TypedEmitter { + private emitter = new EventEmitter() + + on(name: K, handler: Events[K]) { + this.emitter.on(name, handler) + } + + emit( + name: K, + ...args: Parameters + ) { + this.emitter.emit(name, ...args) + } + + _teardown() { + this.emitter.removeAllListeners() + } +} diff --git a/apps/zui/src/views/histogram-pane/settings-button.tsx b/apps/zui/src/views/histogram-pane/settings-button.tsx index cc99bc1261..2130adb407 100644 --- a/apps/zui/src/views/histogram-pane/settings-button.tsx +++ b/apps/zui/src/views/histogram-pane/settings-button.tsx @@ -1,6 +1,6 @@ import {useRef, useState} from "react" import styles from "./histogram-pane.module.css" -import {Dialog} from "src/components/dialog/dialog" +import {Dialog} from "src/components/dialog" import {SettingsForm} from "./settings-form" import {useSelector} from "react-redux" import Current from "src/js/state/Current" diff --git a/apps/zui/src/views/load-pane/form.tsx b/apps/zui/src/views/load-pane/form.tsx index 6b7236c205..1624771ef4 100644 --- a/apps/zui/src/views/load-pane/form.tsx +++ b/apps/zui/src/views/load-pane/form.tsx @@ -17,8 +17,13 @@ import {DataFormatOptions} from "src/components/data-format-select" import {LoadFormat} from "@brimdata/zed-js" import {ErrorWell} from "src/components/error-well" import {errorToString} from "src/util/error-to-string" +import {basename} from "src/util/basename" -export function Form(props: {onClose: () => any; isValid: boolean}) { +export function Form(props: { + onClose: () => any + onCancel: () => any + isValid: boolean +}) { const dispatch = useDispatch() const select = useSelect() const pools = useSelector(Current.getPools) @@ -37,7 +42,6 @@ export function Form(props: {onClose: () => any; isValid: boolean}) { const [error, setError] = useState(null) const onSubmit = async (data) => { - console.log("on submit") const shaper = select(LoadDataForm.getShaper) // @ts-ignore const windowId = window.windowId @@ -93,14 +97,18 @@ export function Form(props: {onClose: () => any; isValid: boolean}) {
    {files.map((f: string, i) => ( -
  • +
  • - {f.split(/[\\/]/).pop()} + {basename(f)} removeFile(f)} />
  • @@ -218,7 +226,10 @@ export function Form(props: {onClose: () => any; isValid: boolean}) {