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);
}
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={() => {}}
/>

View File

@ -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}

View File

@ -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)" }}

View File

@ -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}

View File

@ -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: (

View File

@ -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,

View File

@ -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;
}
return false;
}
if (activeElem.contentEditable == "true") {
return false;
if (keyutil.isInputEvent(e)) {
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,
};

View File

@ -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();
}
});
// 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();

View File

@ -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)[]>([]);

View File

@ -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

View File

@ -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);

View File

@ -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

View File

@ -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,

View File

@ -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);

View File

@ -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)

View File

@ -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 {

View File

@ -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 {