// Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { TypeAheadModal } from "@/app/modals/typeaheadmodal"; import { ContextMenuModel } from "@/app/store/contextmenu"; import { Markdown } from "@/element/markdown"; import { atoms, createBlock, globalStore, useBlockAtom } from "@/store/global"; import * as services from "@/store/services"; import * as WOS from "@/store/wos"; import { getWebServerEndpoint } from "@/util/endpoints"; import * as historyutil from "@/util/historyutil"; import * as keyutil from "@/util/keyutil"; import * as util from "@/util/util"; import clsx from "clsx"; import * as jotai from "jotai"; import { loadable } from "jotai/utils"; import { createRef, useCallback, useEffect, useState } from "react"; import { CenteredDiv } from "../../element/quickelems"; import { CodeEditor } from "../codeeditor/codeeditor"; import { CSVView } from "./csvview"; import { DirectoryPreview } from "./directorypreview"; import "./preview.less"; const MaxFileSize = 1024 * 1024 * 10; // 10MB const MaxCSVSize = 1024 * 1024 * 1; // 1MB function isTextFile(mimeType: string): boolean { return ( mimeType.startsWith("text/") || mimeType == "application/sql" || (mimeType.startsWith("application/") && (mimeType.includes("json") || mimeType.includes("yaml") || mimeType.includes("toml"))) || mimeType == "application/pem-certificate-chain" ); } export class PreviewModel implements ViewModel { viewType: string; blockId: string; blockAtom: jotai.Atom; viewIcon: jotai.Atom; viewName: jotai.Atom; viewText: jotai.Atom; preIconButton: jotai.Atom; endIconButtons: jotai.Atom; ceReadOnly: jotai.PrimitiveAtom; isCeView: jotai.PrimitiveAtom; previewTextRef: React.RefObject; fileName: jotai.Atom; connection: jotai.Atom; statFile: jotai.Atom>; fullFile: jotai.Atom>; fileMimeType: jotai.Atom>; fileMimeTypeLoadable: jotai.Atom>; fileContent: jotai.Atom>; newFileContent: jotai.PrimitiveAtom; showHiddenFiles: jotai.PrimitiveAtom; refreshVersion: jotai.PrimitiveAtom; refreshCallback: () => void; directoryInputElem: HTMLInputElement; setPreviewFileName(fileName: string) { services.ObjectService.UpdateObjectMeta(`block:${this.blockId}`, { file: fileName }); } constructor(blockId: string) { this.viewType = "preview"; this.blockId = blockId; this.showHiddenFiles = jotai.atom(true); this.refreshVersion = jotai.atom(0); this.previewTextRef = createRef(); this.ceReadOnly = jotai.atom(true); this.isCeView = jotai.atom(false); this.blockAtom = WOS.getWaveObjectAtom(`block:${blockId}`); this.viewIcon = jotai.atom((get) => { let blockData = get(this.blockAtom); if (blockData?.meta?.icon) { 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: () => this.goHistory("~"), }); menuItems.push({ label: "Go to Desktop", click: () => this.goHistory("~/Desktop"), }); menuItems.push({ label: "Go to Downloads", click: () => this.goHistory("~/Downloads"), }); menuItems.push({ label: "Go to Documents", click: () => this.goHistory("~/Documents"), }); menuItems.push({ label: "Go to Root", click: () => this.goHistory("/"), }); ContextMenuModel.showContextMenu(menuItems, e); }, }; } const fileName = get(this.fileName); return iconForFile(mimeType, fileName); }); this.viewName = jotai.atom("Preview"); this.viewText = jotai.atom((get) => { if (get(this.isCeView)) { const viewTextChildren: HeaderElem[] = [ { elemtype: "input", value: get(this.fileName), isDisabled: true, }, ]; if (get(this.ceReadOnly) == false) { let saveClassName = "secondary"; if (get(this.newFileContent) !== null) { saveClassName = "primary"; } viewTextChildren.push( { elemtype: "textbutton", text: "Save", className: clsx( `${saveClassName} warning border-radius-4 vertical-padding-2 horizontal-padding-10` ), onClick: this.handleFileSave.bind(this), }, { elemtype: "textbutton", text: "Cancel", className: "secondary border-radius-4 vertical-padding-2 horizontal-padding-10", onClick: () => this.toggleCodeEditorReadOnly(true), } ); } else { viewTextChildren.push({ elemtype: "textbutton", text: "Edit", className: "secondary border-radius-4 vertical-padding-2 horizontal-padding-10", onClick: () => this.toggleCodeEditorReadOnly(false), }); } return [ { elemtype: "div", children: viewTextChildren, }, ] as HeaderElem[]; } else { return [ { elemtype: "text", text: get(this.fileName), ref: this.previewTextRef, }, ]; } }); this.preIconButton = jotai.atom((get) => { const mimeType = util.jotaiLoadableValue(get(this.fileMimeTypeLoadable), ""); if (mimeType == "directory") { return null; } return { elemtype: "iconbutton", icon: "chevron-left", click: this.goParentDirectory.bind(this), }; }); this.endIconButtons = jotai.atom((get) => { const mimeType = util.jotaiLoadableValue(get(this.fileMimeTypeLoadable), ""); if (mimeType == "directory") { 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?.(), }, ]; } return null; }); this.fileName = jotai.atom((get) => { const file = get(this.blockAtom)?.meta?.file; if (util.isBlank(file)) { return "~"; } return file; }); this.connection = jotai.atom((get) => { return get(this.blockAtom)?.meta?.connection; }); this.statFile = jotai.atom>(async (get) => { const fileName = get(this.fileName); if (fileName == null) { return null; } const conn = get(this.connection) ?? ""; const statFile = await services.FileService.StatFile(conn, fileName); return statFile; }); this.fullFile = jotai.atom>(async (get) => { const fileName = get(this.fileName); if (fileName == null) { return null; } const conn = get(this.connection) ?? ""; const file = await services.FileService.ReadFile(conn, fileName); return file; }); this.fileMimeType = jotai.atom>(async (get) => { const fileInfo = await get(this.statFile); return fileInfo?.mimetype; }); this.fileMimeTypeLoadable = loadable(this.fileMimeType); this.fileContent = jotai.atom>(async (get) => { const fullFile = await get(this.fullFile); return util.base64ToString(fullFile?.data64); }); this.newFileContent = jotai.atom(null) as jotai.PrimitiveAtom; this.goParentDirectory = this.goParentDirectory.bind(this); } async resolvePath(filePath, basePath) { // Handle paths starting with "~" to refer to the home directory if (filePath.startsWith("~")) { try { const conn = globalStore.get(this.connection); const sf = await services.FileService.StatFile(conn, "~"); basePath = sf.path; // Update basePath to the fetched home directory path filePath = basePath + filePath.slice(1); // Replace "~" with the fetched home directory path } catch (error) { console.error("Error fetching home directory:", error); return basePath; } } // If filePath is an absolute path, return it directly if (filePath.startsWith("/")) { return filePath; } const stack = basePath.split("/"); // Ensure no empty segments from trailing slashes if (stack[stack.length - 1] === "") { stack.pop(); } // Process the filePath parts filePath.split("/").forEach((part) => { if (part === "..") { // Go up one level, avoid going above root level if (stack.length > 1) { stack.pop(); } } else if (part === "." || part === "") { // Ignore current directory marker and empty parts } else { // Normal path part, add to the stack stack.push(part); } }); return stack.join("/"); } async isValidPath(path) { try { const conn = globalStore.get(this.connection); const sf = await services.FileService.StatFile(conn, path); const isValid = !sf.notfound; return isValid; } catch (error) { console.error("Error checking path validity:", error); return false; } } async goHistory(newPath, isValidated = false) { const fileName = globalStore.get(this.fileName); if (fileName == null) { return; } if (!isValidated) { newPath = await this.resolvePath(newPath, fileName); const isValid = await this.isValidPath(newPath); if (!isValid) { return; } } const blockMeta = globalStore.get(this.blockAtom)?.meta; const updateMeta = historyutil.goHistory("file", fileName, newPath, blockMeta); if (updateMeta == null) { return; } const blockOref = WOS.makeORef("block", this.blockId); services.ObjectService.UpdateObjectMeta(blockOref, updateMeta); } goParentDirectory() { const blockMeta = globalStore.get(this.blockAtom)?.meta; const fileName = globalStore.get(this.fileName); if (fileName == null) { return; } const newPath = historyutil.getParentDirectory(fileName); const updateMeta = historyutil.goHistory("file", fileName, newPath, blockMeta); if (updateMeta == null) { return; } const blockOref = WOS.makeORef("block", this.blockId); services.ObjectService.UpdateObjectMeta(blockOref, updateMeta); } goHistoryBack() { const blockMeta = globalStore.get(this.blockAtom)?.meta; const curPath = globalStore.get(this.fileName); const updateMeta = historyutil.goHistoryBack("file", curPath, blockMeta, true); if (updateMeta == null) { return; } const blockOref = WOS.makeORef("block", this.blockId); services.ObjectService.UpdateObjectMeta(blockOref, updateMeta); } goHistoryForward() { const blockMeta = globalStore.get(this.blockAtom)?.meta; const curPath = globalStore.get(this.fileName); const updateMeta = historyutil.goHistoryForward("file", curPath, blockMeta); if (updateMeta == null) { return; } const blockOref = WOS.makeORef("block", this.blockId); services.ObjectService.UpdateObjectMeta(blockOref, updateMeta); } toggleCodeEditorReadOnly(readOnly: boolean) { globalStore.set(this.ceReadOnly, readOnly); } async handleFileSave() { const fileName = globalStore.get(this.fileName); const newFileContent = globalStore.get(this.newFileContent); const conn = globalStore.get(this.connection) ?? ""; try { services.FileService.SaveFile(conn, fileName, util.stringToBase64(newFileContent)); globalStore.set(this.newFileContent, null); } catch (error) { console.error("Error saving file:", error); } } getSettingsMenuItems(): ContextMenuItem[] { const menuItems: ContextMenuItem[] = []; menuItems.push({ label: "Copy Full Path", click: () => { const fileName = globalStore.get(this.fileName); if (fileName == null) { return; } navigator.clipboard.writeText(fileName); }, }); menuItems.push({ label: "Copy File Name", click: () => { let fileName = globalStore.get(this.fileName); if (fileName == null) { return; } if (fileName.endsWith("/")) { fileName = fileName.substring(0, fileName.length - 1); } const splitPath = fileName.split("/"); const baseName = splitPath[splitPath.length - 1]; navigator.clipboard.writeText(baseName); }, }); const mimeType = util.jotaiLoadableValue(globalStore.get(this.fileMimeTypeLoadable), ""); if (mimeType == "directory") { menuItems.push({ label: "Open Terminal in New Block", click: async () => { const termBlockDef: BlockDef = { meta: { view: "term", controller: "shell", "cmd:cwd": globalStore.get(this.fileName), }, }; await createBlock(termBlockDef); }, }); } return menuItems; } giveFocus(): boolean { if (this.directoryInputElem) { this.directoryInputElem.focus({ preventScroll: true }); return true; } return false; } keyDownHandler(e: WaveKeyboardEvent): boolean { if (keyutil.checkKeyPressed(e, "Cmd:ArrowLeft")) { this.goHistoryBack(); return true; } if (keyutil.checkKeyPressed(e, "Cmd:ArrowRight")) { this.goHistoryForward(); return true; } if (keyutil.checkKeyPressed(e, "Cmd:ArrowUp")) { // handle up directory this.goParentDirectory(); return true; } return false; } } function makePreviewModel(blockId: string): PreviewModel { const previewModel = new PreviewModel(blockId); return previewModel; } function DirNav({ cwdAtom }: { cwdAtom: jotai.WritableAtom }) { const [cwd, setCwd] = jotai.useAtom(cwdAtom); if (cwd == null || cwd == "") { return null; } let splitNav = [cwd]; let remaining = cwd; let idx = remaining.lastIndexOf("/"); while (idx !== -1) { remaining = remaining.substring(0, idx); splitNav.unshift(remaining); idx = remaining.lastIndexOf("/"); } if (splitNav.length === 0) { splitNav = [cwd]; } return (
{splitNav.map((item, idx) => { let splitPath = item.split("/"); if (splitPath.length === 0) { splitPath = [item]; } const isLast = idx == splitNav.length - 1; let baseName = splitPath[splitPath.length - 1]; if (!isLast) { baseName += "/"; } return (
setCwd(item)} > {baseName}
); })}
); } function MarkdownPreview({ contentAtom }: { contentAtom: jotai.Atom> }) { const readmeText = jotai.useAtomValue(contentAtom); return (
); } function StreamingPreview({ connection, fileInfo }: { connection?: string; fileInfo: FileInfo }) { const filePath = fileInfo.path; const usp = new URLSearchParams(); usp.set("path", filePath); if (connection != null) { usp.set("connection", connection); } const streamingUrl = getWebServerEndpoint() + "/wave/stream-file?" + usp.toString(); if (fileInfo.mimetype == "application/pdf") { return (