diff --git a/frontend/app/block/block.tsx b/frontend/app/block/block.tsx index 04ced6616..8c08ecba1 100644 --- a/frontend/app/block/block.tsx +++ b/frontend/app/block/block.tsx @@ -32,7 +32,7 @@ function makeViewModel(blockId: string, blockView: string, nodeModel: NodeModel) return makeTerminalModel(blockId); } if (blockView === "preview") { - return makePreviewModel(blockId); + return makePreviewModel(blockId, nodeModel); } if (blockView === "web") { return makeWebViewModel(blockId, nodeModel); @@ -231,7 +231,7 @@ const BlockFull = React.memo(({ nodeModel, viewModel }: FullBlockProps) => { type="text" value="" ref={focusElemRef} - id={`${nodeModel.blockId}-dummy-focus`} + id={`${nodeModel.blockId}-dummy-focus`} // don't change this name (used in refocusNode) className="dummy-focus" onChange={() => {}} /> diff --git a/frontend/app/block/blockframe.tsx b/frontend/app/block/blockframe.tsx index a8c88251e..8954eaae1 100644 --- a/frontend/app/block/blockframe.tsx +++ b/frontend/app/block/blockframe.tsx @@ -142,7 +142,6 @@ const BlockFrame_Header = ({ const [blockData] = WOS.useWaveObjectValue(WOS.makeORef("block", nodeModel.blockId)); const viewName = util.useAtomValueSafe(viewModel.viewName) ?? blockViewToName(blockData?.meta?.view); const showBlockIds = jotai.useAtomValue(useSettingsKeyAtom("blockheader:showblockids")); - const settingsConfig = jotai.useAtomValue(atoms.settingsAtom); const viewIconUnion = util.useAtomValueSafe(viewModel.viewIcon) ?? blockViewToIcon(blockData?.meta?.view); const preIconButton = util.useAtomValueSafe(viewModel.preIconButton); const headerTextUnion = util.useAtomValueSafe(viewModel.viewText); @@ -186,14 +185,14 @@ const BlockFrame_Header = ({ const connButtonElem = ( ); headerTextElems.unshift(connButtonElem); } - headerTextElems.unshift(); + headerTextElems.unshift(); return (
@@ -327,6 +326,7 @@ const BlockFrame_Default_Component = (props: BlockFrameProps) => { {preview ? null : ( ; connBtnRef: React.RefObject; changeConnModalAtom: jotai.PrimitiveAtom; + nodeModel: NodeModel; }) => { const [connSelected, setConnSelected] = React.useState(""); const changeConnModalOpen = jotai.useAtomValue(changeConnModalAtom); const [blockData] = WOS.useWaveObjectValue(WOS.makeORef("block", blockId)); + const isNodeFocused = jotai.useAtomValue(nodeModel.isFocused); const changeConnection = React.useCallback( async (connName: string) => { const oldCwd = blockData?.meta?.file ?? ""; @@ -401,6 +404,7 @@ const ChangeConnectionBlockModal = React.memo( changeConnection(selected); globalStore.set(changeConnModalAtom, false); }} + autoFocus={isNodeFocused} onKeyDown={(e) => keyutil.keydownWrapper(handleTypeAheadKeyDown)(e)} onChange={(current: string) => setConnSelected(current)} value={connSelected} diff --git a/frontend/app/block/blockutil.tsx b/frontend/app/block/blockutil.tsx index 26debf54a..d4053ddab 100644 --- a/frontend/app/block/blockutil.tsx +++ b/frontend/app/block/blockutil.tsx @@ -189,6 +189,7 @@ export const ControllerStatusIcon = React.memo(({ blockId }: { blockId: string } } const controllerStatusElem = ( ; } const Input = forwardRef( @@ -44,6 +45,7 @@ const Input = forwardRef( autoFocus, disabled, isNumber, + inputRef, }: InputProps, ref ) => { @@ -51,7 +53,7 @@ const Input = forwardRef( const [internalValue, setInternalValue] = useState(defaultValue); const [error, setError] = useState(false); const [hasContent, setHasContent] = useState(Boolean(value || defaultValue)); - const inputRef = useRef(null); + const internalInputRef = useRef(null); useEffect(() => { if (value !== undefined) { @@ -60,25 +62,32 @@ const Input = forwardRef( }, [value]); const handleComponentFocus = () => { - if (inputRef.current && !inputRef.current.contains(document.activeElement)) { - inputRef.current.focus(); + if (internalInputRef.current && !internalInputRef.current.contains(document.activeElement)) { + internalInputRef.current.focus(); } }; const handleComponentBlur = () => { - if (inputRef.current?.contains(document.activeElement)) { - inputRef.current.blur(); + if (internalInputRef.current?.contains(document.activeElement)) { + internalInputRef.current.blur(); } }; + const handleSetInputRef = (elem: HTMLInputElement) => { + if (inputRef) { + inputRef.current = elem; + } + internalInputRef.current = elem; + }; + const handleFocus = () => { setFocused(true); onFocus && onFocus(); }; const handleBlur = () => { - if (inputRef.current) { - const inputValue = inputRef.current.value; + if (internalInputRef.current) { + const inputValue = internalInputRef.current.value; if (required && !inputValue) { setError(true); setFocused(false); @@ -144,7 +153,7 @@ const Input = forwardRef( className={clsx("input-inner-input", { "offset-left": decoration?.startDecoration, })} - ref={inputRef} + ref={handleSetInputRef} id={label} value={inputValue} onChange={handleInputChange} diff --git a/frontend/app/modals/typeaheadmodal.tsx b/frontend/app/modals/typeaheadmodal.tsx index 8682f0d5b..673a34d98 100644 --- a/frontend/app/modals/typeaheadmodal.tsx +++ b/frontend/app/modals/typeaheadmodal.tsx @@ -3,7 +3,7 @@ import { InputDecoration } from "@/app/element/inputdecoration"; import { useDimensions } from "@/app/hook/useDimensions"; import { makeIconClass } from "@/util/util"; import clsx from "clsx"; -import React, { forwardRef, useEffect, useRef, useState } from "react"; +import React, { forwardRef, useEffect, useLayoutEffect, useRef, useState } from "react"; import ReactDOM from "react-dom"; import "./typeaheadmodal.less"; @@ -82,6 +82,8 @@ interface TypeAheadModalProps { onSelect?: (_: string) => void; onClickBackdrop?: () => void; onKeyDown?: (_) => void; + giveFocusRef?: React.MutableRefObject<() => boolean>; + autoFocus?: boolean; } const TypeAheadModal = ({ @@ -95,10 +97,13 @@ const TypeAheadModal = ({ onSelect, onKeyDown, onClickBackdrop, + giveFocusRef, + autoFocus, }: TypeAheadModalProps) => { const { width, height } = useDimensions(blockRef); const modalRef = useRef(null); const inputRef = useRef(null); + const realInputRef = useRef(null); const suggestionsRef = useRef(null); const [suggestionsHeight, setSuggestionsHeight] = useState(undefined); const [modalHeight, setModalHeight] = useState(undefined); @@ -125,6 +130,20 @@ const TypeAheadModal = ({ } }, [height, suggestions]); + useLayoutEffect(() => { + if (giveFocusRef) { + giveFocusRef.current = () => { + realInputRef.current?.focus(); + return true; + }; + } + return () => { + if (giveFocusRef) { + giveFocusRef.current = null; + } + }; + }, [giveFocusRef]); + const renderBackdrop = (onClick) =>
; const handleKeyDown = (e) => { @@ -167,9 +186,10 @@ const TypeAheadModal = ({
{ switchTab(1); @@ -275,5 +279,6 @@ export { registerControlShiftStateUpdateHandler, registerElectronReinjectKeyHandler, registerGlobalKeys, + tryReinjectKey, unsetControlShift, }; diff --git a/frontend/app/view/codeeditor/codeeditor.tsx b/frontend/app/view/codeeditor/codeeditor.tsx index ba4bd32ff..966b12c25 100644 --- a/frontend/app/view/codeeditor/codeeditor.tsx +++ b/frontend/app/view/codeeditor/codeeditor.tsx @@ -5,15 +5,14 @@ import { useHeight } from "@/app/hook/useHeight"; import loader from "@monaco-editor/loader"; import { Editor, Monaco } from "@monaco-editor/react"; import type * as MonacoTypes from "monaco-editor/esm/vs/editor/editor.api"; -import { useEffect, useRef } from "react"; +import React, { useRef } from "react"; -import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil"; import "./codeeditor.less"; // there is a global monaco variable (TODO get the correct TS type) declare var monaco: Monaco; -function loadMonaco() { +export function loadMonaco() { loader.config({ paths: { vs: "monaco" } }); loader .init() @@ -40,11 +39,6 @@ function loadMonaco() { }); } -// TODO: need to update these on theme change (pull from CSS vars) -document.addEventListener("DOMContentLoaded", () => { - setTimeout(loadMonaco, 30); -}); - function defaultEditorOptions(): MonacoTypes.editor.IEditorOptions { const opts: MonacoTypes.editor.IEditorOptions = { scrollBeyondLastLine: false, @@ -66,59 +60,23 @@ interface CodeEditorProps { filename: string; language?: string; onChange?: (text: string) => void; - onSave?: () => void; - onCancel?: () => void; - onEdit?: () => void; + onMount?: (monacoPtr: MonacoTypes.editor.IStandaloneCodeEditor, monaco: Monaco) => () => void; } -export function CodeEditor({ - parentRef, - text, - language, - filename, - onChange, - onSave, - onCancel, - onEdit, -}: CodeEditorProps) { +export function CodeEditor({ parentRef, text, language, filename, onChange, onMount }: CodeEditorProps) { const divRef = useRef(null); - + const unmountRef = useRef<() => void>(null); const parentHeight = useHeight(parentRef); const theme = "wave-theme-dark"; - useEffect(() => { - function handleKeyDown(e: KeyboardEvent) { - const waveEvent = adaptFromReactOrNativeKeyEvent(e); - if (onSave) { - if (checkKeyPressed(waveEvent, "Cmd:s")) { - e.preventDefault(); - onSave(); - return; - } - } - if (onCancel) { - if (checkKeyPressed(waveEvent, "Cmd:r")) { - e.preventDefault(); - onCancel(); - return; - } - } - if (onEdit) { - if (checkKeyPressed(waveEvent, "Cmd:e")) { - e.preventDefault(); - onEdit(); - return; - } - } - } - - const currentParentRef = parentRef.current; - currentParentRef.addEventListener("keydown", handleKeyDown); - + React.useEffect(() => { return () => { - currentParentRef.removeEventListener("keydown", handleKeyDown); + // unmount function + if (unmountRef.current) { + unmountRef.current(); + } }; - }, [onSave, onCancel, onEdit]); + }, []); function handleEditorChange(text: string, ev: MonacoTypes.editor.IModelContentChangedEvent) { if (onChange) { @@ -127,24 +85,9 @@ export function CodeEditor({ } function handleEditorOnMount(editor: MonacoTypes.editor.IStandaloneCodeEditor, monaco: Monaco) { - // bind Cmd:e - editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyE, () => { - if (onEdit) { - onEdit(); - } - }); - // bind Cmd:s - editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => { - if (onSave) { - onSave(); - } - }); - // bind Cmd:r - editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyR, () => { - if (onCancel) { - onCancel(); - } - }); + if (onMount) { + unmountRef.current = onMount(editor, monaco); + } } const editorOpts = defaultEditorOptions(); diff --git a/frontend/app/view/preview/csvview.tsx b/frontend/app/view/preview/csvview.tsx index c7ecbcbac..3a72d67b1 100644 --- a/frontend/app/view/preview/csvview.tsx +++ b/frontend/app/view/preview/csvview.tsx @@ -37,6 +37,7 @@ interface State { const columnHelper = createColumnHelper(); +// TODO remove parentRef dependency -- use own height const CSVView = ({ parentRef, filename, content }: CSVViewProps) => { const csvCacheRef = useRef(new Map()); const rowRef = useRef<(HTMLTableRowElement | null)[]>([]); diff --git a/frontend/app/view/preview/directorypreview.tsx b/frontend/app/view/preview/directorypreview.tsx index d1dc7e8cb..a0c7fc1ff 100644 --- a/frontend/app/view/preview/directorypreview.tsx +++ b/frontend/app/view/preview/directorypreview.tsx @@ -530,16 +530,15 @@ const MemoizedTableBody = React.memo( ) as typeof TableBody; interface DirectoryPreviewProps { - fileNameAtom: jotai.Atom; model: PreviewModel; } -function DirectoryPreview({ fileNameAtom, model }: DirectoryPreviewProps) { +function DirectoryPreview({ model }: DirectoryPreviewProps) { const [searchText, setSearchText] = useState(""); const [focusIndex, setFocusIndex] = useState(0); const [unfilteredData, setUnfilteredData] = useState([]); const [filteredData, setFilteredData] = useState([]); - const fileName = jotai.useAtomValue(fileNameAtom); + const fileName = jotai.useAtomValue(model.metaFilePath); const showHiddenFiles = jotai.useAtomValue(model.showHiddenFiles); const [selectedPath, setSelectedPath] = useState(""); const [refreshVersion, setRefreshVersion] = jotai.useAtom(model.refreshVersion); diff --git a/frontend/app/view/preview/preview.tsx b/frontend/app/view/preview/preview.tsx index e1c6614bf..b343d2812 100644 --- a/frontend/app/view/preview/preview.tsx +++ b/frontend/app/view/preview/preview.tsx @@ -4,28 +4,49 @@ import { TypeAheadModal } from "@/app/modals/typeaheadmodal"; import { ContextMenuModel } from "@/app/store/contextmenu"; import { Markdown } from "@/element/markdown"; -import { createBlock, globalStore, useBlockAtom } from "@/store/global"; +import { createBlock, globalStore, refocusNode } 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 { Monaco } from "@monaco-editor/react"; import clsx from "clsx"; import * as jotai from "jotai"; import { loadable } from "jotai/utils"; -import { createRef, useCallback, useEffect, useState } from "react"; +import type * as MonacoTypes from "monaco-editor/esm/vs/editor/editor.api"; +import * as React from "react"; +import { createRef, useCallback, useState } from "react"; import { CenteredDiv } from "../../element/quickelems"; import { CodeEditor } from "../codeeditor/codeeditor"; import { CSVView } from "./csvview"; import { DirectoryPreview } from "./directorypreview"; +import { tryReinjectKey } from "@/app/store/keymodel"; +import { NodeModel } from "@/layout/index"; import "./preview.less"; const MaxFileSize = 1024 * 1024 * 10; // 10MB const MaxCSVSize = 1024 * 1024 * 1; // 1MB +type SpecializedViewProps = { + model: PreviewModel; + parentRef: React.RefObject; +}; + +const SpecializedViewMap: { [view: string]: ({ model }: SpecializedViewProps) => React.JSX.Element } = { + streaming: StreamingPreview, + markdown: MarkdownPreview, + codeedit: CodeEditPreview, + csv: CSVViewPreview, + directory: DirectoryPreview, +}; + function isTextFile(mimeType: string): boolean { + if (mimeType == null) { + return false; + } return ( mimeType.startsWith("text/") || mimeType == "application/sql" || @@ -36,25 +57,45 @@ function isTextFile(mimeType: string): boolean { } function canPreview(mimeType: string): boolean { + if (mimeType == null) { + return false; + } 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: NodeModel; blockAtom: jotai.Atom; viewIcon: jotai.Atom; viewName: jotai.Atom; viewText: jotai.Atom; preIconButton: jotai.Atom; endIconButtons: jotai.Atom; - ceReadOnly: jotai.PrimitiveAtom; previewTextRef: React.RefObject; editMode: jotai.Atom; canPreview: jotai.PrimitiveAtom; + specializedView: jotai.Atom>; + loadableSpecializedView: jotai.Atom>; manageConnection: jotai.Atom; - fileName: jotai.Atom; + metaFilePath: jotai.Atom; + statFilePath: jotai.Atom>; + normFilePath: jotai.Atom>; + loadableStatFilePath: jotai.Atom>; connection: jotai.Atom; statFile: jotai.Atom>; fullFile: jotai.Atom>; @@ -62,28 +103,36 @@ export class PreviewModel implements ViewModel { fileMimeTypeLoadable: jotai.Atom>; fileContent: jotai.Atom>; newFileContent: jotai.PrimitiveAtom; + openFileModal: jotai.PrimitiveAtom; + openFileError: jotai.PrimitiveAtom; + openFileModalGiveFocusRef: React.MutableRefObject<() => boolean>; + + monacoRef: React.MutableRefObject; showHiddenFiles: jotai.PrimitiveAtom; refreshVersion: jotai.PrimitiveAtom; refreshCallback: () => void; directoryKeyDownHandler: (waveEvent: WaveKeyboardEvent) => boolean; + codeEditKeyDownHandler: (waveEvent: WaveKeyboardEvent) => boolean; setPreviewFileName(fileName: string) { services.ObjectService.UpdateObjectMeta(`block:${this.blockId}`, { file: fileName }); } - constructor(blockId: string) { + constructor(blockId: string, nodeModel: NodeModel) { this.viewType = "preview"; this.blockId = blockId; + this.nodeModel = nodeModel; this.showHiddenFiles = jotai.atom(true); this.refreshVersion = jotai.atom(0); this.previewTextRef = createRef(); - this.ceReadOnly = jotai.atom(true); - this.canPreview = jotai.atom(false); this.openFileModal = jotai.atom(false); + this.openFileError = jotai.atom(null) as jotai.PrimitiveAtom; + this.openFileModalGiveFocusRef = createRef(); this.manageConnection = jotai.atom(true); this.blockAtom = WOS.getWaveObjectAtom(`block:${blockId}`); + this.monacoRef = createRef(); this.viewIcon = jotai.atom((get) => { let blockData = get(this.blockAtom); if (blockData?.meta?.icon) { @@ -120,7 +169,7 @@ export class PreviewModel implements ViewModel { }, }; } - const fileName = get(this.fileName); + const fileName = get(this.metaFilePath); return iconForFile(mimeType, fileName); }); this.editMode = jotai.atom((get) => { @@ -129,22 +178,27 @@ export class PreviewModel implements ViewModel { }); this.viewName = jotai.atom("Preview"); this.viewText = jotai.atom((get) => { - const blockData = get(this.blockAtom); - const editMode = blockData?.meta?.edit ?? false; + const loadableSV = get(this.loadableSpecializedView); + const isCeView = loadableSV.state == "hasData" && loadableSV.data.specializedView == "codeedit"; + let headerPath = get(this.metaFilePath); + let loadablePath: Loadable = get(this.loadableStatFilePath); + if (loadablePath.state == "hasData" && !util.isBlank(loadablePath.data)) { + headerPath = loadablePath.data; + } const viewTextChildren: HeaderElem[] = [ { elemtype: "text", - text: get(this.fileName), + text: headerPath, ref: this.previewTextRef, className: "preview-filename", - onClick: () => globalStore.set(this.openFileModal, true), + onClick: () => this.updateOpenFileModalAndError(true), }, ]; let saveClassName = "secondary"; if (get(this.newFileContent) !== null) { saveClassName = "primary"; } - if (editMode) { + if (isCeView) { viewTextChildren.push({ elemtype: "textbutton", text: "Save", @@ -159,7 +213,7 @@ export class PreviewModel implements ViewModel { text: "Preview", className: "secondary border-radius-4 vertical-padding-2 horizontal-padding-10 font-size-11 font-weight-500", - onClick: () => this.toggleEditMode(false), + onClick: () => this.setEditMode(false), }); } } else if (get(this.canPreview)) { @@ -168,7 +222,7 @@ export class PreviewModel implements ViewModel { text: "Edit", className: "secondary border-radius-4 vertical-padding-2 horizontal-padding-10 font-size-11 font-weight-500", - onClick: () => this.toggleEditMode(true), + onClick: () => this.setEditMode(true), }); } return [ @@ -210,18 +264,33 @@ export class PreviewModel implements ViewModel { } return null; }); - this.fileName = jotai.atom((get) => { + this.metaFilePath = jotai.atom((get) => { const file = get(this.blockAtom)?.meta?.file; if (util.isBlank(file)) { return "~"; } return file; }); + this.statFilePath = jotai.atom>(async (get) => { + const fileInfo = await get(this.statFile); + return fileInfo?.path; + }); + this.normFilePath = jotai.atom>(async (get) => { + const fileInfo = await get(this.statFile); + if (fileInfo == null) { + return null; + } + if (fileInfo.isdir) { + return fileInfo.dir + "/"; + } + return fileInfo.dir + "/" + fileInfo.name; + }); + this.loadableStatFilePath = loadable(this.statFilePath); this.connection = jotai.atom((get) => { return get(this.blockAtom)?.meta?.connection; }); this.statFile = jotai.atom>(async (get) => { - const fileName = get(this.fileName); + const fileName = get(this.metaFilePath); if (fileName == null) { return null; } @@ -236,126 +305,9 @@ export class PreviewModel implements ViewModel { this.fileMimeTypeLoadable = loadable(this.fileMimeType); this.newFileContent = jotai.atom(null) as jotai.PrimitiveAtom; this.goParentDirectory = this.goParentDirectory.bind(this); - this.toggleEditMode(false); - this.setFileContent(); - } - 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); - } - }); - console.log("===============================", stack.join("/")); - 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; - } - updateMeta.edit = false; - 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; - } - updateMeta.edit = false; - 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; - } - updateMeta.edit = false; - const blockOref = WOS.makeORef("block", this.blockId); - services.ObjectService.UpdateObjectMeta(blockOref, updateMeta); - } - - setFileContent() { const fullFileAtom = jotai.atom>(async (get) => { - const fileName = get(this.fileName); + const fileName = get(this.metaFilePath); if (fileName == null) { return null; } @@ -371,57 +323,201 @@ export class PreviewModel implements ViewModel { this.fullFile = fullFileAtom; this.fileContent = fileContentAtom; + + this.specializedView = jotai.atom>(async (get) => { + return this.getSpecializedView(get); + }); + this.loadableSpecializedView = loadable(this.specializedView); + this.canPreview = jotai.atom(false); } - toggleEditMode(edit: boolean) { - if (!edit) { - this.setFileContent(); - } + async getSpecializedView(getFn: jotai.Getter): Promise<{ specializedView?: string; errorStr?: string }> { + const mimeType = await getFn(this.fileMimeType); + const fileInfo = await getFn(this.statFile); + const fileName = await getFn(this.statFilePath); + const editMode = getFn(this.editMode); + if (fileInfo?.notfound) { + return { errorStr: `File Not Found: ${fileInfo.path}` }; + } + if (mimeType == null) { + return { errorStr: `Unable to determine mimetype for: ${fileInfo.path}` }; + } + if (isStreamingType(mimeType)) { + return { specializedView: "streaming" }; + } + if (!fileInfo) { + let 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)) { + return { specializedView: "codeedit" }; + } + return { errorStr: `Preview (${mimeType})` }; + } + + updateOpenFileModalAndError(isOpen, errorMsg = null) { + globalStore.set(this.openFileModal, isOpen); + globalStore.set(this.openFileError, errorMsg); + } + + async goHistory(newPath: string) { + let fileName = globalStore.get(this.metaFilePath); + if (fileName == null) { + fileName = ""; + } + 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); + } + + async goParentDirectory() { + const blockMeta = globalStore.get(this.blockAtom)?.meta; + const metaPath = globalStore.get(this.metaFilePath); + const fileInfo = await globalStore.get(this.statFile); + if (fileInfo == null) { + return; + } + let newPath: string = null; + if (!fileInfo.isdir) { + newPath = fileInfo.dir; + } else { + const lastSlash = fileInfo.dir.lastIndexOf("/"); + newPath = fileInfo.dir.slice(0, lastSlash); + if (newPath.indexOf("/") == -1) { + return; + } + } + const updateMeta = historyutil.goHistory("file", metaPath, newPath, blockMeta); + if (updateMeta == null) { + return; + } + updateMeta.edit = false; + 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.metaFilePath); + const updateMeta = historyutil.goHistoryBack("file", curPath, blockMeta, true); + if (updateMeta == null) { + return; + } + updateMeta.edit = false; + 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.metaFilePath); + const updateMeta = historyutil.goHistoryForward("file", curPath, blockMeta); + if (updateMeta == null) { + return; + } + updateMeta.edit = false; + const blockOref = WOS.makeORef("block", this.blockId); + services.ObjectService.UpdateObjectMeta(blockOref, updateMeta); + } + + setEditMode(edit: boolean) { const blockMeta = globalStore.get(this.blockAtom)?.meta; const blockOref = WOS.makeORef("block", this.blockId); services.ObjectService.UpdateObjectMeta(blockOref, { ...blockMeta, edit }); } async handleFileSave() { - const fileName = globalStore.get(this.fileName); + 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; + } const conn = globalStore.get(this.connection) ?? ""; try { - if (newFileContent != null) { - services.FileService.SaveFile(conn, fileName, util.stringToBase64(newFileContent)); - globalStore.set(this.newFileContent, null); - } + services.FileService.SaveFile(conn, filePath, util.stringToBase64(newFileContent)); + globalStore.set(this.newFileContent, null); + console.log("saved file", filePath); } catch (error) { console.error("Error saving file:", error); } } + async handleFileRevert() { + const fileContent = await globalStore.get(this.fileContent); + this.monacoRef.current?.setValue(fileContent); + globalStore.set(this.newFileContent, null); + } + + async handleOpenFile(filePath: string) { + const fileInfo = await globalStore.get(this.statFile); + if (fileInfo == null) { + this.updateOpenFileModalAndError(false); + return true; + } + const newPath = fileInfo.dir + "/" + filePath; + const conn = globalStore.get(this.connection) ?? ""; + const newFileInfo = await services.FileService.StatFile(conn, newPath); + this.updateOpenFileModalAndError(false); + this.goHistory(newFileInfo.path); + refocusNode(this.blockId); + return true; + } + + isSpecializedView(sv: string): boolean { + const loadableSV = globalStore.get(this.loadableSpecializedView); + return loadableSV.state == "hasData" && loadableSV.data.specializedView == sv; + } + getSettingsMenuItems(): ContextMenuItem[] { const menuItems: ContextMenuItem[] = []; menuItems.push({ label: "Copy Full Path", - click: () => { - const fileName = globalStore.get(this.fileName); - if (fileName == null) { + click: async () => { + const filePath = await globalStore.get(this.normFilePath); + if (filePath == null) { return; } - navigator.clipboard.writeText(fileName); + navigator.clipboard.writeText(filePath); }, }); menuItems.push({ label: "Copy File Name", - click: () => { - let fileName = globalStore.get(this.fileName); - if (fileName == null) { + click: async () => { + const fileInfo = await globalStore.get(this.statFile); + if (fileInfo == null || fileInfo.name == 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); + navigator.clipboard.writeText(fileInfo.name); }, }); const mimeType = util.jotaiLoadableValue(globalStore.get(this.fileMimeTypeLoadable), ""); @@ -429,21 +525,47 @@ export class PreviewModel implements ViewModel { menuItems.push({ label: "Open Terminal in New Block", click: async () => { + const fileInfo = await globalStore.get(this.statFile); const termBlockDef: BlockDef = { meta: { view: "term", controller: "shell", - "cmd:cwd": globalStore.get(this.fileName), + "cmd:cwd": fileInfo.dir, }, }; await createBlock(termBlockDef); }, }); } + const loadableSV = globalStore.get(this.loadableSpecializedView); + 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: this.handleFileSave.bind(this), + }); + menuItems.push({ + label: "Revert File", + click: this.handleFileRevert.bind(this), + }); + } + } + } 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; } @@ -461,68 +583,44 @@ export class PreviewModel implements ViewModel { this.goParentDirectory(); return true; } + const openModalOpen = globalStore.get(this.openFileModal); + if (!openModalOpen) { + if (keyutil.checkKeyPressed(e, "Cmd:o")) { + this.updateOpenFileModalAndError(true); + return true; + } + } + const canPreview = globalStore.get(this.canPreview); + if (canPreview) { + if (keyutil.checkKeyPressed(e, "Cmd:e")) { + const editMode = globalStore.get(this.editMode); + 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; } } -function makePreviewModel(blockId: string): PreviewModel { - const previewModel = new PreviewModel(blockId); +function makePreviewModel(blockId: string, nodeModel: NodeModel): PreviewModel { + const previewModel = new PreviewModel(blockId, nodeModel); 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); +function MarkdownPreview({ model }: SpecializedViewProps) { + const readmeText = jotai.useAtomValue(model.fileContent); return (
@@ -530,12 +628,14 @@ function MarkdownPreview({ contentAtom }: { contentAtom: jotai.AtomPreview Not Supported; } -function CodeEditPreview({ - parentRef, - contentAtom, - filename, - newFileContentAtom, - model, -}: { - parentRef: React.MutableRefObject; - contentAtom: jotai.Atom>; - filename: string; - newFileContentAtom: jotai.PrimitiveAtom; - model: PreviewModel; -}) { - const fileContent = jotai.useAtomValue(contentAtom); - const setNewFileContent = jotai.useSetAtom(newFileContentAtom); +function CodeEditPreview({ parentRef, model }: SpecializedViewProps) { + const fileContent = jotai.useAtomValue(model.fileContent); + const setNewFileContent = jotai.useSetAtom(model.newFileContent); + const fileName = jotai.useAtomValue(model.statFilePath); + + function codeEditKeyDownHandler(e: WaveKeyboardEvent): boolean { + if (keyutil.checkKeyPressed(e, "Cmd:e")) { + model.setEditMode(false); + return true; + } + if (keyutil.checkKeyPressed(e, "Cmd:s")) { + model.handleFileSave(); + return true; + } + if (keyutil.checkKeyPressed(e, "Cmd:r")) { + model.handleFileRevert(); + return true; + } + return false; + } + + React.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; + + const simpleMod = keyutil.getKeyUtilPlatform() == "darwin" ? monaco.KeyMod.CtrlCmd : monaco.KeyMod.Alt; + + editor.onKeyDown((e: MonacoTypes.IKeyboardEvent) => { + const waveEvent = keyutil.adaptFromReactOrNativeKeyEvent(e.browserEvent); + const handled = tryReinjectKey(waveEvent); + if (handled) { + e.stopPropagation(); + e.preventDefault(); + } + }); + + if (false) { + // bind Cmd:e + editor.addCommand(simpleMod | monaco.KeyCode.KeyE, () => { + model.setEditMode(false); + }); + // bind Cmd:s + editor.addCommand(simpleMod | monaco.KeyCode.KeyS, () => { + model.handleFileSave(); + }); + // bind Cmd:o + editor.addCommand(simpleMod | monaco.KeyCode.KeyO, () => { + model.updateOpenFileModalAndError(true); + }); + } + + const isFocused = globalStore.get(model.nodeModel.isFocused); + if (isFocused) { + editor.focus(); + } + + return null; + } return ( setNewFileContent(text)} - onSave={() => model.handleFileSave()} - onCancel={() => model.toggleEditMode(true)} - onEdit={() => model.toggleEditMode(false)} + onMount={onMount} /> ); } -function CSVViewPreview({ - parentRef, - contentAtom, - filename, - readonly, -}: { - parentRef: React.MutableRefObject; - contentAtom: jotai.Atom>; - filename: string; - readonly: boolean; -}) { - const fileContent = jotai.useAtomValue(contentAtom); - return ; +function CSVViewPreview({ model, parentRef }: SpecializedViewProps) { + const fileContent = jotai.useAtomValue(model.fileContent); + const fileName = jotai.useAtomValue(model.statFilePath); + return ; } function iconForFile(mimeType: string, fileName: string): string { @@ -650,6 +789,25 @@ function iconForFile(mimeType: string, fileName: string): string { } } +function SpecializedView({ parentRef, model }: SpecializedViewProps) { + const specializedView = jotai.useAtomValue(model.specializedView); + const mimeType = jotai.useAtomValue(model.fileMimeType); + const setCanPreview = jotai.useSetAtom(model.canPreview); + + React.useEffect(() => { + setCanPreview(canPreview(mimeType)); + }, [mimeType, setCanPreview]); + + if (specializedView.errorStr != null) { + return {specializedView.errorStr}; + } + const SpecializedViewComponent = SpecializedViewMap[specializedView.specializedView]; + if (!SpecializedViewComponent) { + return Invalid Specialzied View Component ({specializedView.specializedView}); + } + return ; +} + function PreviewView({ blockId, blockRef, @@ -661,176 +819,83 @@ function PreviewView({ contentRef: React.RefObject; model: PreviewModel; }) { - const fileNameAtom = model.fileName; - const statFileAtom = model.statFile; - const fileMimeTypeAtom = model.fileMimeType; - const fileContentAtom = model.fileContent; - const newFileContentAtom = model.newFileContent; - const editModeAtom = model.editMode; - const openFileModalAtom = model.openFileModal; - const canPreviewAtom = model.canPreview; - - const mimeType = jotai.useAtomValue(fileMimeTypeAtom) || ""; - const fileName = jotai.useAtomValue(fileNameAtom); - const fileInfo = jotai.useAtomValue(statFileAtom); - const conn = jotai.useAtomValue(model.connection); - const editMode = jotai.useAtomValue(editModeAtom); - const openFileModal = jotai.useAtomValue(openFileModalAtom); - let blockIcon = iconForFile(mimeType, fileName); - - const [filePath, setFilePath] = useState(""); - const [openFileError, setOpenFileError] = useState(""); - - // ensure consistent hook calls - const specializedView = (() => { - let view: React.ReactNode = null; - blockIcon = iconForFile(mimeType, fileName); - if ( - mimeType === "application/pdf" || - mimeType.startsWith("video/") || - mimeType.startsWith("audio/") || - mimeType.startsWith("image/") - ) { - view = ; - } else if (!fileInfo) { - view = File Not Found{util.isBlank(fileName) ? null : JSON.stringify(fileName)}; - } else if (fileInfo.size > MaxFileSize) { - view = File Too Large to Preview; - } else if (mimeType === "text/markdown" && !editMode) { - globalStore.set(canPreviewAtom, true); - view = ; - } else if (mimeType === "text/csv" && !editMode) { - globalStore.set(canPreviewAtom, true); - if (fileInfo.size > MaxCSVSize) { - view = CSV File Too Large to Preview (1MB Max); - } else { - view = ( - - ); - } - } else if (isTextFile(mimeType)) { - model.toggleEditMode(true); - view = ( - - ); - } else if (mimeType === "directory") { - view = ; - if (editMode) { - globalStore.set(openFileModalAtom, true); - } else { - globalStore.set(canPreviewAtom, false); - } - } else { - globalStore.set(canPreviewAtom, false); - model.toggleEditMode(false); - view = ( -
-
Preview ({mimeType})
-
- ); - } - return view; - })(); - - const handleKeyDown = useCallback( - (waveEvent: WaveKeyboardEvent): boolean => { - const updateModalAndError = (isOpen, errorMsg = "") => { - globalStore.set(openFileModalAtom, isOpen); - setOpenFileError(errorMsg); - }; - - const handleEnterPress = async () => { - const newPath = await model.resolvePath(filePath, fileName); - const isValidPath = await model.isValidPath(newPath); - if (isValidPath) { - updateModalAndError(false); - await model.goHistory(newPath, true); - } else { - updateModalAndError(true, "The path you entered does not exist."); - } - model.giveFocus(); - return isValidPath; - }; - - const handleCommandOperations = async () => { - if (keyutil.checkKeyPressed(waveEvent, "Cmd:o")) { - updateModalAndError(true); - return true; - } - if (keyutil.checkKeyPressed(waveEvent, "Cmd:d")) { - updateModalAndError(false); - return false; - } - if (keyutil.checkKeyPressed(waveEvent, "Enter")) { - return handleEnterPress(); - } - return false; - }; - - handleCommandOperations().catch((error) => { - console.error("Error handling key down:", error); - updateModalAndError(true, "An error occurred during operation."); - return false; - }); - return false; - }, - [model, blockId, filePath, fileName] - ); - - const handleFileSuggestionSelect = (value) => { - globalStore.set(openFileModalAtom, false); - }; - - const handleFileSuggestionChange = (value) => { - setFilePath(value); - }; - - const handleBackDropClick = () => { - globalStore.set(openFileModalAtom, false); - }; - - useEffect(() => { - const blockIconOverrideAtom = useBlockAtom(blockId, "blockicon:override", () => { - return jotai.atom(null); - }) as jotai.PrimitiveAtom; - globalStore.set(blockIconOverrideAtom, blockIcon); - }, [blockId, blockIcon]); - return ( <> - {openFileModal && ( - keyutil.keydownWrapper(handleKeyDown)(e)} - onSelect={handleFileSuggestionSelect} - onChange={handleFileSuggestionChange} - onClickBackdrop={handleBackDropClick} - /> - )} -
keyutil.keydownWrapper(handleKeyDown)(e)} - > + +
- {specializedView} +
); } +const OpenFileModal = React.memo( + ({ + model, + blockRef, + blockId, + }: { + model: PreviewModel; + blockRef: React.RefObject; + blockId: string; + }) => { + const openFileModal = jotai.useAtomValue(model.openFileModal); + const curFileName = jotai.useAtomValue(model.metaFilePath); + const [filePath, setFilePath] = useState(""); + const isNodeFocused = jotai.useAtomValue(model.nodeModel.isFocused); + const handleKeyDown = useCallback( + keyutil.keydownWrapper((waveEvent: WaveKeyboardEvent): boolean => { + if (keyutil.checkKeyPressed(waveEvent, "Escape")) { + model.updateOpenFileModalAndError(false); + return true; + } + + const handleCommandOperations = async () => { + if (keyutil.checkKeyPressed(waveEvent, "Enter")) { + model.handleOpenFile(filePath); + return true; + } + return false; + }; + + handleCommandOperations().catch((error) => { + console.error("Error handling key down:", error); + model.updateOpenFileModalAndError(true, "An error occurred during operation."); + return false; + }); + return false; + }), + [model, blockId, filePath, curFileName] + ); + const handleFileSuggestionSelect = (value) => { + globalStore.set(model.openFileModal, false); + }; + const handleFileSuggestionChange = (value) => { + setFilePath(value); + }; + const handleBackDropClick = () => { + globalStore.set(model.openFileModal, false); + }; + if (!openFileModal) { + return null; + } + return ( + + ); + } +); + export { makePreviewModel, PreviewView }; diff --git a/frontend/app/view/term/term.tsx b/frontend/app/view/term/term.tsx index e16cecf1c..ddf3f6517 100644 --- a/frontend/app/view/term/term.tsx +++ b/frontend/app/view/term/term.tsx @@ -3,7 +3,7 @@ import { WshServer } from "@/app/store/wshserver"; import { VDomView } from "@/app/view/term/vdom"; -import { WOS, atoms, getEventORefSubject, globalStore, useBlockAtom, useSettingsPrefixAtom } from "@/store/global"; +import { WOS, atoms, getEventORefSubject, globalStore, useSettingsPrefixAtom } from "@/store/global"; import * as services from "@/store/services"; import * as keyutil from "@/util/keyutil"; import * as util from "@/util/util"; @@ -205,9 +205,6 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => { const termRef = React.useRef(null); model.termRef = termRef; const shellProcStatusRef = React.useRef(null); - const blockIconOverrideAtom = useBlockAtom(blockId, "blockicon:override", () => { - return jotai.atom(null); - }) as jotai.PrimitiveAtom; const htmlElemFocusRef = React.useRef(null); model.htmlElemFocusRef = htmlElemFocusRef; const [blockData] = WOS.useWaveObjectValue(WOS.makeORef("block", blockId)); @@ -310,10 +307,8 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => { shellProcStatusRef.current = status; if (status == "running") { termRef.current?.setIsRunning(true); - globalStore.set(blockIconOverrideAtom, "terminal"); } else { termRef.current?.setIsRunning(false); - globalStore.set(blockIconOverrideAtom, "regular@terminal"); } } const initialRTStatus = services.BlockService.GetControllerStatus(blockId); diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index a4895e766..0da8492da 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -175,6 +175,7 @@ declare global { // wshrpc.FileInfo type FileInfo = { path: string; + dir: string; name: string; notfound?: boolean; size: number; @@ -183,6 +184,7 @@ declare global { modtime: number; isdir?: boolean; mimetype?: string; + readonly?: boolean; }; // filestore.FileOptsType diff --git a/frontend/util/keyutil.ts b/frontend/util/keyutil.ts index c28b1d625..253a35451 100644 --- a/frontend/util/keyutil.ts +++ b/frontend/util/keyutil.ts @@ -14,6 +14,10 @@ function setKeyUtilPlatform(platform: NodeJS.Platform) { PLATFORM = platform; } +function getKeyUtilPlatform(): NodeJS.Platform { + return PLATFORM; +} + function keydownWrapper( fn: (waveEvent: WaveKeyboardEvent) => boolean ): (event: KeyboardEvent | React.KeyboardEvent) => void { @@ -85,6 +89,53 @@ function isCharacterKeyEvent(event: WaveKeyboardEvent): boolean { return util.countGraphemes(event.key) == 1; } +const inputKeyMap = new Map([ + ["Backspace", true], + ["Delete", true], + ["Enter", true], + ["Space", true], + ["Tab", true], + ["ArrowLeft", true], + ["ArrowRight", true], + ["ArrowUp", true], + ["ArrowDown", true], + ["Home", true], + ["End", true], + ["PageUp", true], + ["PageDown", true], + ["Cmd:a", true], + ["Cmd:c", true], + ["Cmd:v", true], + ["Cmd:x", true], + ["Cmd:z", true], + ["Cmd:Shift:z", true], + ["Cmd:ArrowLeft", true], + ["Cmd:ArrowRight", true], + ["Cmd:Backspace", true], + ["Cmd:Delete", true], + ["Shift:ArrowLeft", true], + ["Shift:ArrowRight", true], + ["Shift:ArrowUp", true], + ["Shift:ArrowDown", true], + ["Shift:Home", true], + ["Shift:End", true], + ["Cmd:Shift:ArrowLeft", true], + ["Cmd:Shift:ArrowRight", true], + ["Cmd:Shift:ArrowUp", true], + ["Cmd:Shift:ArrowDown", true], +]); + +function isInputEvent(event: WaveKeyboardEvent): boolean { + if (isCharacterKeyEvent(event)) { + return true; + } + for (let key of inputKeyMap.keys()) { + if (checkKeyPressed(event, key)) { + return true; + } + } +} + function checkKeyPressed(event: WaveKeyboardEvent, keyDescription: string): boolean { let keyPress = parseKeyDescription(keyDescription); if (!keyPress.mods.Alt && notMod(keyPress.mods.Option, event.option)) { @@ -174,7 +225,9 @@ export { adaptFromElectronKeyEvent, adaptFromReactOrNativeKeyEvent, checkKeyPressed, + getKeyUtilPlatform, isCharacterKeyEvent, + isInputEvent, keydownWrapper, parseKeyDescription, setKeyUtilPlatform, diff --git a/frontend/wave.ts b/frontend/wave.ts index 8362ae5a2..007fc5e50 100644 --- a/frontend/wave.ts +++ b/frontend/wave.ts @@ -7,6 +7,8 @@ import { registerGlobalKeys, } from "@/app/store/keymodel"; import { WshServer } from "@/app/store/wshserver"; +import { loadMonaco } from "@/app/view/codeeditor/codeeditor"; +import { getLayoutModelForActiveTab } from "@/layout/index"; import { atoms, countersClear, @@ -48,6 +50,7 @@ loadFonts(); (window as any).isFullScreen = false; (window as any).countersPrint = countersPrint; (window as any).countersClear = countersClear; +(window as any).getLayoutModelForActiveTab = getLayoutModelForActiveTab; document.title = `The Next Wave (${windowId.substring(0, 8)})`; @@ -65,6 +68,7 @@ document.addEventListener("DOMContentLoaded", async () => { registerGlobalKeys(); registerElectronReinjectKeyHandler(); registerControlShiftStateUpdateHandler(); + setTimeout(loadMonaco, 30); const fullConfig = await services.FileService.GetFullConfig(); console.log("fullconfig", fullConfig); globalStore.set(atoms.fullConfigAtom, fullConfig); diff --git a/pkg/util/utilfn/utilfn.go b/pkg/util/utilfn/utilfn.go index a5a21632f..487abde28 100644 --- a/pkg/util/utilfn/utilfn.go +++ b/pkg/util/utilfn/utilfn.go @@ -13,6 +13,7 @@ import ( "errors" "fmt" "io" + "io/fs" "math" mathrand "math/rand" "mime" @@ -627,7 +628,8 @@ func CopyToChannel(outputCh chan<- []byte, reader io.Reader) error { // on error just returns "" // does not return "application/octet-stream" as this is considered a detection failure -func DetectMimeType(path string) string { +// can pass an existing fileInfo to avoid re-statting the file +func DetectMimeType(path string, fileInfo fs.FileInfo) string { ext := filepath.Ext(path) if mimeType, ok := StaticMimeTypeMap[ext]; ok { return mimeType @@ -635,21 +637,24 @@ func DetectMimeType(path string) string { if mimeType := mime.TypeByExtension(ext); mimeType != "" { return mimeType } - stats, err := os.Stat(path) - if err != nil { - return "" + if fileInfo == nil { + statRtn, err := os.Stat(path) + if err != nil { + return "" + } + fileInfo = statRtn } - if stats.IsDir() { + if fileInfo.IsDir() { return "directory" } - if stats.Mode()&os.ModeNamedPipe == os.ModeNamedPipe { + if fileInfo.Mode()&os.ModeNamedPipe == os.ModeNamedPipe { return "pipe" } charDevice := os.ModeDevice | os.ModeCharDevice - if stats.Mode()&charDevice == charDevice { + if fileInfo.Mode()&charDevice == charDevice { return "character-special" } - if stats.Mode()&os.ModeDevice == os.ModeDevice { + if fileInfo.Mode()&os.ModeDevice == os.ModeDevice { return "block-special" } fd, err := os.Open(path) diff --git a/pkg/wshrpc/wshremote/wshremote.go b/pkg/wshrpc/wshremote/wshremote.go index 6783a21af..e02baa97e 100644 --- a/pkg/wshrpc/wshremote/wshremote.go +++ b/pkg/wshrpc/wshremote/wshremote.go @@ -9,9 +9,11 @@ import ( "errors" "fmt" "io" + "io/fs" "log" "os" "path/filepath" + "strings" "github.com/wavetermdev/thenextwave/pkg/util/utilfn" "github.com/wavetermdev/thenextwave/pkg/wavebase" @@ -88,7 +90,7 @@ func (impl *ServerImpl) remoteStreamFileDir(ctx context.Context, path string, by } var fileInfoArr []*wshrpc.FileInfo parent := filepath.Dir(path) - parentFileInfo, err := impl.RemoteFileInfoCommand(ctx, parent) + parentFileInfo, err := impl.fileInfoInternal(parent, false) if err == nil && parent != path { parentFileInfo.Name = ".." parentFileInfo.Size = -1 @@ -102,24 +104,8 @@ func (impl *ServerImpl) remoteStreamFileDir(ctx context.Context, path string, by if err != nil { continue } - mimeType := utilfn.DetectMimeType(filepath.Join(path, innerFileInfoInt.Name())) - var fileSize int64 - if mimeType == "directory" { - fileSize = -1 - } else { - fileSize = innerFileInfoInt.Size() - } - innerFileInfo := wshrpc.FileInfo{ - Path: filepath.Join(path, innerFileInfoInt.Name()), - Name: innerFileInfoInt.Name(), - Size: fileSize, - Mode: innerFileInfoInt.Mode(), - ModeStr: innerFileInfoInt.Mode().String(), - ModTime: innerFileInfoInt.ModTime().UnixMilli(), - IsDir: innerFileInfoInt.IsDir(), - MimeType: mimeType, - } - fileInfoArr = append(fileInfoArr, &innerFileInfo) + innerFileInfo := statToFileInfo(filepath.Join(path, innerFileInfoInt.Name()), innerFileInfoInt) + fileInfoArr = append(fileInfoArr, innerFileInfo) if len(fileInfoArr) >= DirChunkSize { dataCallback(fileInfoArr, nil) fileInfoArr = nil @@ -179,7 +165,7 @@ func (impl *ServerImpl) remoteStreamFileInternal(ctx context.Context, data wshrp } path := data.Path path = wavebase.ExpandHomeDir(path) - finfo, err := impl.RemoteFileInfoCommand(ctx, path) + finfo, err := impl.fileInfoInternal(path, true) if err != nil { return fmt.Errorf("cannot stat file %q: %w", path, err) } @@ -214,18 +200,11 @@ func (impl *ServerImpl) RemoteStreamFileCommand(ctx context.Context, data wshrpc return ch } -func (*ServerImpl) RemoteFileInfoCommand(ctx context.Context, path string) (*wshrpc.FileInfo, error) { - cleanedPath := filepath.Clean(wavebase.ExpandHomeDir(path)) - finfo, err := os.Stat(cleanedPath) - if os.IsNotExist(err) { - return &wshrpc.FileInfo{Path: wavebase.ReplaceHomeDir(path), NotFound: true}, nil - } - if err != nil { - return nil, fmt.Errorf("cannot stat file %q: %w", path, err) - } - mimeType := utilfn.DetectMimeType(cleanedPath) - return &wshrpc.FileInfo{ - Path: cleanedPath, +func statToFileInfo(fullPath string, finfo fs.FileInfo) *wshrpc.FileInfo { + mimeType := utilfn.DetectMimeType(fullPath, finfo) + rtn := &wshrpc.FileInfo{ + Path: wavebase.ReplaceHomeDir(fullPath), + Dir: computeDirPart(fullPath, finfo.IsDir()), Name: finfo.Name(), Size: finfo.Size(), Mode: finfo.Mode(), @@ -233,7 +212,75 @@ func (*ServerImpl) RemoteFileInfoCommand(ctx context.Context, path string) (*wsh ModTime: finfo.ModTime().UnixMilli(), IsDir: finfo.IsDir(), MimeType: mimeType, - }, nil + } + if finfo.IsDir() { + rtn.Size = -1 + } + return rtn +} + +// fileInfo might be null +func checkIsReadOnly(path string, fileInfo fs.FileInfo, exists bool) bool { + if !exists || fileInfo.Mode().IsDir() { + dirName := filepath.Dir(path) + randHexStr, err := utilfn.RandomHexString(12) + if err != nil { + // we're not sure, just return false + return false + } + tmpFileName := filepath.Join(dirName, "wsh-tmp-"+randHexStr) + _, err = os.Create(tmpFileName) + if err != nil { + return true + } + os.Remove(tmpFileName) + return false + } + // try to open for writing, if this fails then it is read-only + file, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0666) + if err != nil { + return true + } + file.Close() + return false +} + +func computeDirPart(path string, isDir bool) string { + path = filepath.Clean(wavebase.ExpandHomeDir(path)) + path = filepath.ToSlash(path) + if path == "/" { + return "/" + } + path = strings.TrimSuffix(path, "/") + if isDir { + return path + } + return filepath.Dir(path) +} + +func (*ServerImpl) fileInfoInternal(path string, extended bool) (*wshrpc.FileInfo, error) { + cleanedPath := filepath.Clean(wavebase.ExpandHomeDir(path)) + finfo, err := os.Stat(cleanedPath) + if os.IsNotExist(err) { + return &wshrpc.FileInfo{ + Path: wavebase.ReplaceHomeDir(path), + Dir: computeDirPart(path, false), + NotFound: true, + ReadOnly: checkIsReadOnly(cleanedPath, finfo, false), + }, nil + } + if err != nil { + return nil, fmt.Errorf("cannot stat file %q: %w", path, err) + } + rtn := statToFileInfo(cleanedPath, finfo) + if extended { + rtn.ReadOnly = checkIsReadOnly(cleanedPath, finfo, true) + } + return rtn, nil +} + +func (impl *ServerImpl) RemoteFileInfoCommand(ctx context.Context, path string) (*wshrpc.FileInfo, error) { + return impl.fileInfoInternal(path, true) } func (*ServerImpl) RemoteWriteFileCommand(ctx context.Context, data wshrpc.CommandRemoteWriteFileData) error { diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index a107da5bb..bcb38ad8a 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -302,7 +302,8 @@ type CpuDataType struct { } type FileInfo struct { - Path string `json:"path"` // cleaned path + Path string `json:"path"` // cleaned path (may have "~") + Dir string `json:"dir"` // returns the directory part of the path (if this is a a directory, it will be equal to Path). "~" will be expanded, and separators will be normalized to "/" Name string `json:"name"` NotFound bool `json:"notfound,omitempty"` Size int64 `json:"size"` @@ -311,6 +312,7 @@ type FileInfo struct { ModTime int64 `json:"modtime"` IsDir bool `json:"isdir,omitempty"` MimeType string `json:"mimetype,omitempty"` + ReadOnly bool `json:"readonly,omitempty"` // this is not set for fileinfo's returned from directory listings } type CommandRemoteStreamFileData struct {