mirror of
https://github.com/wavetermdev/waveterm.git
synced 2025-01-02 18:39:05 +01:00
Create and rename files and dirs in dirpreview (#1156)
New context menu options are available in the directory preview to create and rename files and directories It's missing three pieces of functionality, none of which are a regression: - Editing or creating an entry does not update the focused index. Focus index right now is pretty dumb, it doesn't factor in the column sorting so if you change that, the selected item will change to whatever is now at that index. We should update this so we use the actual file name to determine which element to focus and let the table determine which index to then highlight given the current sorting algo - Open in native preview should not be an option on remote connections with the exception of WSL, where it should resolve the file in the Windows filesystem, rather than the WSL one - We should catch CRUD errors in the dir preview and display a popup
This commit is contained in:
parent
f606a74339
commit
04c4f0a203
@ -359,9 +359,11 @@ electron.ipcMain.on("quicklook", (event, filePath: string) => {
|
|||||||
|
|
||||||
electron.ipcMain.on("open-native-path", (event, filePath: string) => {
|
electron.ipcMain.on("open-native-path", (event, filePath: string) => {
|
||||||
console.log("open-native-path", filePath);
|
console.log("open-native-path", filePath);
|
||||||
electron.shell.openPath(filePath).catch((err) => {
|
fireAndForget(async () =>
|
||||||
console.error(`Failed to open path ${filePath}:`, err);
|
electron.shell.openPath(filePath).then((excuse) => {
|
||||||
});
|
if (excuse) console.error(`Failed to open ${filePath} in native application: ${excuse}`);
|
||||||
|
})
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
async function createNewWaveWindow(): Promise<void> {
|
async function createNewWaveWindow(): Promise<void> {
|
||||||
|
@ -2,15 +2,15 @@
|
|||||||
// SPDX-License-Identifier: Apache-2.0
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
.popover-content {
|
.popover-content {
|
||||||
min-width: 100px;
|
min-width: 100px;
|
||||||
min-height: 150px;
|
min-height: 150px;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 1000; // TODO: put this in theme.scss
|
z-index: 1000; // TODO: put this in theme.scss
|
||||||
display: flex;
|
display: flex;
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
gap: 1px;
|
gap: 1px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
background: #212121;
|
background: #212121;
|
||||||
box-shadow: 0px 8px 24px 0px rgba(0, 0, 0, 0.3);
|
box-shadow: 0px 8px 24px 0px rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
@ -5,6 +5,7 @@ import { Button } from "@/element/button";
|
|||||||
import {
|
import {
|
||||||
autoUpdate,
|
autoUpdate,
|
||||||
FloatingPortal,
|
FloatingPortal,
|
||||||
|
Middleware,
|
||||||
offset as offsetMiddleware,
|
offset as offsetMiddleware,
|
||||||
useClick,
|
useClick,
|
||||||
useDismiss,
|
useDismiss,
|
||||||
@ -34,6 +35,7 @@ interface PopoverProps {
|
|||||||
placement?: Placement;
|
placement?: Placement;
|
||||||
offset?: OffsetOptions;
|
offset?: OffsetOptions;
|
||||||
onDismiss?: () => void;
|
onDismiss?: () => void;
|
||||||
|
middleware?: Middleware[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const isPopoverButton = (
|
const isPopoverButton = (
|
||||||
@ -48,54 +50,63 @@ const isPopoverContent = (
|
|||||||
return element.type === PopoverContent;
|
return element.type === PopoverContent;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Popover = memo(({ children, className, placement = "bottom-start", offset = 3, onDismiss }: PopoverProps) => {
|
const Popover = memo(
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
({ children, className, placement = "bottom-start", offset = 3, onDismiss, middleware }: PopoverProps) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
const handleOpenChange = (open: boolean) => {
|
const handleOpenChange = (open: boolean) => {
|
||||||
setIsOpen(open);
|
setIsOpen(open);
|
||||||
if (!open && onDismiss) {
|
if (!open && onDismiss) {
|
||||||
onDismiss();
|
onDismiss();
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const { refs, floatingStyles, context } = useFloating({
|
|
||||||
placement,
|
|
||||||
open: isOpen,
|
|
||||||
onOpenChange: handleOpenChange,
|
|
||||||
middleware: [offsetMiddleware(offset)],
|
|
||||||
whileElementsMounted: autoUpdate,
|
|
||||||
});
|
|
||||||
|
|
||||||
const click = useClick(context);
|
|
||||||
const dismiss = useDismiss(context);
|
|
||||||
const { getReferenceProps, getFloatingProps } = useInteractions([click, dismiss]);
|
|
||||||
|
|
||||||
const renderChildren = Children.map(children, (child) => {
|
|
||||||
if (isValidElement(child)) {
|
|
||||||
if (isPopoverButton(child)) {
|
|
||||||
return cloneElement(child as any, {
|
|
||||||
isActive: isOpen,
|
|
||||||
ref: refs.setReference,
|
|
||||||
getReferenceProps,
|
|
||||||
// Do not overwrite onClick
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (isPopoverContent(child)) {
|
if (offset === undefined) {
|
||||||
return isOpen
|
offset = 3;
|
||||||
? cloneElement(child as any, {
|
|
||||||
ref: refs.setFloating,
|
|
||||||
style: floatingStyles,
|
|
||||||
getFloatingProps,
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return child;
|
|
||||||
});
|
|
||||||
|
|
||||||
return <div className={clsx("popover", className)}>{renderChildren}</div>;
|
middleware ??= [];
|
||||||
});
|
middleware.push(offsetMiddleware(offset));
|
||||||
|
|
||||||
|
const { refs, floatingStyles, context } = useFloating({
|
||||||
|
placement,
|
||||||
|
open: isOpen,
|
||||||
|
onOpenChange: handleOpenChange,
|
||||||
|
middleware: middleware,
|
||||||
|
whileElementsMounted: autoUpdate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const click = useClick(context);
|
||||||
|
const dismiss = useDismiss(context);
|
||||||
|
const { getReferenceProps, getFloatingProps } = useInteractions([click, dismiss]);
|
||||||
|
|
||||||
|
const renderChildren = Children.map(children, (child) => {
|
||||||
|
if (isValidElement(child)) {
|
||||||
|
if (isPopoverButton(child)) {
|
||||||
|
return cloneElement(child as any, {
|
||||||
|
isActive: isOpen,
|
||||||
|
ref: refs.setReference,
|
||||||
|
getReferenceProps,
|
||||||
|
// Do not overwrite onClick
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPopoverContent(child)) {
|
||||||
|
return isOpen
|
||||||
|
? cloneElement(child as any, {
|
||||||
|
ref: refs.setFloating,
|
||||||
|
style: floatingStyles,
|
||||||
|
getFloatingProps,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return child;
|
||||||
|
});
|
||||||
|
|
||||||
|
return <div className={clsx("popover", className)}>{renderChildren}</div>;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
interface PopoverButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
interface PopoverButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
|
@ -59,11 +59,17 @@ class FileServiceType {
|
|||||||
GetWaveFile(arg1: string, arg2: string): Promise<any> {
|
GetWaveFile(arg1: string, arg2: string): Promise<any> {
|
||||||
return WOS.callBackendService("file", "GetWaveFile", Array.from(arguments))
|
return WOS.callBackendService("file", "GetWaveFile", Array.from(arguments))
|
||||||
}
|
}
|
||||||
|
Mkdir(arg1: string, arg2: string): Promise<void> {
|
||||||
|
return WOS.callBackendService("file", "Mkdir", Array.from(arguments))
|
||||||
|
}
|
||||||
|
|
||||||
// read file
|
// read file
|
||||||
ReadFile(connection: string, path: string): Promise<FullFile> {
|
ReadFile(connection: string, path: string): Promise<FullFile> {
|
||||||
return WOS.callBackendService("file", "ReadFile", Array.from(arguments))
|
return WOS.callBackendService("file", "ReadFile", Array.from(arguments))
|
||||||
}
|
}
|
||||||
|
Rename(arg1: string, arg2: string, arg3: string): Promise<void> {
|
||||||
|
return WOS.callBackendService("file", "Rename", Array.from(arguments))
|
||||||
|
}
|
||||||
|
|
||||||
// save file
|
// save file
|
||||||
SaveFile(connection: string, path: string, data64: string): Promise<void> {
|
SaveFile(connection: string, path: string, data64: string): Promise<void> {
|
||||||
@ -74,6 +80,9 @@ class FileServiceType {
|
|||||||
StatFile(connection: string, path: string): Promise<FileInfo> {
|
StatFile(connection: string, path: string): Promise<FileInfo> {
|
||||||
return WOS.callBackendService("file", "StatFile", Array.from(arguments))
|
return WOS.callBackendService("file", "StatFile", Array.from(arguments))
|
||||||
}
|
}
|
||||||
|
TouchFile(arg1: string, arg2: string): Promise<void> {
|
||||||
|
return WOS.callBackendService("file", "TouchFile", Array.from(arguments))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FileService = new FileServiceType();
|
export const FileService = new FileServiceType();
|
||||||
|
@ -212,6 +212,21 @@ class RpcApiType {
|
|||||||
return client.wshRpcCall("remotefilejoin", data, opts);
|
return client.wshRpcCall("remotefilejoin", data, opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// command "remotefilerename" [call]
|
||||||
|
RemoteFileRenameCommand(client: WshClient, data: string[], opts?: RpcOpts): Promise<void> {
|
||||||
|
return client.wshRpcCall("remotefilerename", data, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// command "remotefiletouch" [call]
|
||||||
|
RemoteFileTouchCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<void> {
|
||||||
|
return client.wshRpcCall("remotefiletouch", data, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// command "remotemkdir" [call]
|
||||||
|
RemoteMkdirCommand(client: WshClient, data: string, opts?: RpcOpts): Promise<void> {
|
||||||
|
return client.wshRpcCall("remotemkdir", data, opts);
|
||||||
|
}
|
||||||
|
|
||||||
// command "remotestreamcpudata" [responsestream]
|
// command "remotestreamcpudata" [responsestream]
|
||||||
RemoteStreamCpuDataCommand(client: WshClient, opts?: RpcOpts): AsyncGenerator<TimeSeriesData, void, boolean> {
|
RemoteStreamCpuDataCommand(client: WshClient, opts?: RpcOpts): AsyncGenerator<TimeSeriesData, void, boolean> {
|
||||||
return client.wshRpcStream("remotestreamcpudata", null, opts);
|
return client.wshRpcStream("remotestreamcpudata", null, opts);
|
||||||
|
@ -207,7 +207,11 @@ const WorkspaceSwitcher = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover className="workspace-switcher-popover" onDismiss={() => setEditingWorkspace(null)}>
|
<Popover
|
||||||
|
className="workspace-switcher-popover"
|
||||||
|
placement="bottom-start"
|
||||||
|
onDismiss={() => setEditingWorkspace(null)}
|
||||||
|
>
|
||||||
<PopoverButton
|
<PopoverButton
|
||||||
className="workspace-switcher-button grey"
|
className="workspace-switcher-button grey"
|
||||||
as="div"
|
as="div"
|
||||||
|
@ -230,3 +230,17 @@
|
|||||||
background-color: var(--highlight-bg-color);
|
background-color: var(--highlight-bg-color);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.entry-manager-overlay {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-width: 90%;
|
||||||
|
max-height: fit-content;
|
||||||
|
display: flex;
|
||||||
|
padding: 10px;
|
||||||
|
gap: 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
background: #212121;
|
||||||
|
box-shadow: 0px 8px 24px 0px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
@ -1,15 +1,18 @@
|
|||||||
// Copyright 2024, Command Line Inc.
|
// Copyright 2024, Command Line Inc.
|
||||||
// SPDX-License-Identifier: Apache-2.0
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
import { Input } from "@/app/element/input";
|
||||||
import { ContextMenuModel } from "@/app/store/contextmenu";
|
import { ContextMenuModel } from "@/app/store/contextmenu";
|
||||||
import { PLATFORM, atoms, createBlock, getApi } from "@/app/store/global";
|
import { PLATFORM, atoms, createBlock, getApi, globalStore } from "@/app/store/global";
|
||||||
import { FileService } from "@/app/store/services";
|
import { FileService } from "@/app/store/services";
|
||||||
import type { PreviewModel } from "@/app/view/preview/preview";
|
import type { PreviewModel } from "@/app/view/preview/preview";
|
||||||
import { checkKeyPressed, isCharacterKeyEvent } from "@/util/keyutil";
|
import { checkKeyPressed, isCharacterKeyEvent } from "@/util/keyutil";
|
||||||
import { base64ToString, isBlank } from "@/util/util";
|
import { base64ToString, fireAndForget, isBlank } from "@/util/util";
|
||||||
|
import { offset, useDismiss, useFloating, useInteractions } from "@floating-ui/react";
|
||||||
import {
|
import {
|
||||||
Column,
|
Column,
|
||||||
Row,
|
Row,
|
||||||
|
RowData,
|
||||||
Table,
|
Table,
|
||||||
createColumnHelper,
|
createColumnHelper,
|
||||||
flexRender,
|
flexRender,
|
||||||
@ -19,13 +22,21 @@ import {
|
|||||||
} from "@tanstack/react-table";
|
} from "@tanstack/react-table";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { PrimitiveAtom, atom, useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||||
import { OverlayScrollbarsComponent, OverlayScrollbarsComponentRef } from "overlayscrollbars-react";
|
import { OverlayScrollbarsComponent, OverlayScrollbarsComponentRef } from "overlayscrollbars-react";
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import React, { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { quote as shellQuote } from "shell-quote";
|
import { quote as shellQuote } from "shell-quote";
|
||||||
import { debounce } from "throttle-debounce";
|
import { debounce } from "throttle-debounce";
|
||||||
import "./directorypreview.scss";
|
import "./directorypreview.scss";
|
||||||
|
|
||||||
|
declare module "@tanstack/react-table" {
|
||||||
|
interface TableMeta<TData extends RowData> {
|
||||||
|
updateName: (path: string) => void;
|
||||||
|
newFile: () => void;
|
||||||
|
newDirectory: () => void;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface DirectoryTableProps {
|
interface DirectoryTableProps {
|
||||||
model: PreviewModel;
|
model: PreviewModel;
|
||||||
data: FileInfo[];
|
data: FileInfo[];
|
||||||
@ -35,6 +46,9 @@ interface DirectoryTableProps {
|
|||||||
setSearch: (_: string) => void;
|
setSearch: (_: string) => void;
|
||||||
setSelectedPath: (_: string) => void;
|
setSelectedPath: (_: string) => void;
|
||||||
setRefreshVersion: React.Dispatch<React.SetStateAction<number>>;
|
setRefreshVersion: React.Dispatch<React.SetStateAction<number>>;
|
||||||
|
entryManagerOverlayPropsAtom: PrimitiveAtom<EntryManagerOverlayProps>;
|
||||||
|
newFile: () => void;
|
||||||
|
newDirectory: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const columnHelper = createColumnHelper<FileInfo>();
|
const columnHelper = createColumnHelper<FileInfo>();
|
||||||
@ -121,6 +135,46 @@ function cleanMimetype(input: string): string {
|
|||||||
return truncated.trim();
|
return truncated.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum EntryManagerType {
|
||||||
|
NewFile = "New File",
|
||||||
|
NewDirectory = "New Folder",
|
||||||
|
EditName = "Rename",
|
||||||
|
}
|
||||||
|
|
||||||
|
type EntryManagerOverlayProps = {
|
||||||
|
forwardRef?: React.Ref<HTMLDivElement>;
|
||||||
|
entryManagerType: EntryManagerType;
|
||||||
|
startingValue?: string;
|
||||||
|
onSave: (newValue: string) => void;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
getReferenceProps?: () => any;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EntryManagerOverlay = memo(
|
||||||
|
({ entryManagerType, startingValue, onSave, forwardRef, style, getReferenceProps }: EntryManagerOverlayProps) => {
|
||||||
|
const [value, setValue] = useState(startingValue);
|
||||||
|
return (
|
||||||
|
<div className="entry-manager-overlay" ref={forwardRef} style={style} {...getReferenceProps()}>
|
||||||
|
<div className="entry-manager-type">{entryManagerType}</div>
|
||||||
|
<div className="entry-manager-input">
|
||||||
|
<Input
|
||||||
|
value={value}
|
||||||
|
onChange={setValue}
|
||||||
|
autoFocus={true}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onSave(value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
></Input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
function DirectoryTable({
|
function DirectoryTable({
|
||||||
model,
|
model,
|
||||||
data,
|
data,
|
||||||
@ -130,6 +184,9 @@ function DirectoryTable({
|
|||||||
setSearch,
|
setSearch,
|
||||||
setSelectedPath,
|
setSelectedPath,
|
||||||
setRefreshVersion,
|
setRefreshVersion,
|
||||||
|
entryManagerOverlayPropsAtom,
|
||||||
|
newFile,
|
||||||
|
newDirectory,
|
||||||
}: DirectoryTableProps) {
|
}: DirectoryTableProps) {
|
||||||
const fullConfig = useAtomValue(atoms.fullConfigAtom);
|
const fullConfig = useAtomValue(atoms.fullConfigAtom);
|
||||||
const getIconFromMimeType = useCallback(
|
const getIconFromMimeType = useCallback(
|
||||||
@ -205,6 +262,28 @@ function DirectoryTable({
|
|||||||
[fullConfig]
|
[fullConfig]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const setEntryManagerProps = useSetAtom(entryManagerOverlayPropsAtom);
|
||||||
|
|
||||||
|
const updateName = useCallback((path: string) => {
|
||||||
|
const fileName = path.split("/").at(-1);
|
||||||
|
setEntryManagerProps({
|
||||||
|
entryManagerType: EntryManagerType.EditName,
|
||||||
|
startingValue: fileName,
|
||||||
|
onSave: (newName: string) => {
|
||||||
|
let newPath: string;
|
||||||
|
if (newName !== fileName) {
|
||||||
|
newPath = path.replace(fileName, newName);
|
||||||
|
console.log(`replacing ${fileName} with ${newName}: ${path}`);
|
||||||
|
fireAndForget(async () => {
|
||||||
|
await FileService.Rename(globalStore.get(model.connection), path, newPath);
|
||||||
|
model.refreshCallback();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setEntryManagerProps(undefined);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data,
|
data,
|
||||||
columns,
|
columns,
|
||||||
@ -229,6 +308,11 @@ function DirectoryTable({
|
|||||||
},
|
},
|
||||||
enableMultiSort: false,
|
enableMultiSort: false,
|
||||||
enableSortingRemoval: false,
|
enableSortingRemoval: false,
|
||||||
|
meta: {
|
||||||
|
updateName,
|
||||||
|
newFile,
|
||||||
|
newDirectory,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -418,6 +502,27 @@ function TableBody({
|
|||||||
openNativeLabel = "Open File in Default Application";
|
openNativeLabel = "Open File in Default Application";
|
||||||
}
|
}
|
||||||
const menu: ContextMenuItem[] = [
|
const menu: ContextMenuItem[] = [
|
||||||
|
{
|
||||||
|
label: "New File",
|
||||||
|
click: () => {
|
||||||
|
table.options.meta.newFile();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "New Folder",
|
||||||
|
click: () => {
|
||||||
|
table.options.meta.newDirectory();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Rename",
|
||||||
|
click: () => {
|
||||||
|
table.options.meta.updateName(finfo.path);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "separator",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Copy File Name",
|
label: "Copy File Name",
|
||||||
click: () => navigator.clipboard.writeText(fileName),
|
click: () => navigator.clipboard.writeText(fileName),
|
||||||
@ -446,6 +551,7 @@ function TableBody({
|
|||||||
{
|
{
|
||||||
type: "separator",
|
type: "separator",
|
||||||
},
|
},
|
||||||
|
// TODO: Only show this option for local files, resolve correct host path if connection is WSL
|
||||||
{
|
{
|
||||||
label: openNativeLabel,
|
label: openNativeLabel,
|
||||||
click: async () => {
|
click: async () => {
|
||||||
@ -483,14 +589,18 @@ function TableBody({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
menu.push({ type: "separator" });
|
menu.push(
|
||||||
menu.push({
|
{
|
||||||
label: "Delete File",
|
type: "separator",
|
||||||
click: async () => {
|
|
||||||
await FileService.DeleteFile(conn, finfo.path).catch((e) => console.log(e));
|
|
||||||
setRefreshVersion((current) => current + 1);
|
|
||||||
},
|
},
|
||||||
});
|
{
|
||||||
|
label: "Delete",
|
||||||
|
click: async () => {
|
||||||
|
await FileService.DeleteFile(conn, finfo.path).catch((e) => console.log(e));
|
||||||
|
setRefreshVersion((current) => current + 1);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
ContextMenuModel.showContextMenu(menu, e);
|
ContextMenuModel.showContextMenu(menu, e);
|
||||||
},
|
},
|
||||||
[setRefreshVersion, conn]
|
[setRefreshVersion, conn]
|
||||||
@ -560,12 +670,12 @@ function DirectoryPreview({ model }: DirectoryPreviewProps) {
|
|||||||
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 = useAtomValue(model.metaFilePath);
|
|
||||||
const showHiddenFiles = useAtomValue(model.showHiddenFiles);
|
const showHiddenFiles = useAtomValue(model.showHiddenFiles);
|
||||||
const [selectedPath, setSelectedPath] = useState("");
|
const [selectedPath, setSelectedPath] = useState("");
|
||||||
const [refreshVersion, setRefreshVersion] = useAtom(model.refreshVersion);
|
const [refreshVersion, setRefreshVersion] = useAtom(model.refreshVersion);
|
||||||
const conn = useAtomValue(model.connection);
|
const conn = useAtomValue(model.connection);
|
||||||
const blockData = useAtomValue(model.blockAtom);
|
const blockData = useAtomValue(model.blockAtom);
|
||||||
|
const dirPath = useAtomValue(model.normFilePath);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
model.refreshCallback = () => {
|
model.refreshCallback = () => {
|
||||||
@ -578,13 +688,13 @@ function DirectoryPreview({ model }: DirectoryPreviewProps) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const getContent = async () => {
|
const getContent = async () => {
|
||||||
const file = await FileService.ReadFile(conn, fileName);
|
const file = await FileService.ReadFile(conn, dirPath);
|
||||||
const serializedContent = base64ToString(file?.data64);
|
const serializedContent = base64ToString(file?.data64);
|
||||||
const content: FileInfo[] = JSON.parse(serializedContent);
|
const content: FileInfo[] = JSON.parse(serializedContent);
|
||||||
setUnfilteredData(content);
|
setUnfilteredData(content);
|
||||||
};
|
};
|
||||||
getContent();
|
getContent();
|
||||||
}, [conn, fileName, refreshVersion]);
|
}, [conn, dirPath, refreshVersion]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const filtered = unfilteredData.filter((fileInfo) => {
|
const filtered = unfilteredData.filter((fileInfo) => {
|
||||||
@ -652,26 +762,138 @@ function DirectoryPreview({ model }: DirectoryPreviewProps) {
|
|||||||
}
|
}
|
||||||
}, [filteredData]);
|
}, [filteredData]);
|
||||||
|
|
||||||
|
const entryManagerPropsAtom = useState(
|
||||||
|
atom<EntryManagerOverlayProps>(null) as PrimitiveAtom<EntryManagerOverlayProps>
|
||||||
|
)[0];
|
||||||
|
const [entryManagerProps, setEntryManagerProps] = useAtom(entryManagerPropsAtom);
|
||||||
|
|
||||||
|
const { refs, floatingStyles, context } = useFloating({
|
||||||
|
open: !!entryManagerProps,
|
||||||
|
onOpenChange: () => setEntryManagerProps(undefined),
|
||||||
|
middleware: [offset(({ rects }) => -rects.reference.height / 2 - rects.floating.height / 2)],
|
||||||
|
});
|
||||||
|
|
||||||
|
const dismiss = useDismiss(context);
|
||||||
|
const { getReferenceProps, getFloatingProps } = useInteractions([dismiss]);
|
||||||
|
|
||||||
|
const newFile = useCallback(() => {
|
||||||
|
setEntryManagerProps({
|
||||||
|
entryManagerType: EntryManagerType.NewFile,
|
||||||
|
onSave: (newName: string) => {
|
||||||
|
console.log(`newFile: ${newName}`);
|
||||||
|
fireAndForget(async () => {
|
||||||
|
await FileService.TouchFile(globalStore.get(model.connection), `${dirPath}/${newName}`);
|
||||||
|
model.refreshCallback();
|
||||||
|
});
|
||||||
|
setEntryManagerProps(undefined);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [dirPath]);
|
||||||
|
const newDirectory = useCallback(() => {
|
||||||
|
setEntryManagerProps({
|
||||||
|
entryManagerType: EntryManagerType.NewDirectory,
|
||||||
|
onSave: (newName: string) => {
|
||||||
|
console.log(`newDirectory: ${newName}`);
|
||||||
|
fireAndForget(async () => {
|
||||||
|
await FileService.Mkdir(globalStore.get(model.connection), `${dirPath}/${newName}`);
|
||||||
|
model.refreshCallback();
|
||||||
|
});
|
||||||
|
setEntryManagerProps(undefined);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [dirPath]);
|
||||||
|
|
||||||
|
const handleFileContextMenu = useCallback(
|
||||||
|
(e: any) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
let openNativeLabel = "Open Directory in File Manager";
|
||||||
|
if (PLATFORM == "darwin") {
|
||||||
|
openNativeLabel = "Open Directory in Finder";
|
||||||
|
} else if (PLATFORM == "win32") {
|
||||||
|
openNativeLabel = "Open Directory in Explorer";
|
||||||
|
}
|
||||||
|
const menu: ContextMenuItem[] = [
|
||||||
|
{
|
||||||
|
label: "New File",
|
||||||
|
click: () => {
|
||||||
|
newFile();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "New Folder",
|
||||||
|
click: () => {
|
||||||
|
newDirectory();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "separator",
|
||||||
|
},
|
||||||
|
// TODO: Only show this option for local files, resolve correct host path if connection is WSL
|
||||||
|
{
|
||||||
|
label: openNativeLabel,
|
||||||
|
click: () => {
|
||||||
|
console.log(`opening ${dirPath}`);
|
||||||
|
getApi().openNativePath(dirPath);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
menu.push({
|
||||||
|
label: "Open Terminal in New Block",
|
||||||
|
click: async () => {
|
||||||
|
const termBlockDef: BlockDef = {
|
||||||
|
meta: {
|
||||||
|
controller: "shell",
|
||||||
|
view: "term",
|
||||||
|
"cmd:cwd": dirPath,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await createBlock(termBlockDef);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
ContextMenuModel.showContextMenu(menu, e);
|
||||||
|
},
|
||||||
|
[setRefreshVersion, conn, newFile, newDirectory, dirPath]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Fragment>
|
||||||
className="dir-table-container"
|
<div
|
||||||
onChangeCapture={(e) => {
|
ref={refs.setReference}
|
||||||
const event = e as React.ChangeEvent<HTMLInputElement>;
|
className="dir-table-container"
|
||||||
setSearchText(event.target.value.toLowerCase());
|
onChangeCapture={(e) => {
|
||||||
}}
|
const event = e as React.ChangeEvent<HTMLInputElement>;
|
||||||
// onFocusCapture={() => document.getSelection().collapseToEnd()}
|
if (!entryManagerProps) {
|
||||||
>
|
setSearchText(event.target.value.toLowerCase());
|
||||||
<DirectoryTable
|
}
|
||||||
model={model}
|
}}
|
||||||
data={filteredData}
|
{...getReferenceProps()}
|
||||||
search={searchText}
|
onContextMenu={(e) => handleFileContextMenu(e)}
|
||||||
focusIndex={focusIndex}
|
>
|
||||||
setFocusIndex={setFocusIndex}
|
<DirectoryTable
|
||||||
setSearch={setSearchText}
|
model={model}
|
||||||
setSelectedPath={setSelectedPath}
|
data={filteredData}
|
||||||
setRefreshVersion={setRefreshVersion}
|
search={searchText}
|
||||||
/>
|
focusIndex={focusIndex}
|
||||||
</div>
|
setFocusIndex={setFocusIndex}
|
||||||
|
setSearch={setSearchText}
|
||||||
|
setSelectedPath={setSelectedPath}
|
||||||
|
setRefreshVersion={setRefreshVersion}
|
||||||
|
entryManagerOverlayPropsAtom={entryManagerPropsAtom}
|
||||||
|
newFile={newFile}
|
||||||
|
newDirectory={newDirectory}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{entryManagerProps && (
|
||||||
|
<EntryManagerOverlay
|
||||||
|
{...entryManagerProps}
|
||||||
|
forwardRef={refs.setFloating}
|
||||||
|
style={floatingStyles}
|
||||||
|
getReferenceProps={getFloatingProps}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -61,6 +61,33 @@ func (fs *FileService) StatFile(connection string, path string) (*wshrpc.FileInf
|
|||||||
return wshclient.RemoteFileInfoCommand(client, path, &wshrpc.RpcOpts{Route: connRoute})
|
return wshclient.RemoteFileInfoCommand(client, path, &wshrpc.RpcOpts{Route: connRoute})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (fs *FileService) Mkdir(connection string, path string) error {
|
||||||
|
if connection == "" {
|
||||||
|
connection = wshrpc.LocalConnName
|
||||||
|
}
|
||||||
|
connRoute := wshutil.MakeConnectionRouteId(connection)
|
||||||
|
client := wshserver.GetMainRpcClient()
|
||||||
|
return wshclient.RemoteMkdirCommand(client, path, &wshrpc.RpcOpts{Route: connRoute})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fs *FileService) TouchFile(connection string, path string) error {
|
||||||
|
if connection == "" {
|
||||||
|
connection = wshrpc.LocalConnName
|
||||||
|
}
|
||||||
|
connRoute := wshutil.MakeConnectionRouteId(connection)
|
||||||
|
client := wshserver.GetMainRpcClient()
|
||||||
|
return wshclient.RemoteFileTouchCommand(client, path, &wshrpc.RpcOpts{Route: connRoute})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fs *FileService) Rename(connection string, path string, newPath string) error {
|
||||||
|
if connection == "" {
|
||||||
|
connection = wshrpc.LocalConnName
|
||||||
|
}
|
||||||
|
connRoute := wshutil.MakeConnectionRouteId(connection)
|
||||||
|
client := wshserver.GetMainRpcClient()
|
||||||
|
return wshclient.RemoteFileRenameCommand(client, [2]string{path, newPath}, &wshrpc.RpcOpts{Route: connRoute})
|
||||||
|
}
|
||||||
|
|
||||||
func (fs *FileService) ReadFile_Meta() tsgenmeta.MethodMeta {
|
func (fs *FileService) ReadFile_Meta() tsgenmeta.MethodMeta {
|
||||||
return tsgenmeta.MethodMeta{
|
return tsgenmeta.MethodMeta{
|
||||||
Desc: "read file",
|
Desc: "read file",
|
||||||
|
@ -259,6 +259,24 @@ func RemoteFileJoinCommand(w *wshutil.WshRpc, data []string, opts *wshrpc.RpcOpt
|
|||||||
return resp, err
|
return resp, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// command "remotefilerename", wshserver.RemoteFileRenameCommand
|
||||||
|
func RemoteFileRenameCommand(w *wshutil.WshRpc, data [2]string, opts *wshrpc.RpcOpts) error {
|
||||||
|
_, err := sendRpcRequestCallHelper[any](w, "remotefilerename", data, opts)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// command "remotefiletouch", wshserver.RemoteFileTouchCommand
|
||||||
|
func RemoteFileTouchCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error {
|
||||||
|
_, err := sendRpcRequestCallHelper[any](w, "remotefiletouch", data, opts)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// command "remotemkdir", wshserver.RemoteMkdirCommand
|
||||||
|
func RemoteMkdirCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error {
|
||||||
|
_, err := sendRpcRequestCallHelper[any](w, "remotemkdir", data, opts)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// command "remotestreamcpudata", wshserver.RemoteStreamCpuDataCommand
|
// command "remotestreamcpudata", wshserver.RemoteStreamCpuDataCommand
|
||||||
func RemoteStreamCpuDataCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) chan wshrpc.RespOrErrorUnion[wshrpc.TimeSeriesData] {
|
func RemoteStreamCpuDataCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) chan wshrpc.RespOrErrorUnion[wshrpc.TimeSeriesData] {
|
||||||
return sendRpcRequestResponseStreamHelper[wshrpc.TimeSeriesData](w, "remotestreamcpudata", nil, opts)
|
return sendRpcRequestResponseStreamHelper[wshrpc.TimeSeriesData](w, "remotestreamcpudata", nil, opts)
|
||||||
|
@ -307,6 +307,49 @@ func (impl *ServerImpl) RemoteFileInfoCommand(ctx context.Context, path string)
|
|||||||
return impl.fileInfoInternal(path, true)
|
return impl.fileInfoInternal(path, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (impl *ServerImpl) RemoteFileTouchCommand(ctx context.Context, path string) error {
|
||||||
|
cleanedPath := filepath.Clean(wavebase.ExpandHomeDirSafe(path))
|
||||||
|
if _, err := os.Stat(cleanedPath); err == nil {
|
||||||
|
return fmt.Errorf("file %q already exists", path)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(cleanedPath), 0755); err != nil {
|
||||||
|
return fmt.Errorf("cannot create directory %q: %w", filepath.Dir(cleanedPath), err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(cleanedPath, []byte{}, 0644); err != nil {
|
||||||
|
return fmt.Errorf("cannot create file %q: %w", cleanedPath, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impl *ServerImpl) RemoteFileRenameCommand(ctx context.Context, pathTuple [2]string) error {
|
||||||
|
path := pathTuple[0]
|
||||||
|
newPath := pathTuple[1]
|
||||||
|
cleanedPath := filepath.Clean(wavebase.ExpandHomeDirSafe(path))
|
||||||
|
cleanedNewPath := filepath.Clean(wavebase.ExpandHomeDirSafe(newPath))
|
||||||
|
if _, err := os.Stat(cleanedNewPath); err == nil {
|
||||||
|
return fmt.Errorf("destination file path %q already exists", path)
|
||||||
|
}
|
||||||
|
if err := os.Rename(cleanedPath, cleanedNewPath); err != nil {
|
||||||
|
return fmt.Errorf("cannot rename file %q to %q: %w", cleanedPath, cleanedNewPath, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (impl *ServerImpl) RemoteMkdirCommand(ctx context.Context, path string) error {
|
||||||
|
cleanedPath := filepath.Clean(wavebase.ExpandHomeDirSafe(path))
|
||||||
|
if stat, err := os.Stat(cleanedPath); err == nil {
|
||||||
|
if stat.IsDir() {
|
||||||
|
return fmt.Errorf("directory %q already exists", path)
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("cannot create directory %q, file exists at path", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(cleanedPath, 0755); err != nil {
|
||||||
|
return fmt.Errorf("cannot create directory %q: %w", cleanedPath, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (*ServerImpl) RemoteWriteFileCommand(ctx context.Context, data wshrpc.CommandRemoteWriteFileData) error {
|
func (*ServerImpl) RemoteWriteFileCommand(ctx context.Context, data wshrpc.CommandRemoteWriteFileData) error {
|
||||||
path, err := wavebase.ExpandHomeDir(data.Path)
|
path, err := wavebase.ExpandHomeDir(data.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -61,6 +61,7 @@ const (
|
|||||||
Command_Test = "test"
|
Command_Test = "test"
|
||||||
Command_RemoteStreamFile = "remotestreamfile"
|
Command_RemoteStreamFile = "remotestreamfile"
|
||||||
Command_RemoteFileInfo = "remotefileinfo"
|
Command_RemoteFileInfo = "remotefileinfo"
|
||||||
|
Command_RemoteFileTouch = "remotefiletouch"
|
||||||
Command_RemoteWriteFile = "remotewritefile"
|
Command_RemoteWriteFile = "remotewritefile"
|
||||||
Command_RemoteFileDelete = "remotefiledelete"
|
Command_RemoteFileDelete = "remotefiledelete"
|
||||||
Command_RemoteFileJoin = "remotefilejoin"
|
Command_RemoteFileJoin = "remotefilejoin"
|
||||||
@ -69,6 +70,7 @@ const (
|
|||||||
Command_Activity = "activity"
|
Command_Activity = "activity"
|
||||||
Command_GetVar = "getvar"
|
Command_GetVar = "getvar"
|
||||||
Command_SetVar = "setvar"
|
Command_SetVar = "setvar"
|
||||||
|
Command_RemoteMkdir = "remotemkdir"
|
||||||
|
|
||||||
Command_ConnStatus = "connstatus"
|
Command_ConnStatus = "connstatus"
|
||||||
Command_WslStatus = "wslstatus"
|
Command_WslStatus = "wslstatus"
|
||||||
@ -161,9 +163,12 @@ type WshRpcInterface interface {
|
|||||||
// remotes
|
// remotes
|
||||||
RemoteStreamFileCommand(ctx context.Context, data CommandRemoteStreamFileData) chan RespOrErrorUnion[CommandRemoteStreamFileRtnData]
|
RemoteStreamFileCommand(ctx context.Context, data CommandRemoteStreamFileData) chan RespOrErrorUnion[CommandRemoteStreamFileRtnData]
|
||||||
RemoteFileInfoCommand(ctx context.Context, path string) (*FileInfo, error)
|
RemoteFileInfoCommand(ctx context.Context, path string) (*FileInfo, error)
|
||||||
|
RemoteFileTouchCommand(ctx context.Context, path string) error
|
||||||
|
RemoteFileRenameCommand(ctx context.Context, pathTuple [2]string) error
|
||||||
RemoteFileDeleteCommand(ctx context.Context, path string) error
|
RemoteFileDeleteCommand(ctx context.Context, path string) error
|
||||||
RemoteWriteFileCommand(ctx context.Context, data CommandRemoteWriteFileData) error
|
RemoteWriteFileCommand(ctx context.Context, data CommandRemoteWriteFileData) error
|
||||||
RemoteFileJoinCommand(ctx context.Context, paths []string) (*FileInfo, error)
|
RemoteFileJoinCommand(ctx context.Context, paths []string) (*FileInfo, error)
|
||||||
|
RemoteMkdirCommand(ctx context.Context, path string) error
|
||||||
RemoteStreamCpuDataCommand(ctx context.Context) chan RespOrErrorUnion[TimeSeriesData]
|
RemoteStreamCpuDataCommand(ctx context.Context) chan RespOrErrorUnion[TimeSeriesData]
|
||||||
|
|
||||||
// emain
|
// emain
|
||||||
|
Loading…
Reference in New Issue
Block a user