mirror of
https://github.com/wavetermdev/waveterm.git
synced 2024-12-21 16:38:23 +01:00
preview refactor for keyboard/focus (#303)
This commit is contained in:
parent
226bc4ee6f
commit
e3b7ab73c0
@ -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={() => {}}
|
||||
/>
|
||||
|
@ -142,7 +142,6 @@ const BlockFrame_Header = ({
|
||||
const [blockData] = WOS.useWaveObjectValue<Block>(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 = (
|
||||
<ConnectionButton
|
||||
ref={connBtnRef}
|
||||
key={nodeModel.blockId}
|
||||
key="connbutton"
|
||||
connection={blockData?.meta?.connection}
|
||||
changeConnModalAtom={changeConnModalAtom}
|
||||
/>
|
||||
);
|
||||
headerTextElems.unshift(connButtonElem);
|
||||
}
|
||||
headerTextElems.unshift(<ControllerStatusIcon blockId={nodeModel.blockId} />);
|
||||
headerTextElems.unshift(<ControllerStatusIcon key="connstatus" blockId={nodeModel.blockId} />);
|
||||
|
||||
return (
|
||||
<div className="block-frame-default-header" ref={dragHandleRef} onContextMenu={onContextMenu}>
|
||||
@ -327,6 +326,7 @@ const BlockFrame_Default_Component = (props: BlockFrameProps) => {
|
||||
{preview ? null : (
|
||||
<ChangeConnectionBlockModal
|
||||
blockId={nodeModel.blockId}
|
||||
nodeModel={nodeModel}
|
||||
viewModel={viewModel}
|
||||
blockRef={blockModel?.blockRef}
|
||||
changeConnModalAtom={changeConnModalAtom}
|
||||
@ -344,16 +344,19 @@ const ChangeConnectionBlockModal = React.memo(
|
||||
blockRef,
|
||||
connBtnRef,
|
||||
changeConnModalAtom,
|
||||
nodeModel,
|
||||
}: {
|
||||
blockId: string;
|
||||
viewModel: ViewModel;
|
||||
blockRef: React.RefObject<HTMLDivElement>;
|
||||
connBtnRef: React.RefObject<HTMLDivElement>;
|
||||
changeConnModalAtom: jotai.PrimitiveAtom<boolean>;
|
||||
nodeModel: NodeModel;
|
||||
}) => {
|
||||
const [connSelected, setConnSelected] = React.useState("");
|
||||
const changeConnModalOpen = jotai.useAtomValue(changeConnModalAtom);
|
||||
const [blockData] = WOS.useWaveObjectValue<Block>(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}
|
||||
|
@ -189,6 +189,7 @@ export const ControllerStatusIcon = React.memo(({ blockId }: { blockId: string }
|
||||
}
|
||||
const controllerStatusElem = (
|
||||
<i
|
||||
key="controller-status"
|
||||
className="fa-sharp fa-solid fa-triangle-exclamation"
|
||||
title="Controller Is Not Running"
|
||||
style={{ color: "var(--error-color)" }}
|
||||
|
@ -24,6 +24,7 @@ interface InputProps {
|
||||
autoFocus?: boolean;
|
||||
disabled?: boolean;
|
||||
isNumber?: boolean;
|
||||
inputRef?: React.MutableRefObject<HTMLInputElement>;
|
||||
}
|
||||
|
||||
const Input = forwardRef<HTMLDivElement, InputProps>(
|
||||
@ -44,6 +45,7 @@ const Input = forwardRef<HTMLDivElement, InputProps>(
|
||||
autoFocus,
|
||||
disabled,
|
||||
isNumber,
|
||||
inputRef,
|
||||
}: InputProps,
|
||||
ref
|
||||
) => {
|
||||
@ -51,7 +53,7 @@ const Input = forwardRef<HTMLDivElement, InputProps>(
|
||||
const [internalValue, setInternalValue] = useState(defaultValue);
|
||||
const [error, setError] = useState(false);
|
||||
const [hasContent, setHasContent] = useState(Boolean(value || defaultValue));
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const internalInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (value !== undefined) {
|
||||
@ -60,25 +62,32 @@ const Input = forwardRef<HTMLDivElement, InputProps>(
|
||||
}, [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<HTMLDivElement, InputProps>(
|
||||
className={clsx("input-inner-input", {
|
||||
"offset-left": decoration?.startDecoration,
|
||||
})}
|
||||
ref={inputRef}
|
||||
ref={handleSetInputRef}
|
||||
id={label}
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
|
@ -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<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLDivElement>(null);
|
||||
const realInputRef = useRef<HTMLInputElement>(null);
|
||||
const suggestionsRef = useRef<HTMLDivElement>(null);
|
||||
const [suggestionsHeight, setSuggestionsHeight] = useState<number | undefined>(undefined);
|
||||
const [modalHeight, setModalHeight] = useState<string | undefined>(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) => <div className="type-ahead-modal-backdrop" onClick={onClick}></div>;
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
@ -167,9 +186,10 @@ const TypeAheadModal = ({
|
||||
<div className={clsx("content-wrapper", { "has-suggestions": suggestions?.length })}>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
inputRef={realInputRef}
|
||||
onChange={handleChange}
|
||||
value={value}
|
||||
autoFocus
|
||||
autoFocus={autoFocus}
|
||||
placeholder={label}
|
||||
decoration={{
|
||||
endDecoration: (
|
||||
|
@ -537,6 +537,24 @@ function getViewModel(blockId: string): ViewModel {
|
||||
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() {
|
||||
Counters.clear();
|
||||
}
|
||||
@ -615,6 +633,7 @@ export {
|
||||
loadConnStatus,
|
||||
openLink,
|
||||
PLATFORM,
|
||||
refocusNode,
|
||||
registerViewModel,
|
||||
sendWSCommand,
|
||||
setNodeFocus,
|
||||
|
@ -35,20 +35,20 @@ function unsetControlShift() {
|
||||
globalStore.set(atoms.controlShiftDelayAtom, false);
|
||||
}
|
||||
|
||||
function shouldDispatchToBlock(): boolean {
|
||||
function shouldDispatchToBlock(e: WaveKeyboardEvent): boolean {
|
||||
if (globalStore.get(atoms.modalOpen)) {
|
||||
return false;
|
||||
}
|
||||
const activeElem = document.activeElement;
|
||||
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")) {
|
||||
return true;
|
||||
}
|
||||
if (keyutil.isInputEvent(e)) {
|
||||
return false;
|
||||
}
|
||||
if (activeElem.contentEditable == "true") {
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
@ -144,7 +144,7 @@ function appHandleKeyDown(waveEvent: WaveKeyboardEvent): boolean {
|
||||
const layoutModel = getLayoutModelForActiveTab();
|
||||
const focusedNode = globalStore.get(layoutModel.focusedNode);
|
||||
const blockId = focusedNode?.data?.blockId;
|
||||
if (blockId != null && shouldDispatchToBlock()) {
|
||||
if (blockId != null && shouldDispatchToBlock(waveEvent)) {
|
||||
const viewModel = getViewModel(blockId);
|
||||
if (viewModel?.keyDownHandler) {
|
||||
const handledByBlock = viewModel.keyDownHandler(waveEvent);
|
||||
@ -172,6 +172,10 @@ function registerElectronReinjectKeyHandler() {
|
||||
});
|
||||
}
|
||||
|
||||
function tryReinjectKey(event: WaveKeyboardEvent): boolean {
|
||||
return appHandleKeyDown(event);
|
||||
}
|
||||
|
||||
function registerGlobalKeys() {
|
||||
globalKeyMap.set("Cmd:]", () => {
|
||||
switchTab(1);
|
||||
@ -275,5 +279,6 @@ export {
|
||||
registerControlShiftStateUpdateHandler,
|
||||
registerElectronReinjectKeyHandler,
|
||||
registerGlobalKeys,
|
||||
tryReinjectKey,
|
||||
unsetControlShift,
|
||||
};
|
||||
|
@ -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<HTMLDivElement>(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();
|
||||
if (onMount) {
|
||||
unmountRef.current = onMount(editor, monaco);
|
||||
}
|
||||
});
|
||||
// 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();
|
||||
|
@ -37,6 +37,7 @@ interface State {
|
||||
|
||||
const columnHelper = createColumnHelper<any>();
|
||||
|
||||
// TODO remove parentRef dependency -- use own height
|
||||
const CSVView = ({ parentRef, filename, content }: CSVViewProps) => {
|
||||
const csvCacheRef = useRef(new Map<string, string>());
|
||||
const rowRef = useRef<(HTMLTableRowElement | null)[]>([]);
|
||||
|
@ -530,16 +530,15 @@ const MemoizedTableBody = React.memo(
|
||||
) as typeof TableBody;
|
||||
|
||||
interface DirectoryPreviewProps {
|
||||
fileNameAtom: jotai.Atom<string>;
|
||||
model: PreviewModel;
|
||||
}
|
||||
|
||||
function DirectoryPreview({ fileNameAtom, model }: DirectoryPreviewProps) {
|
||||
function DirectoryPreview({ model }: DirectoryPreviewProps) {
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [focusIndex, setFocusIndex] = useState(0);
|
||||
const [unfilteredData, setUnfilteredData] = 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 [selectedPath, setSelectedPath] = useState("");
|
||||
const [refreshVersion, setRefreshVersion] = jotai.useAtom(model.refreshVersion);
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -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<TermWrap>(null);
|
||||
model.termRef = termRef;
|
||||
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);
|
||||
model.htmlElemFocusRef = htmlElemFocusRef;
|
||||
const [blockData] = WOS.useWaveObjectValue<Block>(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);
|
||||
|
2
frontend/types/gotypes.d.ts
vendored
2
frontend/types/gotypes.d.ts
vendored
@ -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
|
||||
|
@ -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<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 {
|
||||
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,
|
||||
|
@ -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);
|
||||
|
@ -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 fileInfo == nil {
|
||||
statRtn, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if stats.IsDir() {
|
||||
fileInfo = statRtn
|
||||
}
|
||||
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)
|
||||
|
@ -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,
|
||||
}
|
||||
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 {
|
||||
|
@ -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 {
|
||||
|
Loading…
Reference in New Issue
Block a user