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;