preview refactor for keyboard/focus (#303)

This commit is contained in:
Mike Sawka 2024-09-02 16:48:10 -07:00 committed by GitHub
parent 226bc4ee6f
commit e3b7ab73c0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 723 additions and 549 deletions

View File

@ -32,7 +32,7 @@ function makeViewModel(blockId: string, blockView: string, nodeModel: NodeModel)
return makeTerminalModel(blockId); return makeTerminalModel(blockId);
} }
if (blockView === "preview") { if (blockView === "preview") {
return makePreviewModel(blockId); return makePreviewModel(blockId, nodeModel);
} }
if (blockView === "web") { if (blockView === "web") {
return makeWebViewModel(blockId, nodeModel); return makeWebViewModel(blockId, nodeModel);
@ -231,7 +231,7 @@ const BlockFull = React.memo(({ nodeModel, viewModel }: FullBlockProps) => {
type="text" type="text"
value="" value=""
ref={focusElemRef} ref={focusElemRef}
id={`${nodeModel.blockId}-dummy-focus`} id={`${nodeModel.blockId}-dummy-focus`} // don't change this name (used in refocusNode)
className="dummy-focus" className="dummy-focus"
onChange={() => {}} onChange={() => {}}
/> />

View File

@ -142,7 +142,6 @@ const BlockFrame_Header = ({
const [blockData] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", nodeModel.blockId)); const [blockData] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", nodeModel.blockId));
const viewName = util.useAtomValueSafe(viewModel.viewName) ?? blockViewToName(blockData?.meta?.view); const viewName = util.useAtomValueSafe(viewModel.viewName) ?? blockViewToName(blockData?.meta?.view);
const showBlockIds = jotai.useAtomValue(useSettingsKeyAtom("blockheader:showblockids")); const showBlockIds = jotai.useAtomValue(useSettingsKeyAtom("blockheader:showblockids"));
const settingsConfig = jotai.useAtomValue(atoms.settingsAtom);
const viewIconUnion = util.useAtomValueSafe(viewModel.viewIcon) ?? blockViewToIcon(blockData?.meta?.view); const viewIconUnion = util.useAtomValueSafe(viewModel.viewIcon) ?? blockViewToIcon(blockData?.meta?.view);
const preIconButton = util.useAtomValueSafe(viewModel.preIconButton); const preIconButton = util.useAtomValueSafe(viewModel.preIconButton);
const headerTextUnion = util.useAtomValueSafe(viewModel.viewText); const headerTextUnion = util.useAtomValueSafe(viewModel.viewText);
@ -186,14 +185,14 @@ const BlockFrame_Header = ({
const connButtonElem = ( const connButtonElem = (
<ConnectionButton <ConnectionButton
ref={connBtnRef} ref={connBtnRef}
key={nodeModel.blockId} key="connbutton"
connection={blockData?.meta?.connection} connection={blockData?.meta?.connection}
changeConnModalAtom={changeConnModalAtom} changeConnModalAtom={changeConnModalAtom}
/> />
); );
headerTextElems.unshift(connButtonElem); headerTextElems.unshift(connButtonElem);
} }
headerTextElems.unshift(<ControllerStatusIcon blockId={nodeModel.blockId} />); headerTextElems.unshift(<ControllerStatusIcon key="connstatus" blockId={nodeModel.blockId} />);
return ( return (
<div className="block-frame-default-header" ref={dragHandleRef} onContextMenu={onContextMenu}> <div className="block-frame-default-header" ref={dragHandleRef} onContextMenu={onContextMenu}>
@ -327,6 +326,7 @@ const BlockFrame_Default_Component = (props: BlockFrameProps) => {
{preview ? null : ( {preview ? null : (
<ChangeConnectionBlockModal <ChangeConnectionBlockModal
blockId={nodeModel.blockId} blockId={nodeModel.blockId}
nodeModel={nodeModel}
viewModel={viewModel} viewModel={viewModel}
blockRef={blockModel?.blockRef} blockRef={blockModel?.blockRef}
changeConnModalAtom={changeConnModalAtom} changeConnModalAtom={changeConnModalAtom}
@ -344,16 +344,19 @@ const ChangeConnectionBlockModal = React.memo(
blockRef, blockRef,
connBtnRef, connBtnRef,
changeConnModalAtom, changeConnModalAtom,
nodeModel,
}: { }: {
blockId: string; blockId: string;
viewModel: ViewModel; viewModel: ViewModel;
blockRef: React.RefObject<HTMLDivElement>; blockRef: React.RefObject<HTMLDivElement>;
connBtnRef: React.RefObject<HTMLDivElement>; connBtnRef: React.RefObject<HTMLDivElement>;
changeConnModalAtom: jotai.PrimitiveAtom<boolean>; changeConnModalAtom: jotai.PrimitiveAtom<boolean>;
nodeModel: NodeModel;
}) => { }) => {
const [connSelected, setConnSelected] = React.useState(""); const [connSelected, setConnSelected] = React.useState("");
const changeConnModalOpen = jotai.useAtomValue(changeConnModalAtom); const changeConnModalOpen = jotai.useAtomValue(changeConnModalAtom);
const [blockData] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", blockId)); const [blockData] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", blockId));
const isNodeFocused = jotai.useAtomValue(nodeModel.isFocused);
const changeConnection = React.useCallback( const changeConnection = React.useCallback(
async (connName: string) => { async (connName: string) => {
const oldCwd = blockData?.meta?.file ?? ""; const oldCwd = blockData?.meta?.file ?? "";
@ -401,6 +404,7 @@ const ChangeConnectionBlockModal = React.memo(
changeConnection(selected); changeConnection(selected);
globalStore.set(changeConnModalAtom, false); globalStore.set(changeConnModalAtom, false);
}} }}
autoFocus={isNodeFocused}
onKeyDown={(e) => keyutil.keydownWrapper(handleTypeAheadKeyDown)(e)} onKeyDown={(e) => keyutil.keydownWrapper(handleTypeAheadKeyDown)(e)}
onChange={(current: string) => setConnSelected(current)} onChange={(current: string) => setConnSelected(current)}
value={connSelected} value={connSelected}

View File

@ -189,6 +189,7 @@ export const ControllerStatusIcon = React.memo(({ blockId }: { blockId: string }
} }
const controllerStatusElem = ( const controllerStatusElem = (
<i <i
key="controller-status"
className="fa-sharp fa-solid fa-triangle-exclamation" className="fa-sharp fa-solid fa-triangle-exclamation"
title="Controller Is Not Running" title="Controller Is Not Running"
style={{ color: "var(--error-color)" }} style={{ color: "var(--error-color)" }}

View File

@ -24,6 +24,7 @@ interface InputProps {
autoFocus?: boolean; autoFocus?: boolean;
disabled?: boolean; disabled?: boolean;
isNumber?: boolean; isNumber?: boolean;
inputRef?: React.MutableRefObject<HTMLInputElement>;
} }
const Input = forwardRef<HTMLDivElement, InputProps>( const Input = forwardRef<HTMLDivElement, InputProps>(
@ -44,6 +45,7 @@ const Input = forwardRef<HTMLDivElement, InputProps>(
autoFocus, autoFocus,
disabled, disabled,
isNumber, isNumber,
inputRef,
}: InputProps, }: InputProps,
ref ref
) => { ) => {
@ -51,7 +53,7 @@ const Input = forwardRef<HTMLDivElement, InputProps>(
const [internalValue, setInternalValue] = useState(defaultValue); const [internalValue, setInternalValue] = useState(defaultValue);
const [error, setError] = useState(false); const [error, setError] = useState(false);
const [hasContent, setHasContent] = useState(Boolean(value || defaultValue)); const [hasContent, setHasContent] = useState(Boolean(value || defaultValue));
const inputRef = useRef<HTMLInputElement>(null); const internalInputRef = useRef<HTMLInputElement>(null);
useEffect(() => { useEffect(() => {
if (value !== undefined) { if (value !== undefined) {
@ -60,25 +62,32 @@ const Input = forwardRef<HTMLDivElement, InputProps>(
}, [value]); }, [value]);
const handleComponentFocus = () => { const handleComponentFocus = () => {
if (inputRef.current && !inputRef.current.contains(document.activeElement)) { if (internalInputRef.current && !internalInputRef.current.contains(document.activeElement)) {
inputRef.current.focus(); internalInputRef.current.focus();
} }
}; };
const handleComponentBlur = () => { const handleComponentBlur = () => {
if (inputRef.current?.contains(document.activeElement)) { if (internalInputRef.current?.contains(document.activeElement)) {
inputRef.current.blur(); internalInputRef.current.blur();
} }
}; };
const handleSetInputRef = (elem: HTMLInputElement) => {
if (inputRef) {
inputRef.current = elem;
}
internalInputRef.current = elem;
};
const handleFocus = () => { const handleFocus = () => {
setFocused(true); setFocused(true);
onFocus && onFocus(); onFocus && onFocus();
}; };
const handleBlur = () => { const handleBlur = () => {
if (inputRef.current) { if (internalInputRef.current) {
const inputValue = inputRef.current.value; const inputValue = internalInputRef.current.value;
if (required && !inputValue) { if (required && !inputValue) {
setError(true); setError(true);
setFocused(false); setFocused(false);
@ -144,7 +153,7 @@ const Input = forwardRef<HTMLDivElement, InputProps>(
className={clsx("input-inner-input", { className={clsx("input-inner-input", {
"offset-left": decoration?.startDecoration, "offset-left": decoration?.startDecoration,
})} })}
ref={inputRef} ref={handleSetInputRef}
id={label} id={label}
value={inputValue} value={inputValue}
onChange={handleInputChange} onChange={handleInputChange}

View File

@ -3,7 +3,7 @@ import { InputDecoration } from "@/app/element/inputdecoration";
import { useDimensions } from "@/app/hook/useDimensions"; import { useDimensions } from "@/app/hook/useDimensions";
import { makeIconClass } from "@/util/util"; import { makeIconClass } from "@/util/util";
import clsx from "clsx"; 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 ReactDOM from "react-dom";
import "./typeaheadmodal.less"; import "./typeaheadmodal.less";
@ -82,6 +82,8 @@ interface TypeAheadModalProps {
onSelect?: (_: string) => void; onSelect?: (_: string) => void;
onClickBackdrop?: () => void; onClickBackdrop?: () => void;
onKeyDown?: (_) => void; onKeyDown?: (_) => void;
giveFocusRef?: React.MutableRefObject<() => boolean>;
autoFocus?: boolean;
} }
const TypeAheadModal = ({ const TypeAheadModal = ({
@ -95,10 +97,13 @@ const TypeAheadModal = ({
onSelect, onSelect,
onKeyDown, onKeyDown,
onClickBackdrop, onClickBackdrop,
giveFocusRef,
autoFocus,
}: TypeAheadModalProps) => { }: TypeAheadModalProps) => {
const { width, height } = useDimensions(blockRef); const { width, height } = useDimensions(blockRef);
const modalRef = useRef<HTMLDivElement>(null); const modalRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLDivElement>(null); const inputRef = useRef<HTMLDivElement>(null);
const realInputRef = useRef<HTMLInputElement>(null);
const suggestionsRef = useRef<HTMLDivElement>(null); const suggestionsRef = useRef<HTMLDivElement>(null);
const [suggestionsHeight, setSuggestionsHeight] = useState<number | undefined>(undefined); const [suggestionsHeight, setSuggestionsHeight] = useState<number | undefined>(undefined);
const [modalHeight, setModalHeight] = useState<string | undefined>(undefined); const [modalHeight, setModalHeight] = useState<string | undefined>(undefined);
@ -125,6 +130,20 @@ const TypeAheadModal = ({
} }
}, [height, suggestions]); }, [height, suggestions]);
useLayoutEffect(() => {
if (giveFocusRef) {
giveFocusRef.current = () => {
realInputRef.current?.focus();
return true;
};
}
return () => {
if (giveFocusRef) {
giveFocusRef.current = null;
}
};
}, [giveFocusRef]);
const renderBackdrop = (onClick) => <div className="type-ahead-modal-backdrop" onClick={onClick}></div>; const renderBackdrop = (onClick) => <div className="type-ahead-modal-backdrop" onClick={onClick}></div>;
const handleKeyDown = (e) => { const handleKeyDown = (e) => {
@ -167,9 +186,10 @@ const TypeAheadModal = ({
<div className={clsx("content-wrapper", { "has-suggestions": suggestions?.length })}> <div className={clsx("content-wrapper", { "has-suggestions": suggestions?.length })}>
<Input <Input
ref={inputRef} ref={inputRef}
inputRef={realInputRef}
onChange={handleChange} onChange={handleChange}
value={value} value={value}
autoFocus autoFocus={autoFocus}
placeholder={label} placeholder={label}
decoration={{ decoration={{
endDecoration: ( endDecoration: (

View File

@ -537,6 +537,24 @@ function getViewModel(blockId: string): ViewModel {
return blockViewModelMap.get(blockId); return blockViewModelMap.get(blockId);
} }
function refocusNode(blockId: string) {
if (blockId == null) {
return;
}
const layoutModel = getLayoutModelForActiveTab();
const layoutNodeId = layoutModel.getNodeByBlockId(blockId);
if (layoutNodeId?.id == null) {
return;
}
layoutModel.focusNode(layoutNodeId.id);
const viewModel = getViewModel(blockId);
const ok = viewModel?.giveFocus?.();
if (!ok) {
const inputElem = document.getElementById(`${blockId}-dummy-focus`);
inputElem?.focus();
}
}
function countersClear() { function countersClear() {
Counters.clear(); Counters.clear();
} }
@ -615,6 +633,7 @@ export {
loadConnStatus, loadConnStatus,
openLink, openLink,
PLATFORM, PLATFORM,
refocusNode,
registerViewModel, registerViewModel,
sendWSCommand, sendWSCommand,
setNodeFocus, setNodeFocus,

View File

@ -35,20 +35,20 @@ function unsetControlShift() {
globalStore.set(atoms.controlShiftDelayAtom, false); globalStore.set(atoms.controlShiftDelayAtom, false);
} }
function shouldDispatchToBlock(): boolean { function shouldDispatchToBlock(e: WaveKeyboardEvent): boolean {
if (globalStore.get(atoms.modalOpen)) { if (globalStore.get(atoms.modalOpen)) {
return false; return false;
} }
const activeElem = document.activeElement; const activeElem = document.activeElement;
if (activeElem != null && activeElem instanceof HTMLElement) { if (activeElem != null && activeElem instanceof HTMLElement) {
if (activeElem.tagName == "INPUT" || activeElem.tagName == "TEXTAREA") { if (activeElem.tagName == "INPUT" || activeElem.tagName == "TEXTAREA" || activeElem.contentEditable == "true") {
if (activeElem.classList.contains("dummy-focus")) { if (activeElem.classList.contains("dummy-focus")) {
return true; return true;
} }
return false; if (keyutil.isInputEvent(e)) {
} return false;
if (activeElem.contentEditable == "true") { }
return false; return true;
} }
} }
return true; return true;
@ -144,7 +144,7 @@ function appHandleKeyDown(waveEvent: WaveKeyboardEvent): boolean {
const layoutModel = getLayoutModelForActiveTab(); const layoutModel = getLayoutModelForActiveTab();
const focusedNode = globalStore.get(layoutModel.focusedNode); const focusedNode = globalStore.get(layoutModel.focusedNode);
const blockId = focusedNode?.data?.blockId; const blockId = focusedNode?.data?.blockId;
if (blockId != null && shouldDispatchToBlock()) { if (blockId != null && shouldDispatchToBlock(waveEvent)) {
const viewModel = getViewModel(blockId); const viewModel = getViewModel(blockId);
if (viewModel?.keyDownHandler) { if (viewModel?.keyDownHandler) {
const handledByBlock = viewModel.keyDownHandler(waveEvent); const handledByBlock = viewModel.keyDownHandler(waveEvent);
@ -172,6 +172,10 @@ function registerElectronReinjectKeyHandler() {
}); });
} }
function tryReinjectKey(event: WaveKeyboardEvent): boolean {
return appHandleKeyDown(event);
}
function registerGlobalKeys() { function registerGlobalKeys() {
globalKeyMap.set("Cmd:]", () => { globalKeyMap.set("Cmd:]", () => {
switchTab(1); switchTab(1);
@ -275,5 +279,6 @@ export {
registerControlShiftStateUpdateHandler, registerControlShiftStateUpdateHandler,
registerElectronReinjectKeyHandler, registerElectronReinjectKeyHandler,
registerGlobalKeys, registerGlobalKeys,
tryReinjectKey,
unsetControlShift, unsetControlShift,
}; };

View File

@ -5,15 +5,14 @@ import { useHeight } from "@/app/hook/useHeight";
import loader from "@monaco-editor/loader"; import loader from "@monaco-editor/loader";
import { Editor, Monaco } from "@monaco-editor/react"; import { Editor, Monaco } from "@monaco-editor/react";
import type * as MonacoTypes from "monaco-editor/esm/vs/editor/editor.api"; 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"; import "./codeeditor.less";
// there is a global monaco variable (TODO get the correct TS type) // there is a global monaco variable (TODO get the correct TS type)
declare var monaco: Monaco; declare var monaco: Monaco;
function loadMonaco() { export function loadMonaco() {
loader.config({ paths: { vs: "monaco" } }); loader.config({ paths: { vs: "monaco" } });
loader loader
.init() .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 { function defaultEditorOptions(): MonacoTypes.editor.IEditorOptions {
const opts: MonacoTypes.editor.IEditorOptions = { const opts: MonacoTypes.editor.IEditorOptions = {
scrollBeyondLastLine: false, scrollBeyondLastLine: false,
@ -66,59 +60,23 @@ interface CodeEditorProps {
filename: string; filename: string;
language?: string; language?: string;
onChange?: (text: string) => void; onChange?: (text: string) => void;
onSave?: () => void; onMount?: (monacoPtr: MonacoTypes.editor.IStandaloneCodeEditor, monaco: Monaco) => () => void;
onCancel?: () => void;
onEdit?: () => void;
} }
export function CodeEditor({ export function CodeEditor({ parentRef, text, language, filename, onChange, onMount }: CodeEditorProps) {
parentRef,
text,
language,
filename,
onChange,
onSave,
onCancel,
onEdit,
}: CodeEditorProps) {
const divRef = useRef<HTMLDivElement>(null); const divRef = useRef<HTMLDivElement>(null);
const unmountRef = useRef<() => void>(null);
const parentHeight = useHeight(parentRef); const parentHeight = useHeight(parentRef);
const theme = "wave-theme-dark"; const theme = "wave-theme-dark";
useEffect(() => { React.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);
return () => { return () => {
currentParentRef.removeEventListener("keydown", handleKeyDown); // unmount function
if (unmountRef.current) {
unmountRef.current();
}
}; };
}, [onSave, onCancel, onEdit]); }, []);
function handleEditorChange(text: string, ev: MonacoTypes.editor.IModelContentChangedEvent) { function handleEditorChange(text: string, ev: MonacoTypes.editor.IModelContentChangedEvent) {
if (onChange) { if (onChange) {
@ -127,24 +85,9 @@ export function CodeEditor({
} }
function handleEditorOnMount(editor: MonacoTypes.editor.IStandaloneCodeEditor, monaco: Monaco) { function handleEditorOnMount(editor: MonacoTypes.editor.IStandaloneCodeEditor, monaco: Monaco) {
// bind Cmd:e if (onMount) {
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyE, () => { unmountRef.current = onMount(editor, monaco);
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();
}
});
} }
const editorOpts = defaultEditorOptions(); const editorOpts = defaultEditorOptions();

View File

@ -37,6 +37,7 @@ interface State {
const columnHelper = createColumnHelper<any>(); const columnHelper = createColumnHelper<any>();
// TODO remove parentRef dependency -- use own height
const CSVView = ({ parentRef, filename, content }: CSVViewProps) => { const CSVView = ({ parentRef, filename, content }: CSVViewProps) => {
const csvCacheRef = useRef(new Map<string, string>()); const csvCacheRef = useRef(new Map<string, string>());
const rowRef = useRef<(HTMLTableRowElement | null)[]>([]); const rowRef = useRef<(HTMLTableRowElement | null)[]>([]);

View File

@ -530,16 +530,15 @@ const MemoizedTableBody = React.memo(
) as typeof TableBody; ) as typeof TableBody;
interface DirectoryPreviewProps { interface DirectoryPreviewProps {
fileNameAtom: jotai.Atom<string>;
model: PreviewModel; model: PreviewModel;
} }
function DirectoryPreview({ fileNameAtom, model }: DirectoryPreviewProps) { function DirectoryPreview({ model }: DirectoryPreviewProps) {
const [searchText, setSearchText] = useState(""); const [searchText, setSearchText] = useState("");
const [focusIndex, setFocusIndex] = useState(0); const [focusIndex, setFocusIndex] = useState(0);
const [unfilteredData, setUnfilteredData] = useState<FileInfo[]>([]); const [unfilteredData, setUnfilteredData] = useState<FileInfo[]>([]);
const [filteredData, setFilteredData] = useState<FileInfo[]>([]); const [filteredData, setFilteredData] = useState<FileInfo[]>([]);
const fileName = jotai.useAtomValue(fileNameAtom); const fileName = jotai.useAtomValue(model.metaFilePath);
const showHiddenFiles = jotai.useAtomValue(model.showHiddenFiles); const showHiddenFiles = jotai.useAtomValue(model.showHiddenFiles);
const [selectedPath, setSelectedPath] = useState(""); const [selectedPath, setSelectedPath] = useState("");
const [refreshVersion, setRefreshVersion] = jotai.useAtom(model.refreshVersion); const [refreshVersion, setRefreshVersion] = jotai.useAtom(model.refreshVersion);

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,7 @@
import { WshServer } from "@/app/store/wshserver"; import { WshServer } from "@/app/store/wshserver";
import { VDomView } from "@/app/view/term/vdom"; 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 services from "@/store/services";
import * as keyutil from "@/util/keyutil"; import * as keyutil from "@/util/keyutil";
import * as util from "@/util/util"; import * as util from "@/util/util";
@ -205,9 +205,6 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => {
const termRef = React.useRef<TermWrap>(null); const termRef = React.useRef<TermWrap>(null);
model.termRef = termRef; model.termRef = termRef;
const shellProcStatusRef = React.useRef<string>(null); const shellProcStatusRef = React.useRef<string>(null);
const blockIconOverrideAtom = useBlockAtom<string>(blockId, "blockicon:override", () => {
return jotai.atom<string>(null);
}) as jotai.PrimitiveAtom<string>;
const htmlElemFocusRef = React.useRef<HTMLInputElement>(null); const htmlElemFocusRef = React.useRef<HTMLInputElement>(null);
model.htmlElemFocusRef = htmlElemFocusRef; model.htmlElemFocusRef = htmlElemFocusRef;
const [blockData] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", blockId)); const [blockData] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", blockId));
@ -310,10 +307,8 @@ const TerminalView = ({ blockId, model }: TerminalViewProps) => {
shellProcStatusRef.current = status; shellProcStatusRef.current = status;
if (status == "running") { if (status == "running") {
termRef.current?.setIsRunning(true); termRef.current?.setIsRunning(true);
globalStore.set(blockIconOverrideAtom, "terminal");
} else { } else {
termRef.current?.setIsRunning(false); termRef.current?.setIsRunning(false);
globalStore.set(blockIconOverrideAtom, "regular@terminal");
} }
} }
const initialRTStatus = services.BlockService.GetControllerStatus(blockId); const initialRTStatus = services.BlockService.GetControllerStatus(blockId);

View File

@ -175,6 +175,7 @@ declare global {
// wshrpc.FileInfo // wshrpc.FileInfo
type FileInfo = { type FileInfo = {
path: string; path: string;
dir: string;
name: string; name: string;
notfound?: boolean; notfound?: boolean;
size: number; size: number;
@ -183,6 +184,7 @@ declare global {
modtime: number; modtime: number;
isdir?: boolean; isdir?: boolean;
mimetype?: string; mimetype?: string;
readonly?: boolean;
}; };
// filestore.FileOptsType // filestore.FileOptsType

View File

@ -14,6 +14,10 @@ function setKeyUtilPlatform(platform: NodeJS.Platform) {
PLATFORM = platform; PLATFORM = platform;
} }
function getKeyUtilPlatform(): NodeJS.Platform {
return PLATFORM;
}
function keydownWrapper( function keydownWrapper(
fn: (waveEvent: WaveKeyboardEvent) => boolean fn: (waveEvent: WaveKeyboardEvent) => boolean
): (event: KeyboardEvent | React.KeyboardEvent) => void { ): (event: KeyboardEvent | React.KeyboardEvent) => void {
@ -85,6 +89,53 @@ function isCharacterKeyEvent(event: WaveKeyboardEvent): boolean {
return util.countGraphemes(event.key) == 1; return util.countGraphemes(event.key) == 1;
} }
const inputKeyMap = new Map<string, boolean>([
["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 { function checkKeyPressed(event: WaveKeyboardEvent, keyDescription: string): boolean {
let keyPress = parseKeyDescription(keyDescription); let keyPress = parseKeyDescription(keyDescription);
if (!keyPress.mods.Alt && notMod(keyPress.mods.Option, event.option)) { if (!keyPress.mods.Alt && notMod(keyPress.mods.Option, event.option)) {
@ -174,7 +225,9 @@ export {
adaptFromElectronKeyEvent, adaptFromElectronKeyEvent,
adaptFromReactOrNativeKeyEvent, adaptFromReactOrNativeKeyEvent,
checkKeyPressed, checkKeyPressed,
getKeyUtilPlatform,
isCharacterKeyEvent, isCharacterKeyEvent,
isInputEvent,
keydownWrapper, keydownWrapper,
parseKeyDescription, parseKeyDescription,
setKeyUtilPlatform, setKeyUtilPlatform,

View File

@ -7,6 +7,8 @@ import {
registerGlobalKeys, registerGlobalKeys,
} from "@/app/store/keymodel"; } from "@/app/store/keymodel";
import { WshServer } from "@/app/store/wshserver"; import { WshServer } from "@/app/store/wshserver";
import { loadMonaco } from "@/app/view/codeeditor/codeeditor";
import { getLayoutModelForActiveTab } from "@/layout/index";
import { import {
atoms, atoms,
countersClear, countersClear,
@ -48,6 +50,7 @@ loadFonts();
(window as any).isFullScreen = false; (window as any).isFullScreen = false;
(window as any).countersPrint = countersPrint; (window as any).countersPrint = countersPrint;
(window as any).countersClear = countersClear; (window as any).countersClear = countersClear;
(window as any).getLayoutModelForActiveTab = getLayoutModelForActiveTab;
document.title = `The Next Wave (${windowId.substring(0, 8)})`; document.title = `The Next Wave (${windowId.substring(0, 8)})`;
@ -65,6 +68,7 @@ document.addEventListener("DOMContentLoaded", async () => {
registerGlobalKeys(); registerGlobalKeys();
registerElectronReinjectKeyHandler(); registerElectronReinjectKeyHandler();
registerControlShiftStateUpdateHandler(); registerControlShiftStateUpdateHandler();
setTimeout(loadMonaco, 30);
const fullConfig = await services.FileService.GetFullConfig(); const fullConfig = await services.FileService.GetFullConfig();
console.log("fullconfig", fullConfig); console.log("fullconfig", fullConfig);
globalStore.set(atoms.fullConfigAtom, fullConfig); globalStore.set(atoms.fullConfigAtom, fullConfig);

View File

@ -13,6 +13,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"io/fs"
"math" "math"
mathrand "math/rand" mathrand "math/rand"
"mime" "mime"
@ -627,7 +628,8 @@ func CopyToChannel(outputCh chan<- []byte, reader io.Reader) error {
// on error just returns "" // on error just returns ""
// does not return "application/octet-stream" as this is considered a detection failure // 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) ext := filepath.Ext(path)
if mimeType, ok := StaticMimeTypeMap[ext]; ok { if mimeType, ok := StaticMimeTypeMap[ext]; ok {
return mimeType return mimeType
@ -635,21 +637,24 @@ func DetectMimeType(path string) string {
if mimeType := mime.TypeByExtension(ext); mimeType != "" { if mimeType := mime.TypeByExtension(ext); mimeType != "" {
return mimeType return mimeType
} }
stats, err := os.Stat(path) if fileInfo == nil {
if err != nil { statRtn, err := os.Stat(path)
return "" if err != nil {
return ""
}
fileInfo = statRtn
} }
if stats.IsDir() { if fileInfo.IsDir() {
return "directory" return "directory"
} }
if stats.Mode()&os.ModeNamedPipe == os.ModeNamedPipe { if fileInfo.Mode()&os.ModeNamedPipe == os.ModeNamedPipe {
return "pipe" return "pipe"
} }
charDevice := os.ModeDevice | os.ModeCharDevice charDevice := os.ModeDevice | os.ModeCharDevice
if stats.Mode()&charDevice == charDevice { if fileInfo.Mode()&charDevice == charDevice {
return "character-special" return "character-special"
} }
if stats.Mode()&os.ModeDevice == os.ModeDevice { if fileInfo.Mode()&os.ModeDevice == os.ModeDevice {
return "block-special" return "block-special"
} }
fd, err := os.Open(path) fd, err := os.Open(path)

View File

@ -9,9 +9,11 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"io/fs"
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"github.com/wavetermdev/thenextwave/pkg/util/utilfn" "github.com/wavetermdev/thenextwave/pkg/util/utilfn"
"github.com/wavetermdev/thenextwave/pkg/wavebase" "github.com/wavetermdev/thenextwave/pkg/wavebase"
@ -88,7 +90,7 @@ func (impl *ServerImpl) remoteStreamFileDir(ctx context.Context, path string, by
} }
var fileInfoArr []*wshrpc.FileInfo var fileInfoArr []*wshrpc.FileInfo
parent := filepath.Dir(path) parent := filepath.Dir(path)
parentFileInfo, err := impl.RemoteFileInfoCommand(ctx, parent) parentFileInfo, err := impl.fileInfoInternal(parent, false)
if err == nil && parent != path { if err == nil && parent != path {
parentFileInfo.Name = ".." parentFileInfo.Name = ".."
parentFileInfo.Size = -1 parentFileInfo.Size = -1
@ -102,24 +104,8 @@ func (impl *ServerImpl) remoteStreamFileDir(ctx context.Context, path string, by
if err != nil { if err != nil {
continue continue
} }
mimeType := utilfn.DetectMimeType(filepath.Join(path, innerFileInfoInt.Name())) innerFileInfo := statToFileInfo(filepath.Join(path, innerFileInfoInt.Name()), innerFileInfoInt)
var fileSize int64 fileInfoArr = append(fileInfoArr, innerFileInfo)
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)
if len(fileInfoArr) >= DirChunkSize { if len(fileInfoArr) >= DirChunkSize {
dataCallback(fileInfoArr, nil) dataCallback(fileInfoArr, nil)
fileInfoArr = nil fileInfoArr = nil
@ -179,7 +165,7 @@ func (impl *ServerImpl) remoteStreamFileInternal(ctx context.Context, data wshrp
} }
path := data.Path path := data.Path
path = wavebase.ExpandHomeDir(path) path = wavebase.ExpandHomeDir(path)
finfo, err := impl.RemoteFileInfoCommand(ctx, path) finfo, err := impl.fileInfoInternal(path, true)
if err != nil { if err != nil {
return fmt.Errorf("cannot stat file %q: %w", path, err) 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 return ch
} }
func (*ServerImpl) RemoteFileInfoCommand(ctx context.Context, path string) (*wshrpc.FileInfo, error) { func statToFileInfo(fullPath string, finfo fs.FileInfo) *wshrpc.FileInfo {
cleanedPath := filepath.Clean(wavebase.ExpandHomeDir(path)) mimeType := utilfn.DetectMimeType(fullPath, finfo)
finfo, err := os.Stat(cleanedPath) rtn := &wshrpc.FileInfo{
if os.IsNotExist(err) { Path: wavebase.ReplaceHomeDir(fullPath),
return &wshrpc.FileInfo{Path: wavebase.ReplaceHomeDir(path), NotFound: true}, nil Dir: computeDirPart(fullPath, finfo.IsDir()),
}
if err != nil {
return nil, fmt.Errorf("cannot stat file %q: %w", path, err)
}
mimeType := utilfn.DetectMimeType(cleanedPath)
return &wshrpc.FileInfo{
Path: cleanedPath,
Name: finfo.Name(), Name: finfo.Name(),
Size: finfo.Size(), Size: finfo.Size(),
Mode: finfo.Mode(), Mode: finfo.Mode(),
@ -233,7 +212,75 @@ func (*ServerImpl) RemoteFileInfoCommand(ctx context.Context, path string) (*wsh
ModTime: finfo.ModTime().UnixMilli(), ModTime: finfo.ModTime().UnixMilli(),
IsDir: finfo.IsDir(), IsDir: finfo.IsDir(),
MimeType: mimeType, 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 { func (*ServerImpl) RemoteWriteFileCommand(ctx context.Context, data wshrpc.CommandRemoteWriteFileData) error {

View File

@ -302,7 +302,8 @@ type CpuDataType struct {
} }
type FileInfo 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"` Name string `json:"name"`
NotFound bool `json:"notfound,omitempty"` NotFound bool `json:"notfound,omitempty"`
Size int64 `json:"size"` Size int64 `json:"size"`
@ -311,6 +312,7 @@ type FileInfo struct {
ModTime int64 `json:"modtime"` ModTime int64 `json:"modtime"`
IsDir bool `json:"isdir,omitempty"` IsDir bool `json:"isdir,omitempty"`
MimeType string `json:"mimetype,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 { type CommandRemoteStreamFileData struct {