From 4be8a1e37e2062a3e70965960b80e4c3b6050195 Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Mon, 8 Jul 2024 23:13:12 -0700 Subject: [PATCH] =?UTF-8?q?move=20codeedit=20to=20directory.=20=20new=20us?= =?UTF-8?q?eLongClick=20hook.=20=20show=20quick=20navig=E2=80=A6=20(#105)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit move codeedit to directory. new useLongClick hook. show quick navigation for directoryview on longclick of folder icon. lots more generic stuff for header new fitaddon for xtermjs more fixes for xtermjs scrollbars --- frontend/app/block/block.less | 18 +-- frontend/app/block/block.tsx | 104 ++++++++++++------ frontend/app/hook/useLongClick.tsx | 56 ++++++++++ frontend/app/store/global.ts | 8 ++ .../app/view/{ => codeedit}/codeedit.less | 0 frontend/app/view/{ => codeedit}/codeedit.tsx | 0 frontend/app/view/directorypreview.tsx | 4 +- frontend/app/view/preview.tsx | 41 +++++-- frontend/app/view/term/fitaddon.ts | 101 +++++++++++++++++ frontend/app/view/term/term.less | 1 - frontend/app/view/term/termwrap.ts | 7 +- frontend/types/custom.d.ts | 25 +++-- frontend/wave.ts | 6 +- public/xterm.css | 2 +- 14 files changed, 303 insertions(+), 70 deletions(-) create mode 100644 frontend/app/hook/useLongClick.tsx rename frontend/app/view/{ => codeedit}/codeedit.less (100%) rename frontend/app/view/{ => codeedit}/codeedit.tsx (100%) create mode 100644 frontend/app/view/term/fitaddon.ts diff --git a/frontend/app/block/block.less b/frontend/app/block/block.less index 80c95f6f..d005a3ec 100644 --- a/frontend/app/block/block.less +++ b/frontend/app/block/block.less @@ -77,21 +77,21 @@ background-color: rgba(255, 255, 255, 0.1); border-radius: 8px 8px 0 0; + .block-frame-preicon-button { + opacity: 0.7; + cursor: pointer; + + &:hover { + opacity: 1; + } + } + .block-frame-default-header-iconview { display: flex; align-items: center; gap: 8px; color: var(--main-text-color); - .block-frame-preicon-button { - opacity: 0.7; - cursor: pointer; - - &:hover { - opacity: 1; - } - } - .block-frame-view-icon { font-size: var(--header-icon-size); opacity: 0.5; diff --git a/frontend/app/block/block.tsx b/frontend/app/block/block.tsx index a470b04d..d1567052 100644 --- a/frontend/app/block/block.tsx +++ b/frontend/app/block/block.tsx @@ -1,6 +1,8 @@ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +import { useLongClick } from "@/app/hook/useLongClick"; +import { CodeEdit } from "@/app/view/codeedit/codeedit"; import { ErrorBoundary } from "@/element/errorboundary"; import { CenteredDiv } from "@/element/quickelems"; import { ContextMenuModel } from "@/store/contextmenu"; @@ -8,7 +10,6 @@ import { atoms, globalStore, setBlockFocus, useBlockAtom } from "@/store/global" import * as services from "@/store/services"; import * as WOS from "@/store/wos"; import * as util from "@/util/util"; -import { CodeEdit } from "@/view/codeedit"; import { PlotView } from "@/view/plotview"; import { PreviewView, makePreviewModel } from "@/view/preview"; import { TerminalView } from "@/view/term/term"; @@ -210,6 +211,16 @@ function handleHeaderContextMenu( ContextMenuModel.showContextMenu(menu, e); } +const IconButton = React.memo(({ decl, className }: { decl: HeaderIconButton; className?: string }) => { + const buttonRef = React.useRef(null); + useLongClick(buttonRef, decl.click, decl.longClick); + return ( +
+ +
+ ); +}); + const BlockFrame_Default_Component = ({ blockId, layoutModel, @@ -228,10 +239,10 @@ const BlockFrame_Default_Component = ({ }); }); let isFocused = jotai.useAtomValue(isFocusedAtom); - const viewIcon = jotai.useAtomValue(viewModel.viewIcon); - const viewText = jotai.useAtomValue(viewModel.viewText); - const preIconButton = jotai.useAtomValue(viewModel.preIconButton); - const endIconButtons = jotai.useAtomValue(viewModel.endIconButtons); + const viewIconUnion = util.useAtomValueSafe(viewModel.viewIcon) ?? "square"; + const headerTextUnion = util.useAtomValueSafe(viewModel.viewText); + const preIconButton = util.useAtomValueSafe(viewModel.preIconButton); + const endIconButtons = util.useAtomValueSafe(viewModel.endIconButtons); if (preview) { isFocused = true; } @@ -242,43 +253,65 @@ const BlockFrame_Default_Component = ({ if (isFocused && blockData?.meta?.["frame:bordercolor:focused"]) { style.borderColor = blockData.meta["frame:bordercolor:focused"]; } + let viewIconElem: JSX.Element = null; + if (viewIconUnion == null || typeof viewIconUnion === "string") { + const viewIcon = viewIconUnion as string; + viewIconElem =
{getBlockHeaderIcon(viewIcon, blockData)}
; + } else { + viewIconElem = ; + } let preIconButtonElem: JSX.Element = null; if (preIconButton) { - preIconButtonElem = ( -
- -
- ); + preIconButtonElem = ; } let endIconsElem: JSX.Element[] = []; if (endIconButtons && endIconButtons.length > 0) { for (let idx = 0; idx < endIconButtons.length; idx++) { const button = endIconButtons[idx]; - endIconsElem.push( -
- + endIconsElem.push(); + } + } + const settingsDecl: HeaderIconButton = { + elemtype: "iconbutton", + icon: "cog", + title: "Settings", + click: (e) => handleHeaderContextMenu(e, blockData, viewModel, layoutModel?.onClose), + }; + endIconsElem.push( + + ); + const closeDecl: HeaderIconButton = { + elemtype: "iconbutton", + icon: "xmark-large", + title: "Close", + click: layoutModel?.onClose, + }; + endIconsElem.push( + + ); + let headerTextElems: JSX.Element[] = []; + if (typeof headerTextUnion === "string") { + if (!util.isBlank(headerTextUnion)) { + headerTextElems.push( +
+ {headerTextUnion}
); } + } else if (Array.isArray(headerTextUnion)) { + for (let idx = 0; idx < headerTextUnion.length; idx++) { + const elem = headerTextUnion[idx]; + if (elem.elemtype == "iconbutton") { + headerTextElems.push(); + } else if (elem.elemtype == "text") { + headerTextElems.push( +
+ {elem.text} +
+ ); + } + } } - endIconsElem.push( -
handleHeaderContextMenu(e, blockData, viewModel, layoutModel?.onClose)} - > - -
- ); - endIconsElem.push( -
- -
- ); return (
handleHeaderContextMenu(e, blockData, viewModel, layoutModel?.onClose)} > + {preIconButtonElem}
- {preIconButtonElem} -
{getBlockHeaderIcon(viewIcon, blockData)}
+ {viewIconElem}
{blockViewToName(blockData?.view)}
{settingsConfig?.blockheader?.showblockids && (
[{blockId.substring(0, 8)}]
)}
- {util.isBlank(viewText) ? null :
{viewText}
} + {headerTextElems}
{endIconsElem}
@@ -322,7 +355,7 @@ const BlockFrame_Default = React.memo(BlockFrame_Default_Component) as typeof Bl const BlockFrame = React.memo((props: BlockFrameProps) => { const blockId = props.blockId; - const [blockData, blockDataLoading] = WOS.useWaveObjectValue(WOS.makeORef("block", blockId)); + const [blockData] = WOS.useWaveObjectValue(WOS.makeORef("block", blockId)); const tabData = jotai.useAtomValue(atoms.tabAtom); if (!blockId || !blockData) { @@ -439,7 +472,6 @@ function makeDefaultViewModel(blockId: string): ViewModel { }), preIconButton: jotai.atom(null), endIconButtons: jotai.atom(null), - hasSearch: jotai.atom(false), }; return viewModel; } diff --git a/frontend/app/hook/useLongClick.tsx b/frontend/app/hook/useLongClick.tsx new file mode 100644 index 00000000..39841e57 --- /dev/null +++ b/frontend/app/hook/useLongClick.tsx @@ -0,0 +1,56 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +export const useLongClick = (ref, onClick, onLongClick, ms = 300) => { + const timerRef = useRef(null); + const [longClickTriggered, setLongClickTriggered] = useState(false); + + const startPress = useCallback( + (e: React.MouseEvent) => { + if (onLongClick == null) { + return; + } + setLongClickTriggered(false); + timerRef.current = setTimeout(() => { + setLongClickTriggered(true); + onLongClick?.(e); + }, ms); + }, + [onLongClick, ms] + ); + + const stopPress = useCallback(() => { + clearTimeout(timerRef.current); + }, []); + + const handleClick = useCallback( + (e: React.MouseEvent) => { + if (longClickTriggered) { + event.preventDefault(); + event.stopPropagation(); + return; + } + onClick?.(e); + }, + [longClickTriggered, onClick] + ); + + useEffect(() => { + const element = ref.current; + + if (!element) return; + + element.addEventListener("mousedown", startPress); + element.addEventListener("mouseup", stopPress); + element.addEventListener("mouseleave", stopPress); + element.addEventListener("click", handleClick); + + return () => { + element.removeEventListener("mousedown", startPress); + element.removeEventListener("mouseup", stopPress); + element.removeEventListener("mouseleave", stopPress); + element.removeEventListener("click", handleClick); + }; + }, [ref.current, startPress, stopPress, handleClick]); + + return ref; +}; diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts index 85e2d0ce..b6e9b7ac 100644 --- a/frontend/app/store/global.ts +++ b/frontend/app/store/global.ts @@ -13,6 +13,12 @@ import * as services from "./services"; import * as WOS from "./wos"; import { WSControl } from "./ws"; +let PLATFORM: NodeJS.Platform = "darwin"; + +function setPlatform(platform: NodeJS.Platform) { + PLATFORM = platform; +} + // TODO remove the window dependency completely // we should have the initialization be more orderly -- proceed directly from wave.ts instead of on its own. const globalStore = jotai.createStore(); @@ -363,6 +369,7 @@ function getObjectId(obj: any): number { } export { + PLATFORM, WOS, atoms, createBlock, @@ -378,6 +385,7 @@ export { initWS, sendWSCommand, setBlockFocus, + setPlatform, useBlockAtom, useBlockCache, useSettingsAtom, diff --git a/frontend/app/view/codeedit.less b/frontend/app/view/codeedit/codeedit.less similarity index 100% rename from frontend/app/view/codeedit.less rename to frontend/app/view/codeedit/codeedit.less diff --git a/frontend/app/view/codeedit.tsx b/frontend/app/view/codeedit/codeedit.tsx similarity index 100% rename from frontend/app/view/codeedit.tsx rename to frontend/app/view/codeedit/codeedit.tsx diff --git a/frontend/app/view/directorypreview.tsx b/frontend/app/view/directorypreview.tsx index 238c3963..a83f3346 100644 --- a/frontend/app/view/directorypreview.tsx +++ b/frontend/app/view/directorypreview.tsx @@ -476,9 +476,9 @@ function DirectoryPreview({ fileNameAtom, model }: DirectoryPreviewProps) { const [focusIndex, setFocusIndex] = React.useState(0); const [content, setContent] = React.useState([]); const [fileName, setFileName] = jotai.useAtom(fileNameAtom); - const [hideHiddenFiles, setHideHiddenFiles] = jotai.useAtom(model.showHiddenFiles); + const hideHiddenFiles = jotai.useAtomValue(model.showHiddenFiles); const [selectedPath, setSelectedPath] = React.useState(""); - const [refreshVersion, setRefreshVersion] = React.useState(0); + const [refreshVersion, setRefreshVersion] = jotai.useAtom(model.refreshVersion); React.useEffect(() => { model.refreshCallback = () => { diff --git a/frontend/app/view/preview.tsx b/frontend/app/view/preview.tsx index ecd6f735..335876f4 100644 --- a/frontend/app/view/preview.tsx +++ b/frontend/app/view/preview.tsx @@ -1,6 +1,7 @@ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +import { ContextMenuModel } from "@/app/store/contextmenu"; import { Markdown } from "@/element/markdown"; import { getBackendHostPort, globalStore, useBlockAtom } from "@/store/global"; import * as services from "@/store/services"; @@ -11,10 +12,9 @@ import * as jotai from "jotai"; import { loadable } from "jotai/utils"; import { useRef } from "react"; import { CenteredDiv } from "../element/quickelems"; -import { CodeEdit } from "./codeedit"; +import { CodeEdit } from "./codeedit/codeedit"; import { CSVView } from "./csvview"; import { DirectoryPreview } from "./directorypreview"; - import "./view.less"; const MaxFileSize = 1024 * 1024 * 10; // 10MB @@ -23,12 +23,11 @@ const MaxCSVSize = 1024 * 1024 * 1; // 1MB export class PreviewModel implements ViewModel { blockId: string; blockAtom: jotai.Atom; - viewIcon: jotai.Atom; + viewIcon: jotai.Atom; viewName: jotai.Atom; viewText: jotai.Atom; - preIconButton: jotai.Atom; - endIconButtons: jotai.Atom; - hasSearch: jotai.Atom; + preIconButton: jotai.Atom; + endIconButtons: jotai.Atom; fileName: jotai.WritableAtom; statFile: jotai.Atom>; @@ -38,6 +37,7 @@ export class PreviewModel implements ViewModel { fileContent: jotai.Atom>; showHiddenFiles: jotai.PrimitiveAtom; + refreshVersion: jotai.PrimitiveAtom; refreshCallback: () => void; setPreviewFileName(fileName: string) { @@ -47,6 +47,7 @@ export class PreviewModel implements ViewModel { constructor(blockId: string) { this.blockId = blockId; this.showHiddenFiles = jotai.atom(true); + this.refreshVersion = jotai.atom(0); this.blockAtom = WOS.getWaveObjectAtom(`block:${blockId}`); this.viewIcon = jotai.atom((get) => { let blockData = get(this.blockAtom); @@ -54,6 +55,30 @@ export class PreviewModel implements ViewModel { return blockData.meta.icon; } const mimeType = util.jotaiLoadableValue(get(this.fileMimeTypeLoadable), ""); + if (mimeType == "directory") { + return { + elemtype: "iconbutton", + icon: "folder-open", + longClick: (e: React.MouseEvent) => { + let menuItems: ContextMenuItem[] = []; + menuItems.push({ label: "Go to Home", click: () => globalStore.set(this.fileName, "~") }); + menuItems.push({ + label: "Go to Desktop", + click: () => globalStore.set(this.fileName, "~/Desktop"), + }); + menuItems.push({ + label: "Go to Downloads", + click: () => globalStore.set(this.fileName, "~/Downloads"), + }); + menuItems.push({ + label: "Go to Documents", + click: () => globalStore.set(this.fileName, "~/Documents"), + }); + menuItems.push({ label: "Go to Root", click: () => globalStore.set(this.fileName, "/") }); + ContextMenuModel.showContextMenu(menuItems, e); + }, + }; + } const fileName = get(this.fileName); return iconForFile(mimeType, fileName); }); @@ -67,6 +92,7 @@ export class PreviewModel implements ViewModel { return null; } return { + elemtype: "iconbutton", icon: "chevron-left", click: this.onBack.bind(this), }; @@ -77,12 +103,14 @@ export class PreviewModel implements ViewModel { let showHiddenFiles = get(this.showHiddenFiles); return [ { + elemtype: "iconbutton", icon: showHiddenFiles ? "eye" : "eye-slash", click: () => { globalStore.set(this.showHiddenFiles, (prev) => !prev); }, }, { + elemtype: "iconbutton", icon: "arrows-rotate", click: () => this.refreshCallback?.(), }, @@ -90,7 +118,6 @@ export class PreviewModel implements ViewModel { } return null; }); - this.hasSearch = jotai.atom(false); this.fileName = jotai.atom( (get) => { diff --git a/frontend/app/view/term/fitaddon.ts b/frontend/app/view/term/fitaddon.ts new file mode 100644 index 00000000..fccd1c4d --- /dev/null +++ b/frontend/app/view/term/fitaddon.ts @@ -0,0 +1,101 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +// This file is a copy of the original xterm.js file, with the following changes: +// - removed the allowance for the scrollbar + +import type { FitAddon as IFitApi } from "@xterm/addon-fit"; +import type { ITerminalAddon, Terminal } from "@xterm/xterm"; +import { IRenderDimensions } from "@xterm/xterm/src/browser/renderer/shared/Types"; + +interface ITerminalDimensions { + /** + * The number of rows in the terminal. + */ + rows: number; + + /** + * The number of columns in the terminal. + */ + cols: number; +} + +const MINIMUM_COLS = 2; +const MINIMUM_ROWS = 1; + +export class FitAddon implements ITerminalAddon, IFitApi { + private _terminal: Terminal | undefined; + public noScrollbar: boolean = false; + + public activate(terminal: Terminal): void { + this._terminal = terminal; + } + + public dispose(): void {} + + public fit(): void { + const dims = this.proposeDimensions(); + if (!dims || !this._terminal || isNaN(dims.cols) || isNaN(dims.rows)) { + return; + } + + // TODO: Remove reliance on private API + const core = (this._terminal as any)._core; + + // Force a full render + if (this._terminal.rows !== dims.rows || this._terminal.cols !== dims.cols) { + core._renderService.clear(); + this._terminal.resize(dims.cols, dims.rows); + } + } + + public proposeDimensions(): ITerminalDimensions | undefined { + if (!this._terminal) { + return undefined; + } + + if (!this._terminal.element || !this._terminal.element.parentElement) { + return undefined; + } + + // TODO: Remove reliance on private API + const core = (this._terminal as any)._core; + const dims: IRenderDimensions = core._renderService.dimensions; + + if (dims.css.cell.width === 0 || dims.css.cell.height === 0) { + return undefined; + } + + // UPDATED CODE (removed reliance on FALLBACK_SCROLL_BAR_WIDTH in viewport) + const measuredScrollBarWidth = + core.viewport._viewportElement.offsetWidth - core.viewport._scrollArea.offsetWidth; + let scrollbarWidth = this._terminal.options.scrollback === 0 ? 0 : measuredScrollBarWidth; + if (this.noScrollbar) { + scrollbarWidth = 0; + } + // END UPDATED CODE + + const parentElementStyle = window.getComputedStyle(this._terminal.element.parentElement); + const parentElementHeight = parseInt(parentElementStyle.getPropertyValue("height")); + const parentElementWidth = Math.max(0, parseInt(parentElementStyle.getPropertyValue("width"))); + const elementStyle = window.getComputedStyle(this._terminal.element); + const elementPadding = { + top: parseInt(elementStyle.getPropertyValue("padding-top")), + bottom: parseInt(elementStyle.getPropertyValue("padding-bottom")), + right: parseInt(elementStyle.getPropertyValue("padding-right")), + left: parseInt(elementStyle.getPropertyValue("padding-left")), + }; + const elementPaddingVer = elementPadding.top + elementPadding.bottom; + const elementPaddingHor = elementPadding.right + elementPadding.left; + const availableHeight = parentElementHeight - elementPaddingVer; + // UPDATED added 6 here (adjustment in xterm.css, right: -6px for scrollbar) + const availableWidth = parentElementWidth - elementPaddingHor - scrollbarWidth - 6; + const geometry = { + cols: Math.max(MINIMUM_COLS, Math.floor(availableWidth / dims.css.cell.width)), + rows: Math.max(MINIMUM_ROWS, Math.floor(availableHeight / dims.css.cell.height)), + }; + return geometry; + } +} diff --git a/frontend/app/view/term/term.less b/frontend/app/view/term/term.less index 276dd694..2559d6e1 100644 --- a/frontend/app/view/term/term.less +++ b/frontend/app/view/term/term.less @@ -7,7 +7,6 @@ width: 100%; height: 100%; overflow: hidden; - border-left: 4px solid transparent; padding-left: 4px; position: relative; diff --git a/frontend/app/view/term/termwrap.ts b/frontend/app/view/term/termwrap.ts index 3b4e7ac0..3b7fb165 100644 --- a/frontend/app/view/term/termwrap.ts +++ b/frontend/app/view/term/termwrap.ts @@ -1,14 +1,14 @@ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { fetchWaveFile, getFileSubject, sendWSCommand } from "@/store/global"; +import { PLATFORM, fetchWaveFile, getFileSubject, sendWSCommand } from "@/store/global"; import * as services from "@/store/services"; import { base64ToArray } from "@/util/util"; -import { FitAddon } from "@xterm/addon-fit"; import { SerializeAddon } from "@xterm/addon-serialize"; import * as TermTypes from "@xterm/xterm"; import { Terminal } from "@xterm/xterm"; import { debounce } from "throttle-debounce"; +import { FitAddon } from "./fitaddon"; export class TermWrap { blockId: string; @@ -18,7 +18,7 @@ export class TermWrap { connectElem: HTMLDivElement; fitAddon: FitAddon; serializeAddon: SerializeAddon; - mainFileSubject: SubjectWithRef; + mainFileSubject: SubjectWithRef; loaded: boolean; heldData: Uint8Array[]; handleResize_debounced: () => void; @@ -36,6 +36,7 @@ export class TermWrap { this.dataBytesProcessed = 0; this.terminal = new Terminal(options); this.fitAddon = new FitAddon(); + this.fitAddon.noScrollbar = PLATFORM == "darwin"; this.serializeAddon = new SerializeAddon(); this.terminal.loadAddon(this.fitAddon); this.terminal.loadAddon(this.serializeAddon); diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index 5ef825cd..af9dcb8e 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -116,20 +116,27 @@ declare global { type SubjectWithRef = rxjs.Subject & { refCount: number; release: () => void }; - type IconButtonDecl = { + type HeaderElem = HeaderIconButton | HeaderText; + + type HeaderIconButton = { + elemtype: "iconbutton"; icon: string; title?: string; - click: () => void; + click?: (e: React.MouseEvent) => void; + longClick?: (e: React.MouseEvent) => void; + }; + + type HeaderText = { + elemtype: "text"; + text: string; }; interface ViewModel { - viewIcon: jotai.Atom; - viewName: jotai.Atom; - viewText: jotai.Atom; - preIconButton: jotai.Atom; - endIconButtons: jotai.Atom; - - hasSearch: jotai.Atom; + viewIcon?: jotai.Atom; + viewName?: jotai.Atom; + viewText?: jotai.Atom; + preIconButton?: jotai.Atom; + endIconButtons?: jotai.Atom; onBack?: () => void; onForward?: () => void; diff --git a/frontend/wave.ts b/frontend/wave.ts index ea7dae07..b054b89a 100644 --- a/frontend/wave.ts +++ b/frontend/wave.ts @@ -1,7 +1,7 @@ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { atoms, getApi, globalStore, globalWS, initWS } from "@/store/global"; +import { atoms, getApi, globalStore, globalWS, initWS, setPlatform } from "@/store/global"; import * as services from "@/store/services"; import * as WOS from "@/store/wos"; import * as keyutil from "@/util/keyutil"; @@ -17,7 +17,9 @@ let clientId = urlParams.get("clientid"); console.log("Wave Starting"); console.log("clientid", clientId, "windowid", windowId); -keyutil.setKeyUtilPlatform(getApi().getPlatform()); +let platform = getApi().getPlatform(); +setPlatform(platform); +keyutil.setKeyUtilPlatform(platform); loadFonts(); (window as any).globalWS = globalWS; diff --git a/public/xterm.css b/public/xterm.css index c9bd2dc4..650c6598 100644 --- a/public/xterm.css +++ b/public/xterm.css @@ -96,7 +96,7 @@ overflow-y: scroll; cursor: default; position: absolute; - right: 0; + right: -6px; /* if this gets updated, must update fitaddon.ts */ left: 0; top: 0; bottom: 0;