diff --git a/emain/emain.ts b/emain/emain.ts index 5535b8864..77cbc8f54 100644 --- a/emain/emain.ts +++ b/emain/emain.ts @@ -359,9 +359,11 @@ electron.ipcMain.on("quicklook", (event, filePath: string) => { electron.ipcMain.on("open-native-path", (event, filePath: string) => { console.log("open-native-path", filePath); - electron.shell.openPath(filePath).catch((err) => { - console.error(`Failed to open path ${filePath}:`, err); - }); + fireAndForget(async () => + electron.shell.openPath(filePath).then((excuse) => { + if (excuse) console.error(`Failed to open ${filePath} in native application: ${excuse}`); + }) + ); }); async function createNewWaveWindow(): Promise { diff --git a/frontend/app/element/popover.scss b/frontend/app/element/popover.scss index 32af28ef3..bd4217653 100644 --- a/frontend/app/element/popover.scss +++ b/frontend/app/element/popover.scss @@ -2,15 +2,15 @@ // SPDX-License-Identifier: Apache-2.0 .popover-content { - min-width: 100px; - min-height: 150px; - position: absolute; - z-index: 1000; // TODO: put this in theme.scss - display: flex; - padding: 2px; - gap: 1px; - 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); + min-width: 100px; + min-height: 150px; + position: absolute; + z-index: 1000; // TODO: put this in theme.scss + display: flex; + padding: 2px; + gap: 1px; + 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); } diff --git a/frontend/app/element/popover.tsx b/frontend/app/element/popover.tsx index cac445981..587ade0aa 100644 --- a/frontend/app/element/popover.tsx +++ b/frontend/app/element/popover.tsx @@ -5,6 +5,7 @@ import { Button } from "@/element/button"; import { autoUpdate, FloatingPortal, + Middleware, offset as offsetMiddleware, useClick, useDismiss, @@ -34,6 +35,7 @@ interface PopoverProps { placement?: Placement; offset?: OffsetOptions; onDismiss?: () => void; + middleware?: Middleware[]; } const isPopoverButton = ( @@ -48,54 +50,63 @@ const isPopoverContent = ( return element.type === PopoverContent; }; -const Popover = memo(({ children, className, placement = "bottom-start", offset = 3, onDismiss }: PopoverProps) => { - const [isOpen, setIsOpen] = useState(false); +const Popover = memo( + ({ children, className, placement = "bottom-start", offset = 3, onDismiss, middleware }: PopoverProps) => { + const [isOpen, setIsOpen] = useState(false); - const handleOpenChange = (open: boolean) => { - setIsOpen(open); - if (!open && 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 - }); + const handleOpenChange = (open: boolean) => { + setIsOpen(open); + if (!open && onDismiss) { + onDismiss(); } + }; - if (isPopoverContent(child)) { - return isOpen - ? cloneElement(child as any, { - ref: refs.setFloating, - style: floatingStyles, - getFloatingProps, - }) - : null; - } + if (offset === undefined) { + offset = 3; } - return child; - }); - return
{renderChildren}
; -}); + 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
{renderChildren}
; + } +); interface PopoverButtonProps extends React.ButtonHTMLAttributes { isActive?: boolean; diff --git a/frontend/app/store/services.ts b/frontend/app/store/services.ts index 4b7714701..5abbaef9d 100644 --- a/frontend/app/store/services.ts +++ b/frontend/app/store/services.ts @@ -59,11 +59,17 @@ class FileServiceType { GetWaveFile(arg1: string, arg2: string): Promise { return WOS.callBackendService("file", "GetWaveFile", Array.from(arguments)) } + Mkdir(arg1: string, arg2: string): Promise { + return WOS.callBackendService("file", "Mkdir", Array.from(arguments)) + } // read file ReadFile(connection: string, path: string): Promise { return WOS.callBackendService("file", "ReadFile", Array.from(arguments)) } + Rename(arg1: string, arg2: string, arg3: string): Promise { + return WOS.callBackendService("file", "Rename", Array.from(arguments)) + } // save file SaveFile(connection: string, path: string, data64: string): Promise { @@ -74,6 +80,9 @@ class FileServiceType { StatFile(connection: string, path: string): Promise { return WOS.callBackendService("file", "StatFile", Array.from(arguments)) } + TouchFile(arg1: string, arg2: string): Promise { + return WOS.callBackendService("file", "TouchFile", Array.from(arguments)) + } } export const FileService = new FileServiceType(); diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index 244349289..e6142aeee 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -212,6 +212,21 @@ class RpcApiType { return client.wshRpcCall("remotefilejoin", data, opts); } + // command "remotefilerename" [call] + RemoteFileRenameCommand(client: WshClient, data: string[], opts?: RpcOpts): Promise { + return client.wshRpcCall("remotefilerename", data, opts); + } + + // command "remotefiletouch" [call] + RemoteFileTouchCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + return client.wshRpcCall("remotefiletouch", data, opts); + } + + // command "remotemkdir" [call] + RemoteMkdirCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + return client.wshRpcCall("remotemkdir", data, opts); + } + // command "remotestreamcpudata" [responsestream] RemoteStreamCpuDataCommand(client: WshClient, opts?: RpcOpts): AsyncGenerator { return client.wshRpcStream("remotestreamcpudata", null, opts); diff --git a/frontend/app/tab/workspaceswitcher.tsx b/frontend/app/tab/workspaceswitcher.tsx index 606436dbe..fc513f91c 100644 --- a/frontend/app/tab/workspaceswitcher.tsx +++ b/frontend/app/tab/workspaceswitcher.tsx @@ -207,7 +207,11 @@ const WorkspaceSwitcher = () => { }; return ( - setEditingWorkspace(null)}> + setEditingWorkspace(null)} + > { + updateName: (path: string) => void; + newFile: () => void; + newDirectory: () => void; + } +} + interface DirectoryTableProps { model: PreviewModel; data: FileInfo[]; @@ -35,6 +46,9 @@ interface DirectoryTableProps { setSearch: (_: string) => void; setSelectedPath: (_: string) => void; setRefreshVersion: React.Dispatch>; + entryManagerOverlayPropsAtom: PrimitiveAtom; + newFile: () => void; + newDirectory: () => void; } const columnHelper = createColumnHelper(); @@ -121,6 +135,46 @@ function cleanMimetype(input: string): string { return truncated.trim(); } +enum EntryManagerType { + NewFile = "New File", + NewDirectory = "New Folder", + EditName = "Rename", +} + +type EntryManagerOverlayProps = { + forwardRef?: React.Ref; + 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 ( +
+
{entryManagerType}
+
+ { + if (e.key === "Enter") { + e.preventDefault(); + e.stopPropagation(); + onSave(value); + } + }} + > +
+
+ ); + } +); + function DirectoryTable({ model, data, @@ -130,6 +184,9 @@ function DirectoryTable({ setSearch, setSelectedPath, setRefreshVersion, + entryManagerOverlayPropsAtom, + newFile, + newDirectory, }: DirectoryTableProps) { const fullConfig = useAtomValue(atoms.fullConfigAtom); const getIconFromMimeType = useCallback( @@ -205,6 +262,28 @@ function DirectoryTable({ [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({ data, columns, @@ -229,6 +308,11 @@ function DirectoryTable({ }, enableMultiSort: false, enableSortingRemoval: false, + meta: { + updateName, + newFile, + newDirectory, + }, }); useEffect(() => { @@ -418,6 +502,27 @@ function TableBody({ openNativeLabel = "Open File in Default Application"; } 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", click: () => navigator.clipboard.writeText(fileName), @@ -446,6 +551,7 @@ function TableBody({ { type: "separator", }, + // TODO: Only show this option for local files, resolve correct host path if connection is WSL { label: openNativeLabel, click: async () => { @@ -483,14 +589,18 @@ function TableBody({ }, }); } - menu.push({ type: "separator" }); - menu.push({ - label: "Delete File", - click: async () => { - await FileService.DeleteFile(conn, finfo.path).catch((e) => console.log(e)); - setRefreshVersion((current) => current + 1); + menu.push( + { + type: "separator", }, - }); + { + label: "Delete", + click: async () => { + await FileService.DeleteFile(conn, finfo.path).catch((e) => console.log(e)); + setRefreshVersion((current) => current + 1); + }, + } + ); ContextMenuModel.showContextMenu(menu, e); }, [setRefreshVersion, conn] @@ -560,12 +670,12 @@ function DirectoryPreview({ model }: DirectoryPreviewProps) { const [focusIndex, setFocusIndex] = useState(0); const [unfilteredData, setUnfilteredData] = useState([]); const [filteredData, setFilteredData] = useState([]); - const fileName = useAtomValue(model.metaFilePath); const showHiddenFiles = useAtomValue(model.showHiddenFiles); const [selectedPath, setSelectedPath] = useState(""); const [refreshVersion, setRefreshVersion] = useAtom(model.refreshVersion); const conn = useAtomValue(model.connection); const blockData = useAtomValue(model.blockAtom); + const dirPath = useAtomValue(model.normFilePath); useEffect(() => { model.refreshCallback = () => { @@ -578,13 +688,13 @@ function DirectoryPreview({ model }: DirectoryPreviewProps) { useEffect(() => { const getContent = async () => { - const file = await FileService.ReadFile(conn, fileName); + const file = await FileService.ReadFile(conn, dirPath); const serializedContent = base64ToString(file?.data64); const content: FileInfo[] = JSON.parse(serializedContent); setUnfilteredData(content); }; getContent(); - }, [conn, fileName, refreshVersion]); + }, [conn, dirPath, refreshVersion]); useEffect(() => { const filtered = unfilteredData.filter((fileInfo) => { @@ -652,26 +762,138 @@ function DirectoryPreview({ model }: DirectoryPreviewProps) { } }, [filteredData]); + const entryManagerPropsAtom = useState( + atom(null) as PrimitiveAtom + )[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 ( -
{ - const event = e as React.ChangeEvent; - setSearchText(event.target.value.toLowerCase()); - }} - // onFocusCapture={() => document.getSelection().collapseToEnd()} - > - -
+ +
{ + const event = e as React.ChangeEvent; + if (!entryManagerProps) { + setSearchText(event.target.value.toLowerCase()); + } + }} + {...getReferenceProps()} + onContextMenu={(e) => handleFileContextMenu(e)} + > + +
+ {entryManagerProps && ( + + )} +
); } diff --git a/pkg/service/fileservice/fileservice.go b/pkg/service/fileservice/fileservice.go index b9c1cf662..68490a2f4 100644 --- a/pkg/service/fileservice/fileservice.go +++ b/pkg/service/fileservice/fileservice.go @@ -61,6 +61,33 @@ func (fs *FileService) StatFile(connection string, path string) (*wshrpc.FileInf 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 { return tsgenmeta.MethodMeta{ Desc: "read file", diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go index 88635130f..30f81fe8c 100644 --- a/pkg/wshrpc/wshclient/wshclient.go +++ b/pkg/wshrpc/wshclient/wshclient.go @@ -259,6 +259,24 @@ func RemoteFileJoinCommand(w *wshutil.WshRpc, data []string, opts *wshrpc.RpcOpt 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 func RemoteStreamCpuDataCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) chan wshrpc.RespOrErrorUnion[wshrpc.TimeSeriesData] { return sendRpcRequestResponseStreamHelper[wshrpc.TimeSeriesData](w, "remotestreamcpudata", nil, opts) diff --git a/pkg/wshrpc/wshremote/wshremote.go b/pkg/wshrpc/wshremote/wshremote.go index 0cd6b637c..72df33852 100644 --- a/pkg/wshrpc/wshremote/wshremote.go +++ b/pkg/wshrpc/wshremote/wshremote.go @@ -307,6 +307,49 @@ func (impl *ServerImpl) RemoteFileInfoCommand(ctx context.Context, path string) 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 { path, err := wavebase.ExpandHomeDir(data.Path) if err != nil { diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index 4f0992f11..517ef33e0 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -61,6 +61,7 @@ const ( Command_Test = "test" Command_RemoteStreamFile = "remotestreamfile" Command_RemoteFileInfo = "remotefileinfo" + Command_RemoteFileTouch = "remotefiletouch" Command_RemoteWriteFile = "remotewritefile" Command_RemoteFileDelete = "remotefiledelete" Command_RemoteFileJoin = "remotefilejoin" @@ -69,6 +70,7 @@ const ( Command_Activity = "activity" Command_GetVar = "getvar" Command_SetVar = "setvar" + Command_RemoteMkdir = "remotemkdir" Command_ConnStatus = "connstatus" Command_WslStatus = "wslstatus" @@ -161,9 +163,12 @@ type WshRpcInterface interface { // remotes RemoteStreamFileCommand(ctx context.Context, data CommandRemoteStreamFileData) chan RespOrErrorUnion[CommandRemoteStreamFileRtnData] 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 RemoteWriteFileCommand(ctx context.Context, data CommandRemoteWriteFileData) error RemoteFileJoinCommand(ctx context.Context, paths []string) (*FileInfo, error) + RemoteMkdirCommand(ctx context.Context, path string) error RemoteStreamCpuDataCommand(ctx context.Context) chan RespOrErrorUnion[TimeSeriesData] // emain