From 48704509248b3a08e763a54bbb7d01566dbd58e3 Mon Sep 17 00:00:00 2001 From: sawka Date: Mon, 15 Sep 2025 11:06:34 -0700 Subject: [PATCH 1/3] refactor, split preview-model from preview... --- frontend/app/block/block.tsx | 2 +- .../app/view/preview/directorypreview.tsx | 17 +- frontend/app/view/preview/preview-model.tsx | 842 ++++++++++++++++++ frontend/app/view/preview/preview.tsx | 832 +---------------- 4 files changed, 858 insertions(+), 835 deletions(-) create mode 100644 frontend/app/view/preview/preview-model.tsx diff --git a/frontend/app/block/block.tsx b/frontend/app/block/block.tsx index 4d849de9d1..1aa507f4f8 100644 --- a/frontend/app/block/block.tsx +++ b/frontend/app/block/block.tsx @@ -10,7 +10,7 @@ import { SubBlockProps, } from "@/app/block/blocktypes"; import { LauncherViewModel } from "@/app/view/launcher/launcher"; -import { PreviewModel } from "@/app/view/preview/preview"; +import { PreviewModel } from "@/app/view/preview/preview-model"; import { SysinfoViewModel } from "@/app/view/sysinfo/sysinfo"; import { VDomModel } from "@/app/view/vdom/vdom-model"; import { ErrorBoundary } from "@/element/errorboundary"; diff --git a/frontend/app/view/preview/directorypreview.tsx b/frontend/app/view/preview/directorypreview.tsx index be8201546e..68f3303698 100644 --- a/frontend/app/view/preview/directorypreview.tsx +++ b/frontend/app/view/preview/directorypreview.tsx @@ -7,7 +7,6 @@ import { ContextMenuModel } from "@/app/store/contextmenu"; import { atoms, getApi, globalStore } from "@/app/store/global"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; -import { type PreviewModel } from "@/app/view/preview/preview"; import { checkKeyPressed, isCharacterKeyEvent } from "@/util/keyutil"; import { PLATFORM, PlatformMacOS } from "@/util/platformutil"; import { addOpenMenuItems } from "@/util/previewutil"; @@ -34,6 +33,7 @@ import { useDrag, useDrop } from "react-dnd"; import { quote as shellQuote } from "shell-quote"; import { debounce } from "throttle-debounce"; import "./directorypreview.scss"; +import { type PreviewModel } from "./preview-model"; const PageJumpSize = 20; @@ -527,12 +527,12 @@ function TableBody({ if (focusIndex === null || !bodyRef.current || !osRef) { return; } - + const rowElement = bodyRef.current.querySelector(`[data-rowindex="${focusIndex}"]`) as HTMLDivElement; if (!rowElement) { return; } - + const viewport = osRef.osInstance().elements().viewport; const viewportHeight = viewport.offsetHeight; const rowRect = rowElement.getBoundingClientRect(); @@ -540,7 +540,7 @@ function TableBody({ const viewportScrollTop = viewport.scrollTop; const rowTopRelativeToViewport = rowRect.top - parentRect.top + viewport.scrollTop; const rowBottomRelativeToViewport = rowRect.bottom - parentRect.top + viewport.scrollTop; - + if (rowTopRelativeToViewport - 30 < viewportScrollTop) { // Row is above the visible area let topVal = rowTopRelativeToViewport - 30; @@ -732,9 +732,12 @@ const TableRow = React.forwardRef(function ({ [dragItem] ); - const dragRef = useCallback((node: HTMLDivElement | null) => { - drag(node); - }, [drag]); + const dragRef = useCallback( + (node: HTMLDivElement | null) => { + drag(node); + }, + [drag] + ); return (
; + blockAtom: Atom; + viewIcon: Atom; + viewName: Atom; + viewText: Atom; + preIconButton: Atom; + endIconButtons: Atom; + previewTextRef: React.RefObject; + editMode: Atom; + canPreview: PrimitiveAtom; + specializedView: Atom>; + loadableSpecializedView: Atom>; + manageConnection: Atom; + connStatus: Atom; + filterOutNowsh?: Atom; + + metaFilePath: Atom; + statFilePath: Atom>; + loadableFileInfo: Atom>; + connection: Atom>; + connectionImmediate: Atom; + statFile: Atom>; + fullFile: Atom>; + fileMimeType: Atom>; + fileMimeTypeLoadable: Atom>; + fileContentSaved: PrimitiveAtom; + fileContent: WritableAtom, [string], void>; + newFileContent: PrimitiveAtom; + connectionError: PrimitiveAtom; + errorMsgAtom: PrimitiveAtom; + + openFileModal: PrimitiveAtom; + openFileModalDelay: PrimitiveAtom; + openFileError: PrimitiveAtom; + openFileModalGiveFocusRef: React.MutableRefObject<() => boolean>; + + markdownShowToc: PrimitiveAtom; + + monacoRef: React.MutableRefObject; + + showHiddenFiles: PrimitiveAtom; + refreshVersion: PrimitiveAtom; + refreshCallback: () => void; + directoryKeyDownHandler: (waveEvent: WaveKeyboardEvent) => boolean; + codeEditKeyDownHandler: (waveEvent: WaveKeyboardEvent) => boolean; + + showS3 = atom(true); + + constructor(blockId: string, nodeModel: BlockNodeModel) { + this.viewType = "preview"; + this.blockId = blockId; + this.nodeModel = nodeModel; + let showHiddenFiles = globalStore.get(getSettingsKeyAtom("preview:showhiddenfiles")) ?? true; + this.showHiddenFiles = atom(showHiddenFiles); + this.refreshVersion = atom(0); + this.previewTextRef = createRef(); + this.openFileModal = atom(false); + this.openFileModalDelay = atom(false); + this.openFileError = atom(null) as PrimitiveAtom; + this.openFileModalGiveFocusRef = createRef(); + this.manageConnection = atom(true); + this.blockAtom = WOS.getWaveObjectAtom(`block:${blockId}`); + this.markdownShowToc = atom(false); + this.filterOutNowsh = atom(true); + this.monacoRef = createRef(); + this.connectionError = atom(""); + this.errorMsgAtom = atom(null) as PrimitiveAtom; + this.viewIcon = atom((get) => { + const blockData = get(this.blockAtom); + if (blockData?.meta?.icon) { + return blockData.meta.icon; + } + const connStatus = get(this.connStatus); + if (connStatus?.status != "connected") { + return null; + } + const mimeTypeLoadable = get(this.fileMimeTypeLoadable); + const mimeType = jotaiLoadableValue(mimeTypeLoadable, ""); + if (mimeType == "directory") { + return { + elemtype: "iconbutton", + icon: "folder-open", + longClick: (e: React.MouseEvent) => { + const menuItems: ContextMenuItem[] = BOOKMARKS.map((bookmark) => ({ + label: `Go to ${bookmark.label} (${bookmark.path})`, + click: () => this.goHistory(bookmark.path), + })); + ContextMenuModel.showContextMenu(menuItems, e); + }, + }; + } + return iconForFile(mimeType); + }); + this.editMode = atom((get) => { + const blockData = get(this.blockAtom); + return blockData?.meta?.edit ?? false; + }); + this.viewName = atom("Preview"); + this.viewText = atom((get) => { + let headerPath = get(this.metaFilePath); + const connStatus = get(this.connStatus); + if (connStatus?.status != "connected") { + return [ + { + elemtype: "text", + text: headerPath, + className: "preview-filename", + }, + ]; + } + const loadableSV = get(this.loadableSpecializedView); + const isCeView = loadableSV.state == "hasData" && loadableSV.data.specializedView == "codeedit"; + const loadableFileInfo = get(this.loadableFileInfo); + if (loadableFileInfo.state == "hasData") { + headerPath = loadableFileInfo.data?.path; + if (headerPath == "~") { + headerPath = `~ (${loadableFileInfo.data?.dir + "/" + loadableFileInfo.data?.name})`; + } + } + if (!isBlank(headerPath) && headerPath != "/" && headerPath.endsWith("/")) { + headerPath = headerPath.slice(0, -1); + } + const viewTextChildren: HeaderElem[] = [ + { + elemtype: "text", + text: headerPath, + ref: this.previewTextRef, + className: "preview-filename", + onClick: () => this.toggleOpenFileModal(), + }, + ]; + let saveClassName = "grey"; + if (get(this.newFileContent) !== null) { + saveClassName = "green"; + } + if (isCeView) { + const fileInfo = globalStore.get(this.loadableFileInfo); + if (fileInfo.state != "hasData") { + viewTextChildren.push({ + elemtype: "textbutton", + text: "Loading ...", + className: clsx( + `grey warning border-radius-4 vertical-padding-2 horizontal-padding-10 font-size-11 font-weight-500` + ), + onClick: () => {}, + }); + } else if (fileInfo.data.readonly) { + viewTextChildren.push({ + elemtype: "textbutton", + text: "Read Only", + className: clsx( + `yellow warning border-radius-4 vertical-padding-2 horizontal-padding-10 font-size-11 font-weight-500` + ), + onClick: () => {}, + }); + } else { + viewTextChildren.push({ + elemtype: "textbutton", + text: "Save", + className: clsx( + `${saveClassName} warning border-radius-4 vertical-padding-2 horizontal-padding-10 font-size-11 font-weight-500` + ), + onClick: () => fireAndForget(this.handleFileSave.bind(this)), + }); + } + if (get(this.canPreview)) { + viewTextChildren.push({ + elemtype: "textbutton", + text: "Preview", + className: + "grey border-radius-4 vertical-padding-2 horizontal-padding-10 font-size-11 font-weight-500", + onClick: () => fireAndForget(() => this.setEditMode(false)), + }); + } + } else if (get(this.canPreview)) { + viewTextChildren.push({ + elemtype: "textbutton", + text: "Edit", + className: + "grey border-radius-4 vertical-padding-2 horizontal-padding-10 font-size-11 font-weight-500", + onClick: () => fireAndForget(() => this.setEditMode(true)), + }); + } + return [ + { + elemtype: "div", + children: viewTextChildren, + }, + ] as HeaderElem[]; + }); + this.preIconButton = atom((get) => { + const connStatus = get(this.connStatus); + if (connStatus?.status != "connected") { + return null; + } + const mimeType = jotaiLoadableValue(get(this.fileMimeTypeLoadable), ""); + const metaPath = get(this.metaFilePath); + if (mimeType == "directory" && metaPath == "/") { + return null; + } + return { + elemtype: "iconbutton", + icon: "chevron-left", + click: this.goParentDirectory.bind(this), + }; + }); + this.endIconButtons = atom((get) => { + const connStatus = get(this.connStatus); + if (connStatus?.status != "connected") { + return null; + } + const mimeType = jotaiLoadableValue(get(this.fileMimeTypeLoadable), ""); + const loadableSV = get(this.loadableSpecializedView); + const isCeView = loadableSV.state == "hasData" && loadableSV.data.specializedView == "codeedit"; + if (mimeType == "directory") { + const 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?.(), + }, + ] as IconButtonDecl[]; + } else if (!isCeView && mimeType?.startsWith("text/markdown")) { + return [ + { + elemtype: "iconbutton", + icon: "book", + title: "Table of Contents", + click: () => this.markdownShowTocToggle(), + }, + ] as IconButtonDecl[]; + } + return null; + }); + this.metaFilePath = atom((get) => { + const file = get(this.blockAtom)?.meta?.file; + if (isBlank(file)) { + return "~"; + } + return file; + }); + this.statFilePath = atom>(async (get) => { + const fileInfo = await get(this.statFile); + return fileInfo?.path; + }); + this.connection = atom>(async (get) => { + const connName = get(this.blockAtom)?.meta?.connection; + try { + await RpcApi.ConnEnsureCommand(TabRpcClient, { connname: connName }, { timeout: 60000 }); + globalStore.set(this.connectionError, ""); + } catch (e) { + globalStore.set(this.connectionError, e as string); + } + return connName; + }); + this.connectionImmediate = atom((get) => { + return get(this.blockAtom)?.meta?.connection; + }); + this.statFile = atom>(async (get) => { + const fileName = get(this.metaFilePath); + const path = await this.formatRemoteUri(fileName, get); + if (fileName == null) { + return null; + } + try { + const statFile = await RpcApi.FileInfoCommand(TabRpcClient, { + info: { + path, + }, + }); + return statFile; + } catch (e) { + const errorStatus: ErrorMsg = { + status: "File Read Failed", + text: `${e}`, + }; + globalStore.set(this.errorMsgAtom, errorStatus); + } + }); + this.fileMimeType = atom>(async (get) => { + const fileInfo = await get(this.statFile); + return fileInfo?.mimetype; + }); + this.fileMimeTypeLoadable = loadable(this.fileMimeType); + this.newFileContent = atom(null) as PrimitiveAtom; + this.goParentDirectory = this.goParentDirectory.bind(this); + + const fullFileAtom = atom>(async (get) => { + const fileName = get(this.metaFilePath); + const path = await this.formatRemoteUri(fileName, get); + if (fileName == null) { + return null; + } + try { + const file = await RpcApi.FileReadCommand(TabRpcClient, { + info: { + path, + }, + }); + return file; + } catch (e) { + const errorStatus: ErrorMsg = { + status: "File Read Failed", + text: `${e}`, + }; + globalStore.set(this.errorMsgAtom, errorStatus); + } + }); + + this.fileContentSaved = atom(null) as PrimitiveAtom; + const fileContentAtom = atom( + async (get) => { + const newContent = get(this.newFileContent); + if (newContent != null) { + return newContent; + } + const savedContent = get(this.fileContentSaved); + if (savedContent != null) { + return savedContent; + } + const fullFile = await get(fullFileAtom); + return base64ToString(fullFile?.data64); + }, + (_, set, update: string) => { + set(this.fileContentSaved, update); + } + ); + + this.fullFile = fullFileAtom; + this.fileContent = fileContentAtom; + + this.specializedView = atom>(async (get) => { + return this.getSpecializedView(get); + }); + this.loadableSpecializedView = loadable(this.specializedView); + this.canPreview = atom(false); + this.loadableFileInfo = loadable(this.statFile); + this.connStatus = atom((get) => { + const blockData = get(this.blockAtom); + const connName = blockData?.meta?.connection; + const connAtom = getConnStatusAtom(connName); + return get(connAtom); + }); + + this.noPadding = atom(true); + } + + markdownShowTocToggle() { + globalStore.set(this.markdownShowToc, !globalStore.get(this.markdownShowToc)); + } + + get viewComponent(): ViewComponent { + return PreviewView; + } + + async getSpecializedView(getFn: Getter): Promise<{ specializedView?: string; errorStr?: string }> { + const mimeType = await getFn(this.fileMimeType); + const fileInfo = await getFn(this.statFile); + const fileName = fileInfo?.name; + const connErr = getFn(this.connectionError); + const editMode = getFn(this.editMode); + const genErr = getFn(this.errorMsgAtom); + + if (!fileInfo) { + return { errorStr: `Load Error: ${genErr?.text}` }; + } + if (connErr != "") { + return { errorStr: `Connection Error: ${connErr}` }; + } + if (fileInfo?.notfound) { + return { specializedView: "codeedit" }; + } + if (mimeType == null) { + return { errorStr: `Unable to determine mimetype for: ${fileInfo.path}` }; + } + if (isStreamingType(mimeType)) { + return { specializedView: "streaming" }; + } + if (!fileInfo) { + const fileNameStr = fileName ? " " + JSON.stringify(fileName) : ""; + return { errorStr: "File Not Found" + fileNameStr }; + } + if (fileInfo.size > MaxFileSize) { + return { errorStr: "File Too Large to Preiview (10 MB Max)" }; + } + if (mimeType == "text/csv" && fileInfo.size > MaxCSVSize) { + return { errorStr: "CSV File Too Large to Preiview (1 MB Max)" }; + } + if (mimeType == "directory") { + return { specializedView: "directory" }; + } + if (mimeType == "text/csv") { + if (editMode) { + return { specializedView: "codeedit" }; + } + return { specializedView: "csv" }; + } + if (mimeType.startsWith("text/markdown")) { + if (editMode) { + return { specializedView: "codeedit" }; + } + return { specializedView: "markdown" }; + } + if (isTextFile(mimeType) || fileInfo.size == 0) { + return { specializedView: "codeedit" }; + } + return { errorStr: `Preview (${mimeType})` }; + } + + updateOpenFileModalAndError(isOpen, errorMsg = null) { + globalStore.set(this.openFileModal, isOpen); + globalStore.set(this.openFileError, errorMsg); + if (isOpen) { + globalStore.set(this.openFileModalDelay, true); + } else { + const delayVal = globalStore.get(this.openFileModalDelay); + if (delayVal) { + setTimeout(() => { + globalStore.set(this.openFileModalDelay, false); + }, 200); + } + } + } + + toggleOpenFileModal() { + const modalOpen = globalStore.get(this.openFileModal); + const delayVal = globalStore.get(this.openFileModalDelay); + if (!modalOpen && delayVal) { + return; + } + this.updateOpenFileModalAndError(!modalOpen); + } + + async goHistory(newPath: string) { + let fileName = globalStore.get(this.metaFilePath); + if (fileName == null) { + fileName = ""; + } + const blockMeta = globalStore.get(this.blockAtom)?.meta; + const updateMeta = goHistory("file", fileName, newPath, blockMeta); + if (updateMeta == null) { + return; + } + const blockOref = WOS.makeORef("block", this.blockId); + await services.ObjectService.UpdateObjectMeta(blockOref, updateMeta); + + // Clear the saved file buffers + globalStore.set(this.fileContentSaved, null); + globalStore.set(this.newFileContent, null); + } + + async goParentDirectory({ fileInfo = null }: { fileInfo?: FileInfo | null }) { + // optional parameter needed for recursive case + const defaultFileInfo = await globalStore.get(this.statFile); + if (fileInfo === null) { + fileInfo = defaultFileInfo; + } + if (fileInfo == null) { + this.updateOpenFileModalAndError(false); + return true; + } + try { + this.updateOpenFileModalAndError(false); + await this.goHistory(fileInfo.dir); + refocusNode(this.blockId); + } catch (e) { + globalStore.set(this.openFileError, e.message); + console.error("Error opening file", fileInfo.dir, e); + } + } + + async goHistoryBack() { + const blockMeta = globalStore.get(this.blockAtom)?.meta; + const curPath = globalStore.get(this.metaFilePath); + const updateMeta = goHistoryBack("file", curPath, blockMeta, true); + if (updateMeta == null) { + return; + } + updateMeta.edit = false; + const blockOref = WOS.makeORef("block", this.blockId); + await services.ObjectService.UpdateObjectMeta(blockOref, updateMeta); + } + + async goHistoryForward() { + const blockMeta = globalStore.get(this.blockAtom)?.meta; + const curPath = globalStore.get(this.metaFilePath); + const updateMeta = goHistoryForward("file", curPath, blockMeta); + if (updateMeta == null) { + return; + } + updateMeta.edit = false; + const blockOref = WOS.makeORef("block", this.blockId); + await services.ObjectService.UpdateObjectMeta(blockOref, updateMeta); + } + + async setEditMode(edit: boolean) { + const blockMeta = globalStore.get(this.blockAtom)?.meta; + const blockOref = WOS.makeORef("block", this.blockId); + await services.ObjectService.UpdateObjectMeta(blockOref, { ...blockMeta, edit }); + } + + async handleFileSave() { + const filePath = await globalStore.get(this.statFilePath); + if (filePath == null) { + return; + } + const newFileContent = globalStore.get(this.newFileContent); + if (newFileContent == null) { + console.log("not saving file, newFileContent is null"); + return; + } + try { + await RpcApi.FileWriteCommand(TabRpcClient, { + info: { + path: await this.formatRemoteUri(filePath, globalStore.get), + }, + data64: stringToBase64(newFileContent), + }); + globalStore.set(this.fileContent, newFileContent); + globalStore.set(this.newFileContent, null); + console.log("saved file", filePath); + } catch (e) { + const errorStatus: ErrorMsg = { + status: "Save Failed", + text: `${e}`, + }; + globalStore.set(this.errorMsgAtom, errorStatus); + } + } + + async handleFileRevert() { + const fileContent = await globalStore.get(this.fileContent); + this.monacoRef.current?.setValue(fileContent); + globalStore.set(this.newFileContent, null); + } + + async handleOpenFile(filePath: string) { + const conn = globalStore.get(this.connectionImmediate); + if (!isBlank(conn) && conn.startsWith("aws:")) { + if (!isBlank(filePath) && filePath != "/" && filePath.startsWith("/")) { + filePath = filePath.substring(1); + } + } + const fileInfo = await globalStore.get(this.statFile); + this.updateOpenFileModalAndError(false); + if (fileInfo == null) { + return true; + } + try { + this.goHistory(filePath); + refocusNode(this.blockId); + } catch (e) { + globalStore.set(this.openFileError, e.message); + console.error("Error opening file", filePath, e); + } + } + + isSpecializedView(sv: string): boolean { + const loadableSV = globalStore.get(this.loadableSpecializedView); + return loadableSV.state == "hasData" && loadableSV.data.specializedView == sv; + } + + getSettingsMenuItems(): ContextMenuItem[] { + const defaultFontSize = globalStore.get(getSettingsKeyAtom("editor:fontsize")) ?? 12; + const blockData = globalStore.get(this.blockAtom); + const overrideFontSize = blockData?.meta?.["editor:fontsize"]; + const menuItems: ContextMenuItem[] = []; + menuItems.push({ + label: "Copy Full Path", + click: () => + fireAndForget(async () => { + const filePath = await globalStore.get(this.statFilePath); + if (filePath == null) { + return; + } + const conn = await globalStore.get(this.connection); + if (conn) { + // remote path + await navigator.clipboard.writeText(formatRemoteUri(filePath, conn)); + } else { + // local path + await navigator.clipboard.writeText(filePath); + } + }), + }); + menuItems.push({ + label: "Copy File Name", + click: () => + fireAndForget(async () => { + const fileInfo = await globalStore.get(this.statFile); + if (fileInfo == null || fileInfo.name == null) { + return; + } + await navigator.clipboard.writeText(fileInfo.name); + }), + }); + menuItems.push({ type: "separator" }); + const fontSizeSubMenu: ContextMenuItem[] = [6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18].map( + (fontSize: number) => { + return { + label: fontSize.toString() + "px", + type: "checkbox", + checked: overrideFontSize == fontSize, + click: () => { + RpcApi.SetMetaCommand(TabRpcClient, { + oref: WOS.makeORef("block", this.blockId), + meta: { "editor:fontsize": fontSize }, + }); + }, + }; + } + ); + fontSizeSubMenu.unshift({ + label: "Default (" + defaultFontSize + "px)", + type: "checkbox", + checked: overrideFontSize == null, + click: () => { + RpcApi.SetMetaCommand(TabRpcClient, { + oref: WOS.makeORef("block", this.blockId), + meta: { "editor:fontsize": null }, + }); + }, + }); + menuItems.push({ + label: "Editor Font Size", + submenu: fontSizeSubMenu, + }); + const finfo = jotaiLoadableValue(globalStore.get(this.loadableFileInfo), null); + addOpenMenuItems(menuItems, globalStore.get(this.connectionImmediate), finfo); + const loadableSV = globalStore.get(this.loadableSpecializedView); + const wordWrapAtom = getOverrideConfigAtom(this.blockId, "editor:wordwrap"); + const wordWrap = globalStore.get(wordWrapAtom) ?? false; + if (loadableSV.state == "hasData") { + if (loadableSV.data.specializedView == "codeedit") { + if (globalStore.get(this.newFileContent) != null) { + menuItems.push({ type: "separator" }); + menuItems.push({ + label: "Save File", + click: () => fireAndForget(this.handleFileSave.bind(this)), + }); + menuItems.push({ + label: "Revert File", + click: () => fireAndForget(this.handleFileRevert.bind(this)), + }); + } + menuItems.push({ type: "separator" }); + menuItems.push({ + label: "Word Wrap", + type: "checkbox", + checked: wordWrap, + click: () => + fireAndForget(async () => { + const blockOref = WOS.makeORef("block", this.blockId); + await services.ObjectService.UpdateObjectMeta(blockOref, { + "editor:wordwrap": !wordWrap, + }); + }), + }); + } + } + return menuItems; + } + + giveFocus(): boolean { + const openModalOpen = globalStore.get(this.openFileModal); + if (openModalOpen) { + this.openFileModalGiveFocusRef.current?.(); + return true; + } + if (this.monacoRef.current) { + this.monacoRef.current.focus(); + return true; + } + return false; + } + + keyDownHandler(e: WaveKeyboardEvent): boolean { + if (checkKeyPressed(e, "Cmd:ArrowLeft")) { + fireAndForget(this.goHistoryBack.bind(this)); + return true; + } + if (checkKeyPressed(e, "Cmd:ArrowRight")) { + fireAndForget(this.goHistoryForward.bind(this)); + return true; + } + if (checkKeyPressed(e, "Cmd:ArrowUp")) { + // handle up directory + fireAndForget(() => this.goParentDirectory({})); + return true; + } + if (checkKeyPressed(e, "Cmd:o")) { + this.toggleOpenFileModal(); + return true; + } + const canPreview = globalStore.get(this.canPreview); + if (canPreview) { + if (checkKeyPressed(e, "Cmd:e")) { + const editMode = globalStore.get(this.editMode); + fireAndForget(() => this.setEditMode(!editMode)); + return true; + } + } + if (this.directoryKeyDownHandler) { + const handled = this.directoryKeyDownHandler(e); + if (handled) { + return true; + } + } + if (this.codeEditKeyDownHandler) { + const handled = this.codeEditKeyDownHandler(e); + if (handled) { + return true; + } + } + return false; + } + + async formatRemoteUri(path: string, get: Getter): Promise { + return formatRemoteUri(path, await get(this.connection)); + } +} diff --git a/frontend/app/view/preview/preview.tsx b/frontend/app/view/preview/preview.tsx index 563e0a36db..4ff2ece996 100644 --- a/frontend/app/view/preview/preview.tsx +++ b/frontend/app/view/preview/preview.tsx @@ -1,49 +1,32 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { BlockNodeModel } from "@/app/block/blocktypes"; import { Button } from "@/app/element/button"; import { CopyButton } from "@/app/element/copybutton"; import { CenteredDiv } from "@/app/element/quickelems"; -import { ContextMenuModel } from "@/app/store/contextmenu"; import { tryReinjectKey } from "@/app/store/keymodel"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { BlockHeaderSuggestionControl } from "@/app/suggestion/suggestion"; import { CodeEditor } from "@/app/view/codeeditor/codeeditor"; import { Markdown } from "@/element/markdown"; -import { getConnStatusAtom, getOverrideConfigAtom, getSettingsKeyAtom, globalStore, refocusNode } from "@/store/global"; -import * as services from "@/store/services"; -import * as WOS from "@/store/wos"; +import { getOverrideConfigAtom, globalStore } from "@/store/global"; import { getWebServerEndpoint } from "@/util/endpoints"; -import { goHistory, goHistoryBack, goHistoryForward } from "@/util/historyutil"; import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil"; -import { addOpenMenuItems } from "@/util/previewutil"; -import { base64ToString, fireAndForget, isBlank, jotaiLoadableValue, makeConnRoute, stringToBase64 } from "@/util/util"; +import { fireAndForget, isBlank, makeConnRoute } from "@/util/util"; import { formatRemoteUri } from "@/util/waveutil"; import { Monaco } from "@monaco-editor/react"; import clsx from "clsx"; -import { Atom, atom, Getter, PrimitiveAtom, useAtom, useAtomValue, useSetAtom, WritableAtom } from "jotai"; -import { loadable } from "jotai/utils"; +import { useAtom, useAtomValue, useSetAtom } from "jotai"; import type * as MonacoTypes from "monaco-editor/esm/vs/editor/editor.api"; import { OverlayScrollbarsComponent } from "overlayscrollbars-react"; -import { createRef, memo, useCallback, useEffect, useMemo } from "react"; +import { memo, useCallback, useEffect, useMemo } from "react"; import { TransformComponent, TransformWrapper, useControls } from "react-zoom-pan-pinch"; import { CSVView } from "./csvview"; import { DirectoryPreview } from "./directorypreview"; +import type { PreviewModel } from "./preview-model"; import "./preview.scss"; -const MaxFileSize = 1024 * 1024 * 10; // 10MB -const MaxCSVSize = 1024 * 1024 * 1; // 1MB - -// TODO drive this using config -const BOOKMARKS: { label: string; path: string }[] = [ - { label: "Home", path: "~" }, - { label: "Desktop", path: "~/Desktop" }, - { label: "Downloads", path: "~/Downloads" }, - { label: "Documents", path: "~/Documents" }, - { label: "Root", path: "/" }, -]; type SpecializedViewProps = { model: PreviewModel; @@ -58,40 +41,6 @@ const SpecializedViewMap: { [view: string]: ({ model }: SpecializedViewProps) => directory: DirectoryPreview, }; -const textApplicationMimetypes = [ - "application/sql", - "application/x-php", - "application/x-pem-file", - "application/x-httpd-php", - "application/liquid", - "application/graphql", - "application/javascript", - "application/typescript", - "application/x-javascript", - "application/x-typescript", - "application/dart", - "application/vnd.dart", - "application/x-ruby", - "application/sql", - "application/wasm", - "application/x-latex", - "application/x-sh", - "application/x-python", - "application/x-awk", -]; - -function isTextFile(mimeType: string): boolean { - if (mimeType == null) { - return false; - } - return ( - mimeType.startsWith("text/") || - textApplicationMimetypes.includes(mimeType) || - (mimeType.startsWith("application/") && - (mimeType.includes("json") || mimeType.includes("yaml") || mimeType.includes("toml"))) || - mimeType.includes("xml") - ); -} function canPreview(mimeType: string): boolean { if (mimeType == null) { @@ -100,750 +49,6 @@ function canPreview(mimeType: string): boolean { return mimeType.startsWith("text/markdown") || mimeType.startsWith("text/csv"); } -function isStreamingType(mimeType: string): boolean { - if (mimeType == null) { - return false; - } - return ( - mimeType.startsWith("application/pdf") || - mimeType.startsWith("video/") || - mimeType.startsWith("audio/") || - mimeType.startsWith("image/") - ); -} -export class PreviewModel implements ViewModel { - viewType: string; - blockId: string; - nodeModel: BlockNodeModel; - noPadding?: Atom; - blockAtom: Atom; - viewIcon: Atom; - viewName: Atom; - viewText: Atom; - preIconButton: Atom; - endIconButtons: Atom; - previewTextRef: React.RefObject; - editMode: Atom; - canPreview: PrimitiveAtom; - specializedView: Atom>; - loadableSpecializedView: Atom>; - manageConnection: Atom; - connStatus: Atom; - filterOutNowsh?: Atom; - - metaFilePath: Atom; - statFilePath: Atom>; - loadableFileInfo: Atom>; - connection: Atom>; - connectionImmediate: Atom; - statFile: Atom>; - fullFile: Atom>; - fileMimeType: Atom>; - fileMimeTypeLoadable: Atom>; - fileContentSaved: PrimitiveAtom; - fileContent: WritableAtom, [string], void>; - newFileContent: PrimitiveAtom; - connectionError: PrimitiveAtom; - errorMsgAtom: PrimitiveAtom; - - openFileModal: PrimitiveAtom; - openFileModalDelay: PrimitiveAtom; - openFileError: PrimitiveAtom; - openFileModalGiveFocusRef: React.MutableRefObject<() => boolean>; - - markdownShowToc: PrimitiveAtom; - - monacoRef: React.MutableRefObject; - - showHiddenFiles: PrimitiveAtom; - refreshVersion: PrimitiveAtom; - refreshCallback: () => void; - directoryKeyDownHandler: (waveEvent: WaveKeyboardEvent) => boolean; - codeEditKeyDownHandler: (waveEvent: WaveKeyboardEvent) => boolean; - - showS3 = atom(true); - - constructor(blockId: string, nodeModel: BlockNodeModel) { - this.viewType = "preview"; - this.blockId = blockId; - this.nodeModel = nodeModel; - let showHiddenFiles = globalStore.get(getSettingsKeyAtom("preview:showhiddenfiles")) ?? true; - this.showHiddenFiles = atom(showHiddenFiles); - this.refreshVersion = atom(0); - this.previewTextRef = createRef(); - this.openFileModal = atom(false); - this.openFileModalDelay = atom(false); - this.openFileError = atom(null) as PrimitiveAtom; - this.openFileModalGiveFocusRef = createRef(); - this.manageConnection = atom(true); - this.blockAtom = WOS.getWaveObjectAtom(`block:${blockId}`); - this.markdownShowToc = atom(false); - this.filterOutNowsh = atom(true); - this.monacoRef = createRef(); - this.connectionError = atom(""); - this.errorMsgAtom = atom(null) as PrimitiveAtom; - this.viewIcon = atom((get) => { - const blockData = get(this.blockAtom); - if (blockData?.meta?.icon) { - return blockData.meta.icon; - } - const connStatus = get(this.connStatus); - if (connStatus?.status != "connected") { - return null; - } - const mimeTypeLoadable = get(this.fileMimeTypeLoadable); - const mimeType = jotaiLoadableValue(mimeTypeLoadable, ""); - if (mimeType == "directory") { - return { - elemtype: "iconbutton", - icon: "folder-open", - longClick: (e: React.MouseEvent) => { - const menuItems: ContextMenuItem[] = BOOKMARKS.map((bookmark) => ({ - label: `Go to ${bookmark.label} (${bookmark.path})`, - click: () => this.goHistory(bookmark.path), - })); - ContextMenuModel.showContextMenu(menuItems, e); - }, - }; - } - return iconForFile(mimeType); - }); - this.editMode = atom((get) => { - const blockData = get(this.blockAtom); - return blockData?.meta?.edit ?? false; - }); - this.viewName = atom("Preview"); - this.viewText = atom((get) => { - let headerPath = get(this.metaFilePath); - const connStatus = get(this.connStatus); - if (connStatus?.status != "connected") { - return [ - { - elemtype: "text", - text: headerPath, - className: "preview-filename", - }, - ]; - } - const loadableSV = get(this.loadableSpecializedView); - const isCeView = loadableSV.state == "hasData" && loadableSV.data.specializedView == "codeedit"; - const loadableFileInfo = get(this.loadableFileInfo); - if (loadableFileInfo.state == "hasData") { - headerPath = loadableFileInfo.data?.path; - if (headerPath == "~") { - headerPath = `~ (${loadableFileInfo.data?.dir + "/" + loadableFileInfo.data?.name})`; - } - } - if (!isBlank(headerPath) && headerPath != "/" && headerPath.endsWith("/")) { - headerPath = headerPath.slice(0, -1); - } - const viewTextChildren: HeaderElem[] = [ - { - elemtype: "text", - text: headerPath, - ref: this.previewTextRef, - className: "preview-filename", - onClick: () => this.toggleOpenFileModal(), - }, - ]; - let saveClassName = "grey"; - if (get(this.newFileContent) !== null) { - saveClassName = "green"; - } - if (isCeView) { - const fileInfo = globalStore.get(this.loadableFileInfo); - if (fileInfo.state != "hasData") { - viewTextChildren.push({ - elemtype: "textbutton", - text: "Loading ...", - className: clsx( - `grey warning border-radius-4 vertical-padding-2 horizontal-padding-10 font-size-11 font-weight-500` - ), - onClick: () => {}, - }); - } else if (fileInfo.data.readonly) { - viewTextChildren.push({ - elemtype: "textbutton", - text: "Read Only", - className: clsx( - `yellow warning border-radius-4 vertical-padding-2 horizontal-padding-10 font-size-11 font-weight-500` - ), - onClick: () => {}, - }); - } else { - viewTextChildren.push({ - elemtype: "textbutton", - text: "Save", - className: clsx( - `${saveClassName} warning border-radius-4 vertical-padding-2 horizontal-padding-10 font-size-11 font-weight-500` - ), - onClick: () => fireAndForget(this.handleFileSave.bind(this)), - }); - } - if (get(this.canPreview)) { - viewTextChildren.push({ - elemtype: "textbutton", - text: "Preview", - className: - "grey border-radius-4 vertical-padding-2 horizontal-padding-10 font-size-11 font-weight-500", - onClick: () => fireAndForget(() => this.setEditMode(false)), - }); - } - } else if (get(this.canPreview)) { - viewTextChildren.push({ - elemtype: "textbutton", - text: "Edit", - className: - "grey border-radius-4 vertical-padding-2 horizontal-padding-10 font-size-11 font-weight-500", - onClick: () => fireAndForget(() => this.setEditMode(true)), - }); - } - return [ - { - elemtype: "div", - children: viewTextChildren, - }, - ] as HeaderElem[]; - }); - this.preIconButton = atom((get) => { - const connStatus = get(this.connStatus); - if (connStatus?.status != "connected") { - return null; - } - const mimeType = jotaiLoadableValue(get(this.fileMimeTypeLoadable), ""); - const metaPath = get(this.metaFilePath); - if (mimeType == "directory" && metaPath == "/") { - return null; - } - return { - elemtype: "iconbutton", - icon: "chevron-left", - click: this.goParentDirectory.bind(this), - }; - }); - this.endIconButtons = atom((get) => { - const connStatus = get(this.connStatus); - if (connStatus?.status != "connected") { - return null; - } - const mimeType = jotaiLoadableValue(get(this.fileMimeTypeLoadable), ""); - const loadableSV = get(this.loadableSpecializedView); - const isCeView = loadableSV.state == "hasData" && loadableSV.data.specializedView == "codeedit"; - if (mimeType == "directory") { - const 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?.(), - }, - ] as IconButtonDecl[]; - } else if (!isCeView && mimeType?.startsWith("text/markdown")) { - return [ - { - elemtype: "iconbutton", - icon: "book", - title: "Table of Contents", - click: () => this.markdownShowTocToggle(), - }, - ] as IconButtonDecl[]; - } - return null; - }); - this.metaFilePath = atom((get) => { - const file = get(this.blockAtom)?.meta?.file; - if (isBlank(file)) { - return "~"; - } - return file; - }); - this.statFilePath = atom>(async (get) => { - const fileInfo = await get(this.statFile); - return fileInfo?.path; - }); - this.connection = atom>(async (get) => { - const connName = get(this.blockAtom)?.meta?.connection; - try { - await RpcApi.ConnEnsureCommand(TabRpcClient, { connname: connName }, { timeout: 60000 }); - globalStore.set(this.connectionError, ""); - } catch (e) { - globalStore.set(this.connectionError, e as string); - } - return connName; - }); - this.connectionImmediate = atom((get) => { - return get(this.blockAtom)?.meta?.connection; - }); - this.statFile = atom>(async (get) => { - const fileName = get(this.metaFilePath); - const path = await this.formatRemoteUri(fileName, get); - if (fileName == null) { - return null; - } - try { - const statFile = await RpcApi.FileInfoCommand(TabRpcClient, { - info: { - path, - }, - }); - return statFile; - } catch (e) { - const errorStatus: ErrorMsg = { - status: "File Read Failed", - text: `${e}`, - }; - globalStore.set(this.errorMsgAtom, errorStatus); - } - }); - this.fileMimeType = atom>(async (get) => { - const fileInfo = await get(this.statFile); - return fileInfo?.mimetype; - }); - this.fileMimeTypeLoadable = loadable(this.fileMimeType); - this.newFileContent = atom(null) as PrimitiveAtom; - this.goParentDirectory = this.goParentDirectory.bind(this); - - const fullFileAtom = atom>(async (get) => { - const fileName = get(this.metaFilePath); - const path = await this.formatRemoteUri(fileName, get); - if (fileName == null) { - return null; - } - try { - const file = await RpcApi.FileReadCommand(TabRpcClient, { - info: { - path, - }, - }); - return file; - } catch (e) { - const errorStatus: ErrorMsg = { - status: "File Read Failed", - text: `${e}`, - }; - globalStore.set(this.errorMsgAtom, errorStatus); - } - }); - - this.fileContentSaved = atom(null) as PrimitiveAtom; - const fileContentAtom = atom( - async (get) => { - const newContent = get(this.newFileContent); - if (newContent != null) { - return newContent; - } - const savedContent = get(this.fileContentSaved); - if (savedContent != null) { - return savedContent; - } - const fullFile = await get(fullFileAtom); - return base64ToString(fullFile?.data64); - }, - (_, set, update: string) => { - set(this.fileContentSaved, update); - } - ); - - this.fullFile = fullFileAtom; - this.fileContent = fileContentAtom; - - this.specializedView = atom>(async (get) => { - return this.getSpecializedView(get); - }); - this.loadableSpecializedView = loadable(this.specializedView); - this.canPreview = atom(false); - this.loadableFileInfo = loadable(this.statFile); - this.connStatus = atom((get) => { - const blockData = get(this.blockAtom); - const connName = blockData?.meta?.connection; - const connAtom = getConnStatusAtom(connName); - return get(connAtom); - }); - - this.noPadding = atom(true); - } - - markdownShowTocToggle() { - globalStore.set(this.markdownShowToc, !globalStore.get(this.markdownShowToc)); - } - - get viewComponent(): ViewComponent { - return PreviewView; - } - - async getSpecializedView(getFn: Getter): Promise<{ specializedView?: string; errorStr?: string }> { - const mimeType = await getFn(this.fileMimeType); - const fileInfo = await getFn(this.statFile); - const fileName = fileInfo?.name; - const connErr = getFn(this.connectionError); - const editMode = getFn(this.editMode); - const genErr = getFn(this.errorMsgAtom); - - if (!fileInfo) { - return { errorStr: `Load Error: ${genErr?.text}` }; - } - if (connErr != "") { - return { errorStr: `Connection Error: ${connErr}` }; - } - if (fileInfo?.notfound) { - return { specializedView: "codeedit" }; - } - if (mimeType == null) { - return { errorStr: `Unable to determine mimetype for: ${fileInfo.path}` }; - } - if (isStreamingType(mimeType)) { - return { specializedView: "streaming" }; - } - if (!fileInfo) { - const fileNameStr = fileName ? " " + JSON.stringify(fileName) : ""; - return { errorStr: "File Not Found" + fileNameStr }; - } - if (fileInfo.size > MaxFileSize) { - return { errorStr: "File Too Large to Preiview (10 MB Max)" }; - } - if (mimeType == "text/csv" && fileInfo.size > MaxCSVSize) { - return { errorStr: "CSV File Too Large to Preiview (1 MB Max)" }; - } - if (mimeType == "directory") { - return { specializedView: "directory" }; - } - if (mimeType == "text/csv") { - if (editMode) { - return { specializedView: "codeedit" }; - } - return { specializedView: "csv" }; - } - if (mimeType.startsWith("text/markdown")) { - if (editMode) { - return { specializedView: "codeedit" }; - } - return { specializedView: "markdown" }; - } - if (isTextFile(mimeType) || fileInfo.size == 0) { - return { specializedView: "codeedit" }; - } - return { errorStr: `Preview (${mimeType})` }; - } - - updateOpenFileModalAndError(isOpen, errorMsg = null) { - globalStore.set(this.openFileModal, isOpen); - globalStore.set(this.openFileError, errorMsg); - if (isOpen) { - globalStore.set(this.openFileModalDelay, true); - } else { - const delayVal = globalStore.get(this.openFileModalDelay); - if (delayVal) { - setTimeout(() => { - globalStore.set(this.openFileModalDelay, false); - }, 200); - } - } - } - - toggleOpenFileModal() { - const modalOpen = globalStore.get(this.openFileModal); - const delayVal = globalStore.get(this.openFileModalDelay); - if (!modalOpen && delayVal) { - return; - } - this.updateOpenFileModalAndError(!modalOpen); - } - - async goHistory(newPath: string) { - let fileName = globalStore.get(this.metaFilePath); - if (fileName == null) { - fileName = ""; - } - const blockMeta = globalStore.get(this.blockAtom)?.meta; - const updateMeta = goHistory("file", fileName, newPath, blockMeta); - if (updateMeta == null) { - return; - } - const blockOref = WOS.makeORef("block", this.blockId); - await services.ObjectService.UpdateObjectMeta(blockOref, updateMeta); - - // Clear the saved file buffers - globalStore.set(this.fileContentSaved, null); - globalStore.set(this.newFileContent, null); - } - - async goParentDirectory({ fileInfo = null }: { fileInfo?: FileInfo | null }) { - // optional parameter needed for recursive case - const defaultFileInfo = await globalStore.get(this.statFile); - if (fileInfo === null) { - fileInfo = defaultFileInfo; - } - if (fileInfo == null) { - this.updateOpenFileModalAndError(false); - return true; - } - try { - this.updateOpenFileModalAndError(false); - await this.goHistory(fileInfo.dir); - refocusNode(this.blockId); - } catch (e) { - globalStore.set(this.openFileError, e.message); - console.error("Error opening file", fileInfo.dir, e); - } - } - - async goHistoryBack() { - const blockMeta = globalStore.get(this.blockAtom)?.meta; - const curPath = globalStore.get(this.metaFilePath); - const updateMeta = goHistoryBack("file", curPath, blockMeta, true); - if (updateMeta == null) { - return; - } - updateMeta.edit = false; - const blockOref = WOS.makeORef("block", this.blockId); - await services.ObjectService.UpdateObjectMeta(blockOref, updateMeta); - } - - async goHistoryForward() { - const blockMeta = globalStore.get(this.blockAtom)?.meta; - const curPath = globalStore.get(this.metaFilePath); - const updateMeta = goHistoryForward("file", curPath, blockMeta); - if (updateMeta == null) { - return; - } - updateMeta.edit = false; - const blockOref = WOS.makeORef("block", this.blockId); - await services.ObjectService.UpdateObjectMeta(blockOref, updateMeta); - } - - async setEditMode(edit: boolean) { - const blockMeta = globalStore.get(this.blockAtom)?.meta; - const blockOref = WOS.makeORef("block", this.blockId); - await services.ObjectService.UpdateObjectMeta(blockOref, { ...blockMeta, edit }); - } - - async handleFileSave() { - const filePath = await globalStore.get(this.statFilePath); - if (filePath == null) { - return; - } - const newFileContent = globalStore.get(this.newFileContent); - if (newFileContent == null) { - console.log("not saving file, newFileContent is null"); - return; - } - try { - await RpcApi.FileWriteCommand(TabRpcClient, { - info: { - path: await this.formatRemoteUri(filePath, globalStore.get), - }, - data64: stringToBase64(newFileContent), - }); - globalStore.set(this.fileContent, newFileContent); - globalStore.set(this.newFileContent, null); - console.log("saved file", filePath); - } catch (e) { - const errorStatus: ErrorMsg = { - status: "Save Failed", - text: `${e}`, - }; - globalStore.set(this.errorMsgAtom, errorStatus); - } - } - - async handleFileRevert() { - const fileContent = await globalStore.get(this.fileContent); - this.monacoRef.current?.setValue(fileContent); - globalStore.set(this.newFileContent, null); - } - - async handleOpenFile(filePath: string) { - const conn = globalStore.get(this.connectionImmediate); - if (!isBlank(conn) && conn.startsWith("aws:")) { - if (!isBlank(filePath) && filePath != "/" && filePath.startsWith("/")) { - filePath = filePath.substring(1); - } - } - const fileInfo = await globalStore.get(this.statFile); - this.updateOpenFileModalAndError(false); - if (fileInfo == null) { - return true; - } - try { - this.goHistory(filePath); - refocusNode(this.blockId); - } catch (e) { - globalStore.set(this.openFileError, e.message); - console.error("Error opening file", filePath, e); - } - } - - isSpecializedView(sv: string): boolean { - const loadableSV = globalStore.get(this.loadableSpecializedView); - return loadableSV.state == "hasData" && loadableSV.data.specializedView == sv; - } - - getSettingsMenuItems(): ContextMenuItem[] { - const defaultFontSize = globalStore.get(getSettingsKeyAtom("editor:fontsize")) ?? 12; - const blockData = globalStore.get(this.blockAtom); - const overrideFontSize = blockData?.meta?.["editor:fontsize"]; - const menuItems: ContextMenuItem[] = []; - menuItems.push({ - label: "Copy Full Path", - click: () => - fireAndForget(async () => { - const filePath = await globalStore.get(this.statFilePath); - if (filePath == null) { - return; - } - const conn = await globalStore.get(this.connection); - if (conn) { - // remote path - await navigator.clipboard.writeText(formatRemoteUri(filePath, conn)); - } else { - // local path - await navigator.clipboard.writeText(filePath); - } - }), - }); - menuItems.push({ - label: "Copy File Name", - click: () => - fireAndForget(async () => { - const fileInfo = await globalStore.get(this.statFile); - if (fileInfo == null || fileInfo.name == null) { - return; - } - await navigator.clipboard.writeText(fileInfo.name); - }), - }); - menuItems.push({ type: "separator" }); - const fontSizeSubMenu: ContextMenuItem[] = [6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18].map( - (fontSize: number) => { - return { - label: fontSize.toString() + "px", - type: "checkbox", - checked: overrideFontSize == fontSize, - click: () => { - RpcApi.SetMetaCommand(TabRpcClient, { - oref: WOS.makeORef("block", this.blockId), - meta: { "editor:fontsize": fontSize }, - }); - }, - }; - } - ); - fontSizeSubMenu.unshift({ - label: "Default (" + defaultFontSize + "px)", - type: "checkbox", - checked: overrideFontSize == null, - click: () => { - RpcApi.SetMetaCommand(TabRpcClient, { - oref: WOS.makeORef("block", this.blockId), - meta: { "editor:fontsize": null }, - }); - }, - }); - menuItems.push({ - label: "Editor Font Size", - submenu: fontSizeSubMenu, - }); - const finfo = jotaiLoadableValue(globalStore.get(this.loadableFileInfo), null); - addOpenMenuItems(menuItems, globalStore.get(this.connectionImmediate), finfo); - const loadableSV = globalStore.get(this.loadableSpecializedView); - const wordWrapAtom = getOverrideConfigAtom(this.blockId, "editor:wordwrap"); - const wordWrap = globalStore.get(wordWrapAtom) ?? false; - if (loadableSV.state == "hasData") { - if (loadableSV.data.specializedView == "codeedit") { - if (globalStore.get(this.newFileContent) != null) { - menuItems.push({ type: "separator" }); - menuItems.push({ - label: "Save File", - click: () => fireAndForget(this.handleFileSave.bind(this)), - }); - menuItems.push({ - label: "Revert File", - click: () => fireAndForget(this.handleFileRevert.bind(this)), - }); - } - menuItems.push({ type: "separator" }); - menuItems.push({ - label: "Word Wrap", - type: "checkbox", - checked: wordWrap, - click: () => - fireAndForget(async () => { - const blockOref = WOS.makeORef("block", this.blockId); - await services.ObjectService.UpdateObjectMeta(blockOref, { - "editor:wordwrap": !wordWrap, - }); - }), - }); - } - } - return menuItems; - } - - giveFocus(): boolean { - const openModalOpen = globalStore.get(this.openFileModal); - if (openModalOpen) { - this.openFileModalGiveFocusRef.current?.(); - return true; - } - if (this.monacoRef.current) { - this.monacoRef.current.focus(); - return true; - } - return false; - } - - keyDownHandler(e: WaveKeyboardEvent): boolean { - if (checkKeyPressed(e, "Cmd:ArrowLeft")) { - fireAndForget(this.goHistoryBack.bind(this)); - return true; - } - if (checkKeyPressed(e, "Cmd:ArrowRight")) { - fireAndForget(this.goHistoryForward.bind(this)); - return true; - } - if (checkKeyPressed(e, "Cmd:ArrowUp")) { - // handle up directory - fireAndForget(() => this.goParentDirectory({})); - return true; - } - if (checkKeyPressed(e, "Cmd:o")) { - this.toggleOpenFileModal(); - return true; - } - const canPreview = globalStore.get(this.canPreview); - if (canPreview) { - if (checkKeyPressed(e, "Cmd:e")) { - const editMode = globalStore.get(this.editMode); - fireAndForget(() => this.setEditMode(!editMode)); - return true; - } - } - if (this.directoryKeyDownHandler) { - const handled = this.directoryKeyDownHandler(e); - if (handled) { - return true; - } - } - if (this.codeEditKeyDownHandler) { - const handled = this.codeEditKeyDownHandler(e); - if (handled) { - return true; - } - } - return false; - } - - async formatRemoteUri(path: string, get: Getter): Promise { - return formatRemoteUri(path, await get(this.connection)); - } -} function MarkdownPreview({ model }: SpecializedViewProps) { const connName = useAtomValue(model.connection); @@ -1016,33 +221,6 @@ function CSVViewPreview({ model, parentRef }: SpecializedViewProps) { return ; } -function iconForFile(mimeType: string): string { - if (mimeType == null) { - mimeType = "unknown"; - } - if (mimeType == "application/pdf") { - return "file-pdf"; - } else if (mimeType.startsWith("image/")) { - return "image"; - } else if (mimeType.startsWith("video/")) { - return "film"; - } else if (mimeType.startsWith("audio/")) { - return "headphones"; - } else if (mimeType.startsWith("text/markdown")) { - return "file-lines"; - } else if (mimeType == "text/csv") { - return "file-csv"; - } else if ( - mimeType.startsWith("text/") || - mimeType == "application/sql" || - (mimeType.startsWith("application/") && - (mimeType.includes("json") || mimeType.includes("yaml") || mimeType.includes("toml"))) - ) { - return "file-code"; - } else { - return "file"; - } -} const SpecializedView = memo(({ parentRef, model }: SpecializedViewProps) => { const specializedView = useAtomValue(model.specializedView); From 003a01c4f28ba0f7c5bdfce883b4970404c248dd Mon Sep 17 00:00:00 2001 From: sawka Date: Mon, 15 Sep 2025 11:14:52 -0700 Subject: [PATCH 2/3] more refactoring of preview views --- ...ctorypreview.tsx => preview-directory.tsx} | 0 frontend/app/view/preview/preview-edit.tsx | 79 ++++++++ .../app/view/preview/preview-markdown.tsx | 34 ++++ .../app/view/preview/preview-streaming.tsx | 89 ++++++++ frontend/app/view/preview/preview.tsx | 191 +----------------- 5 files changed, 210 insertions(+), 183 deletions(-) rename frontend/app/view/preview/{directorypreview.tsx => preview-directory.tsx} (100%) create mode 100644 frontend/app/view/preview/preview-edit.tsx create mode 100644 frontend/app/view/preview/preview-markdown.tsx create mode 100644 frontend/app/view/preview/preview-streaming.tsx diff --git a/frontend/app/view/preview/directorypreview.tsx b/frontend/app/view/preview/preview-directory.tsx similarity index 100% rename from frontend/app/view/preview/directorypreview.tsx rename to frontend/app/view/preview/preview-directory.tsx diff --git a/frontend/app/view/preview/preview-edit.tsx b/frontend/app/view/preview/preview-edit.tsx new file mode 100644 index 0000000000..3915424828 --- /dev/null +++ b/frontend/app/view/preview/preview-edit.tsx @@ -0,0 +1,79 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { tryReinjectKey } from "@/app/store/keymodel"; +import { CodeEditor } from "@/app/view/codeeditor/codeeditor"; +import { globalStore } from "@/store/global"; +import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil"; +import { fireAndForget } from "@/util/util"; +import { Monaco } from "@monaco-editor/react"; +import { useAtomValue, useSetAtom } from "jotai"; +import type * as MonacoTypes from "monaco-editor/esm/vs/editor/editor.api"; +import { useEffect } from "react"; +import type { SpecializedViewProps } from "./preview"; + +function CodeEditPreview({ model }: SpecializedViewProps) { + const fileContent = useAtomValue(model.fileContent); + const setNewFileContent = useSetAtom(model.newFileContent); + const fileInfo = useAtomValue(model.statFile); + const fileName = fileInfo?.name; + const blockMeta = useAtomValue(model.blockAtom)?.meta; + + function codeEditKeyDownHandler(e: WaveKeyboardEvent): boolean { + if (checkKeyPressed(e, "Cmd:e")) { + fireAndForget(() => model.setEditMode(false)); + return true; + } + if (checkKeyPressed(e, "Cmd:s") || checkKeyPressed(e, "Ctrl:s")) { + fireAndForget(model.handleFileSave.bind(model)); + return true; + } + if (checkKeyPressed(e, "Cmd:r")) { + fireAndForget(model.handleFileRevert.bind(model)); + return true; + } + return false; + } + + useEffect(() => { + model.codeEditKeyDownHandler = codeEditKeyDownHandler; + return () => { + model.codeEditKeyDownHandler = null; + model.monacoRef.current = null; + }; + }, []); + + function onMount(editor: MonacoTypes.editor.IStandaloneCodeEditor, monaco: Monaco): () => void { + model.monacoRef.current = editor; + + editor.onKeyDown((e: MonacoTypes.IKeyboardEvent) => { + const waveEvent = adaptFromReactOrNativeKeyEvent(e.browserEvent); + const handled = tryReinjectKey(waveEvent); + if (handled) { + e.stopPropagation(); + e.preventDefault(); + } + }); + + const isFocused = globalStore.get(model.nodeModel.isFocused); + if (isFocused) { + editor.focus(); + } + + return null; + } + + return ( + setNewFileContent(text)} + onMount={onMount} + /> + ); +} + +export { CodeEditPreview }; diff --git a/frontend/app/view/preview/preview-markdown.tsx b/frontend/app/view/preview/preview-markdown.tsx new file mode 100644 index 0000000000..87e8a336df --- /dev/null +++ b/frontend/app/view/preview/preview-markdown.tsx @@ -0,0 +1,34 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { Markdown } from "@/element/markdown"; +import { getOverrideConfigAtom } from "@/store/global"; +import { useAtomValue } from "jotai"; +import { useMemo } from "react"; +import type { SpecializedViewProps } from "./preview"; + +function MarkdownPreview({ model }: SpecializedViewProps) { + const connName = useAtomValue(model.connection); + const fileInfo = useAtomValue(model.statFile); + const fontSizeOverride = useAtomValue(getOverrideConfigAtom(model.blockId, "markdown:fontsize")); + const fixedFontSizeOverride = useAtomValue(getOverrideConfigAtom(model.blockId, "markdown:fixedfontsize")); + const resolveOpts: MarkdownResolveOpts = useMemo(() => { + return { + connName: connName, + baseDir: fileInfo.dir, + }; + }, [connName, fileInfo.dir]); + return ( +
+ +
+ ); +} + +export { MarkdownPreview }; diff --git a/frontend/app/view/preview/preview-streaming.tsx b/frontend/app/view/preview/preview-streaming.tsx new file mode 100644 index 0000000000..7fe9e56f97 --- /dev/null +++ b/frontend/app/view/preview/preview-streaming.tsx @@ -0,0 +1,89 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { Button } from "@/app/element/button"; +import { CenteredDiv } from "@/app/element/quickelems"; +import { getWebServerEndpoint } from "@/util/endpoints"; +import { formatRemoteUri } from "@/util/waveutil"; +import { useAtomValue } from "jotai"; +import { TransformComponent, TransformWrapper, useControls } from "react-zoom-pan-pinch"; +import type { SpecializedViewProps } from "./preview"; + +function ImageZooomControls() { + const { zoomIn, zoomOut, resetTransform } = useControls(); + + return ( +
+ + + +
+ ); +} + +function StreamingImagePreview({ url }: { url: string }) { + return ( +
+ + {({ zoomIn, zoomOut, resetTransform, ...rest }) => ( + <> + + + + + + )} + +
+ ); +} + +function StreamingPreview({ model }: SpecializedViewProps) { + const conn = useAtomValue(model.connection); + const fileInfo = useAtomValue(model.statFile); + const filePath = fileInfo.path; + const remotePath = formatRemoteUri(filePath, conn); + const usp = new URLSearchParams(); + usp.set("path", remotePath); + if (conn != null) { + usp.set("connection", conn); + } + const streamingUrl = `${getWebServerEndpoint()}/wave/stream-file?${usp.toString()}`; + if (fileInfo.mimetype === "application/pdf") { + return ( +
+