Merge branch 'main' into red/tabs-new-design-2

This commit is contained in:
Red J Adaya 2024-12-04 22:01:59 +08:00 committed by GitHub
commit 1b8ac7f8bf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 612 additions and 172 deletions

View File

@ -274,6 +274,7 @@ export class WaveBrowserWindow extends BaseWindow {
console.log("switchWorkspace newWs", newWs);
if (this.allTabViews.size) {
for (const tab of this.allTabViews.values()) {
this.contentView.removeChildView(tab);
tab?.destroy();
}
}

View File

@ -39,6 +39,7 @@ import {
getAllWaveWindows,
getWaveWindowById,
getWaveWindowByWebContentsId,
getWaveWindowByWorkspaceId,
WaveBrowserWindow,
} from "./emain-window";
import { ElectronWshClient, initElectronWshClient } from "./emain-wsh";
@ -125,6 +126,14 @@ function handleWSEvent(evtMsg: WSEventType) {
if (ww != null) {
ww.destroy(); // bypass the "are you sure?" dialog
}
} else if (evtMsg.eventtype == "electron:updateactivetab") {
const activeTabUpdate: { workspaceid: string; newactivetabid: string } = evtMsg.data;
console.log("electron:updateactivetab", activeTabUpdate);
const ww = getWaveWindowByWorkspaceId(activeTabUpdate.workspaceid);
if (ww == null) {
return;
}
await ww.setActiveTab(activeTabUpdate.newactivetabid, false);
} else {
console.log("unhandled electron ws eventtype", evtMsg.eventtype);
}
@ -359,9 +368,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<void> {

View File

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

View File

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

View File

@ -557,6 +557,7 @@ function getConnStatusAtom(conn: string): PrimitiveAtom<ConnStatus> {
status: "connected",
hasconnected: true,
activeconnnum: 0,
wshenabled: false,
};
rtn = atom(connStatus);
} else {
@ -567,6 +568,7 @@ function getConnStatusAtom(conn: string): PrimitiveAtom<ConnStatus> {
status: "disconnected",
hasconnected: false,
activeconnnum: 0,
wshenabled: false,
};
rtn = atom(connStatus);
}
@ -620,8 +622,13 @@ function removeNotification(id: string) {
});
}
async function createTab(): Promise<void> {
await getApi().createTab();
function createTab() {
getApi().createTab();
}
function setActiveTab(tabId: string) {
document.body.classList.add("nohover");
getApi().setActiveTab(tabId);
}
export {
@ -655,6 +662,7 @@ export {
removeFlashError,
removeNotification,
removeNotificationById,
setActiveTab,
setNodeFocus,
setPlatform,
subscribeToConnEvents,

View File

@ -59,11 +59,17 @@ class FileServiceType {
GetWaveFile(arg1: string, arg2: string): Promise<any> {
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
ReadFile(connection: string, path: string): Promise<FullFile> {
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
SaveFile(connection: string, path: string, data64: string): Promise<void> {
@ -74,6 +80,9 @@ class FileServiceType {
StatFile(connection: string, path: string): Promise<FileInfo> {
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();

View File

@ -212,6 +212,21 @@ class RpcApiType {
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]
RemoteStreamCpuDataCommand(client: WshClient, opts?: RpcOpts): AsyncGenerator<TimeSeriesData, void, boolean> {
return client.wshRpcStream("remotestreamcpudata", null, opts);

View File

@ -33,13 +33,6 @@
border-radius: 6px;
}
&:hover {
.tab-inner {
border-color: transparent;
background: rgb(from var(--main-text-color) r g b / 0.07);
}
}
&.animate {
transition:
transform 0.3s ease,
@ -87,19 +80,20 @@
}
.close {
visibility: hidden;
position: absolute;
top: 50%;
right: 4px;
transform: translate3d(0, -50%, 0);
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: var(--zindex-tab-name);
padding: 1px 2px;
visibility: hidden;
position: absolute;
top: 50%;
right: 4px;
transform: translate3d(0, -50%, 0);
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: var(--zindex-tab-name);
padding: 1px 2px;
transition: none !important;
}
&:hover .close {
@ -112,6 +106,20 @@
}
}
body:not(.nohover) .tab:hover {
.tab-inner {
border-color: transparent;
background: rgb(from var(--main-text-color) r g b / 0.07);
}
.close {
visibility: visible;
&:hover {
color: var(--main-text-color);
}
}
}
@keyframes expandWidthAndFadeIn {
from {
width: var(--initial-tab-width);

View File

@ -5,7 +5,7 @@ import { Button } from "@/app/element/button";
import { modalsModel } from "@/app/store/modalmodel";
import { WindowDrag } from "@/element/windowdrag";
import { deleteLayoutModelForTab } from "@/layout/index";
import { atoms, createTab, getApi, isDev, PLATFORM } from "@/store/global";
import { atoms, createTab, getApi, isDev, PLATFORM, setActiveTab } from "@/store/global";
import { fireAndForget } from "@/util/util";
import { useAtomValue, useSetAtom } from "jotai";
import { OverlayScrollbars } from "overlayscrollbars";
@ -500,7 +500,7 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
const handleSelectTab = (tabId: string) => {
if (!draggingTabDataRef.current.dragged) {
getApi().setActiveTab(tabId);
setActiveTab(tabId);
}
};

View File

@ -207,7 +207,7 @@ const WorkspaceSwitcher = forwardRef<HTMLDivElement, {}>(({}, ref) => {
};
return (
<Popover ref={ref} className="workspace-switcher-popover" onDismiss={() => setEditingWorkspace(null)}>
<Popover ref={ref} className="workspace-switcher-popover" placement="bottom-start" onDismiss={() => setEditingWorkspace(null)}>
<PopoverButton
className="workspace-switcher-button grey"
as="div"

View File

@ -230,3 +230,17 @@
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);
}

View File

@ -1,15 +1,18 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { Input } from "@/app/element/input";
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 type { PreviewModel } from "@/app/view/preview/preview";
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 {
Column,
Row,
RowData,
Table,
createColumnHelper,
flexRender,
@ -19,13 +22,21 @@ import {
} from "@tanstack/react-table";
import clsx from "clsx";
import dayjs from "dayjs";
import { useAtom, useAtomValue } from "jotai";
import { PrimitiveAtom, atom, useAtom, useAtomValue, useSetAtom } from "jotai";
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 { debounce } from "throttle-debounce";
import "./directorypreview.scss";
declare module "@tanstack/react-table" {
interface TableMeta<TData extends RowData> {
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<React.SetStateAction<number>>;
entryManagerOverlayPropsAtom: PrimitiveAtom<EntryManagerOverlayProps>;
newFile: () => void;
newDirectory: () => void;
}
const columnHelper = createColumnHelper<FileInfo>();
@ -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<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({
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<FileInfo[]>([]);
const [filteredData, setFilteredData] = useState<FileInfo[]>([]);
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<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 (
<div
className="dir-table-container"
onChangeCapture={(e) => {
const event = e as React.ChangeEvent<HTMLInputElement>;
setSearchText(event.target.value.toLowerCase());
}}
// onFocusCapture={() => document.getSelection().collapseToEnd()}
>
<DirectoryTable
model={model}
data={filteredData}
search={searchText}
focusIndex={focusIndex}
setFocusIndex={setFocusIndex}
setSearch={setSearchText}
setSelectedPath={setSelectedPath}
setRefreshVersion={setRefreshVersion}
/>
</div>
<Fragment>
<div
ref={refs.setReference}
className="dir-table-container"
onChangeCapture={(e) => {
const event = e as React.ChangeEvent<HTMLInputElement>;
if (!entryManagerProps) {
setSearchText(event.target.value.toLowerCase());
}
}}
{...getReferenceProps()}
onContextMenu={(e) => handleFileContextMenu(e)}
>
<DirectoryTable
model={model}
data={filteredData}
search={searchText}
focusIndex={focusIndex}
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>
);
}

View File

@ -87,6 +87,11 @@ async function initWaveWrap(initOpts: WaveInitOpts) {
async function reinitWave() {
console.log("Reinit Wave");
getApi().sendLog("Reinit Wave");
requestAnimationFrame(() => {
setTimeout(() => {
document.body.classList.remove("nohover");
}, 50);
});
const client = await WOS.reloadWaveObject<Client>(WOS.makeORef("client", savedInitOpts.clientId));
const waveWindow = await WOS.reloadWaveObject<WaveWindow>(WOS.makeORef("window", savedInitOpts.windowId));
const ws = await WOS.reloadWaveObject<Workspace>(WOS.makeORef("workspace", waveWindow.workspaceid));

View File

@ -13,9 +13,10 @@ import (
)
const (
WSEvent_ElectronNewWindow = "electron:newwindow"
WSEvent_ElectronCloseWindow = "electron:closewindow"
WSEvent_Rpc = "rpc"
WSEvent_ElectronNewWindow = "electron:newwindow"
WSEvent_ElectronCloseWindow = "electron:closewindow"
WSEvent_ElectronUpdateActiveTab = "electron:updateactivetab"
WSEvent_Rpc = "rpc"
)
type WSEventType struct {

View File

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

View File

@ -121,7 +121,7 @@ func (svc *ObjectService) DeleteBlock(uiContext waveobj.UIContext, blockId strin
ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout)
defer cancelFn()
ctx = waveobj.ContextWithUpdates(ctx)
err := wcore.DeleteBlock(ctx, blockId)
err := wcore.DeleteBlock(ctx, blockId, true)
if err != nil {
return nil, fmt.Errorf("error deleting block: %w", err)
}

View File

@ -169,21 +169,13 @@ func (svc *WorkspaceService) CloseTab(ctx context.Context, workspaceId string, t
blockcontroller.StopBlockController(blockId)
}
}()
newActiveTabId, err := wcore.DeleteTab(ctx, workspaceId, tabId)
newActiveTabId, err := wcore.DeleteTab(ctx, workspaceId, tabId, true)
if err != nil {
return nil, nil, fmt.Errorf("error closing tab: %w", err)
}
rtn := &CloseTabRtnType{}
if newActiveTabId == "" {
windowId, err := wstore.DBFindWindowForWorkspaceId(ctx, workspaceId)
if err != nil {
return rtn, nil, fmt.Errorf("unable to find window for workspace id %v: %w", workspaceId, err)
}
rtn.CloseWindow = true
err = wcore.CloseWindow(ctx, windowId, fromElectron)
if err != nil {
return rtn, nil, err
}
} else {
rtn.NewActiveTabId = newActiveTabId
}

View File

@ -159,6 +159,11 @@ type WorkspaceListEntry struct {
type WorkspaceList []*WorkspaceListEntry
type ActiveTabUpdate struct {
WorkspaceId string `json:"workspaceid"`
NewActiveTabId string `json:"newactivetabid"`
}
type Workspace struct {
OID string `json:"oid"`
Version int `json:"version"`

View File

@ -3,11 +3,14 @@ package wcore
import (
"context"
"fmt"
"log"
"time"
"github.com/google/uuid"
"github.com/wavetermdev/waveterm/pkg/blockcontroller"
"github.com/wavetermdev/waveterm/pkg/panichandler"
"github.com/wavetermdev/waveterm/pkg/telemetry"
"github.com/wavetermdev/waveterm/pkg/util/utilfn"
"github.com/wavetermdev/waveterm/pkg/waveobj"
"github.com/wavetermdev/waveterm/pkg/wps"
"github.com/wavetermdev/waveterm/pkg/wshrpc"
@ -21,13 +24,34 @@ func CreateSubBlock(ctx context.Context, blockId string, blockDef *waveobj.Block
if blockDef.Meta == nil || blockDef.Meta.GetString(waveobj.MetaKey_View, "") == "" {
return nil, fmt.Errorf("no view provided for new block")
}
blockData, err := wstore.CreateSubBlock(ctx, blockId, blockDef)
blockData, err := createSubBlockObj(ctx, blockId, blockDef)
if err != nil {
return nil, fmt.Errorf("error creating sub block: %w", err)
}
return blockData, nil
}
func createSubBlockObj(ctx context.Context, parentBlockId string, blockDef *waveobj.BlockDef) (*waveobj.Block, error) {
return wstore.WithTxRtn(ctx, func(tx *wstore.TxWrap) (*waveobj.Block, error) {
parentBlock, _ := wstore.DBGet[*waveobj.Block](tx.Context(), parentBlockId)
if parentBlock == nil {
return nil, fmt.Errorf("parent block not found: %q", parentBlockId)
}
blockId := uuid.NewString()
blockData := &waveobj.Block{
OID: blockId,
ParentORef: waveobj.MakeORef(waveobj.OType_Block, parentBlockId).String(),
BlockDef: blockDef,
RuntimeOpts: nil,
Meta: blockDef.Meta,
}
wstore.DBInsert(tx.Context(), blockData)
parentBlock.SubBlockIds = append(parentBlock.SubBlockIds, blockId)
wstore.DBUpdate(tx.Context(), parentBlock)
return blockData, nil
})
}
func CreateBlock(ctx context.Context, tabId string, blockDef *waveobj.BlockDef, rtOpts *waveobj.RuntimeOpts) (*waveobj.Block, error) {
if blockDef == nil {
return nil, fmt.Errorf("blockDef is nil")
@ -35,7 +59,7 @@ func CreateBlock(ctx context.Context, tabId string, blockDef *waveobj.BlockDef,
if blockDef.Meta == nil || blockDef.Meta.GetString(waveobj.MetaKey_View, "") == "" {
return nil, fmt.Errorf("no view provided for new block")
}
blockData, err := wstore.CreateBlock(ctx, tabId, blockDef, rtOpts)
blockData, err := createBlockObj(ctx, tabId, blockDef, rtOpts)
if err != nil {
return nil, fmt.Errorf("error creating block: %w", err)
}
@ -54,7 +78,32 @@ func CreateBlock(ctx context.Context, tabId string, blockDef *waveobj.BlockDef,
return blockData, nil
}
func DeleteBlock(ctx context.Context, blockId string) error {
func createBlockObj(ctx context.Context, tabId string, blockDef *waveobj.BlockDef, rtOpts *waveobj.RuntimeOpts) (*waveobj.Block, error) {
return wstore.WithTxRtn(ctx, func(tx *wstore.TxWrap) (*waveobj.Block, error) {
tab, _ := wstore.DBGet[*waveobj.Tab](tx.Context(), tabId)
if tab == nil {
return nil, fmt.Errorf("tab not found: %q", tabId)
}
blockId := uuid.NewString()
blockData := &waveobj.Block{
OID: blockId,
ParentORef: waveobj.MakeORef(waveobj.OType_Tab, tabId).String(),
BlockDef: blockDef,
RuntimeOpts: rtOpts,
Meta: blockDef.Meta,
}
wstore.DBInsert(tx.Context(), blockData)
tab.BlockIds = append(tab.BlockIds, blockId)
wstore.DBUpdate(tx.Context(), tab)
return blockData, nil
})
}
// Must delete all blocks individually first.
// Also deletes LayoutState.
// recursive: if true, will recursively close parent tab, window, workspace, if they are empty.
// Returns new active tab id, error.
func DeleteBlock(ctx context.Context, blockId string, recursive bool) error {
block, err := wstore.DBMustGet[*waveobj.Block](ctx, blockId)
if err != nil {
return fmt.Errorf("error getting block: %w", err)
@ -64,21 +113,77 @@ func DeleteBlock(ctx context.Context, blockId string) error {
}
if len(block.SubBlockIds) > 0 {
for _, subBlockId := range block.SubBlockIds {
err := DeleteBlock(ctx, subBlockId)
err := DeleteBlock(ctx, subBlockId, recursive)
if err != nil {
return fmt.Errorf("error deleting subblock %s: %w", subBlockId, err)
}
}
}
err = wstore.DeleteBlock(ctx, blockId)
parentBlockCount, err := deleteBlockObj(ctx, blockId)
if err != nil {
return fmt.Errorf("error deleting block: %w", err)
}
log.Printf("DeleteBlock: parentBlockCount: %d", parentBlockCount)
parentORef := waveobj.ParseORefNoErr(block.ParentORef)
if parentORef.OType == waveobj.OType_Tab {
if parentBlockCount == 0 && recursive {
// if parent tab has no blocks, delete the tab
log.Printf("DeleteBlock: parent tab has no blocks, deleting tab %s", parentORef.OID)
parentWorkspaceId, err := wstore.DBFindWorkspaceForTabId(ctx, parentORef.OID)
if err != nil {
return fmt.Errorf("error finding workspace for tab to delete %s: %w", parentORef.OID, err)
}
newActiveTabId, err := DeleteTab(ctx, parentWorkspaceId, parentORef.OID, true)
if err != nil {
return fmt.Errorf("error deleting tab %s: %w", parentORef.OID, err)
}
SendActiveTabUpdate(ctx, parentWorkspaceId, newActiveTabId)
}
}
go blockcontroller.StopBlockController(blockId)
sendBlockCloseEvent(blockId)
return nil
}
// returns the updated block count for the parent object
func deleteBlockObj(ctx context.Context, blockId string) (int, error) {
return wstore.WithTxRtn(ctx, func(tx *wstore.TxWrap) (int, error) {
block, err := wstore.DBGet[*waveobj.Block](tx.Context(), blockId)
if err != nil {
return -1, fmt.Errorf("error getting block: %w", err)
}
if block == nil {
return -1, fmt.Errorf("block not found: %q", blockId)
}
if len(block.SubBlockIds) > 0 {
return -1, fmt.Errorf("block has subblocks, must delete subblocks first")
}
parentORef := waveobj.ParseORefNoErr(block.ParentORef)
parentBlockCount := -1
if parentORef != nil {
if parentORef.OType == waveobj.OType_Tab {
tab, _ := wstore.DBGet[*waveobj.Tab](tx.Context(), parentORef.OID)
if tab != nil {
tab.BlockIds = utilfn.RemoveElemFromSlice(tab.BlockIds, blockId)
wstore.DBUpdate(tx.Context(), tab)
parentBlockCount = len(tab.BlockIds)
}
} else if parentORef.OType == waveobj.OType_Block {
parentBlock, _ := wstore.DBGet[*waveobj.Block](tx.Context(), parentORef.OID)
if parentBlock != nil {
parentBlock.SubBlockIds = utilfn.RemoveElemFromSlice(parentBlock.SubBlockIds, blockId)
wstore.DBUpdate(tx.Context(), parentBlock)
parentBlockCount = len(parentBlock.SubBlockIds)
}
}
}
wstore.DBDelete(tx.Context(), waveobj.OType_Block, blockId)
return parentBlockCount, nil
})
}
func sendBlockCloseEvent(blockId string) {
waveEvent := wps.WaveEvent{
Event: wps.Event_BlockClose,

View File

@ -120,6 +120,8 @@ func CreateWindow(ctx context.Context, winSize *waveobj.WinSize, workspaceId str
return GetWindow(ctx, windowId)
}
// CloseWindow closes a window and deletes its workspace if it is empty and not named.
// If fromElectron is true, it does not send an event to Electron.
func CloseWindow(ctx context.Context, windowId string, fromElectron bool) error {
log.Printf("CloseWindow %s\n", windowId)
window, err := GetWindow(ctx, windowId)

View File

@ -7,6 +7,7 @@ import (
"time"
"github.com/google/uuid"
"github.com/wavetermdev/waveterm/pkg/eventbus"
"github.com/wavetermdev/waveterm/pkg/telemetry"
"github.com/wavetermdev/waveterm/pkg/util/utilfn"
"github.com/wavetermdev/waveterm/pkg/waveobj"
@ -27,19 +28,22 @@ func CreateWorkspace(ctx context.Context, name string, icon string, color string
return ws, nil
}
// If force is true, it will delete even if workspace is named.
// If workspace is empty, it will be deleted, even if it is named.
// Returns true if workspace was deleted, false if it was not deleted.
func DeleteWorkspace(ctx context.Context, workspaceId string, force bool) (bool, error) {
log.Printf("DeleteWorkspace %s\n", workspaceId)
workspace, err := wstore.DBMustGet[*waveobj.Workspace](ctx, workspaceId)
if err != nil {
return false, fmt.Errorf("error getting workspace: %w", err)
}
if workspace.Name != "" && workspace.Icon != "" && !force {
if workspace.Name != "" && workspace.Icon != "" && !force && len(workspace.TabIds) > 0 {
log.Printf("Ignoring DeleteWorkspace for workspace %s as it is named\n", workspaceId)
return false, nil
}
for _, tabId := range workspace.TabIds {
log.Printf("deleting tab %s\n", tabId)
_, err := DeleteTab(ctx, workspaceId, tabId)
_, err := DeleteTab(ctx, workspaceId, tabId, false)
if err != nil {
return false, fmt.Errorf("error closing tab: %w", err)
}
@ -103,8 +107,9 @@ func CreateTab(ctx context.Context, workspaceId string, tabName string, activate
// Must delete all blocks individually first.
// Also deletes LayoutState.
// recursive: if true, will recursively close parent window, workspace, if they are empty.
// Returns new active tab id, error.
func DeleteTab(ctx context.Context, workspaceId string, tabId string) (string, error) {
func DeleteTab(ctx context.Context, workspaceId string, tabId string, recursive bool) (string, error) {
ws, _ := wstore.DBGet[*waveobj.Workspace](ctx, workspaceId)
if ws == nil {
return "", fmt.Errorf("workspace not found: %q", workspaceId)
@ -116,7 +121,7 @@ func DeleteTab(ctx context.Context, workspaceId string, tabId string) (string, e
// close blocks (sends events + stops block controllers)
for _, blockId := range tab.BlockIds {
err := DeleteBlock(ctx, blockId)
err := DeleteBlock(ctx, blockId, false)
if err != nil {
return "", fmt.Errorf("error deleting block %s: %w", blockId, err)
}
@ -139,6 +144,17 @@ func DeleteTab(ctx context.Context, workspaceId string, tabId string) (string, e
wstore.DBUpdate(ctx, ws)
wstore.DBDelete(ctx, waveobj.OType_Tab, tabId)
wstore.DBDelete(ctx, waveobj.OType_LayoutState, tab.LayoutState)
if newActiveTabId == "" && recursive {
windowId, err := wstore.DBFindWindowForWorkspaceId(ctx, workspaceId)
if err != nil {
return newActiveTabId, fmt.Errorf("unable to find window for workspace id %v: %w", workspaceId, err)
}
err = CloseWindow(ctx, windowId, false)
if err != nil {
return newActiveTabId, err
}
}
return newActiveTabId, nil
}
@ -158,6 +174,13 @@ func SetActiveTab(ctx context.Context, workspaceId string, tabId string) error {
return nil
}
func SendActiveTabUpdate(ctx context.Context, workspaceId string, newActiveTabId string) {
eventbus.SendEventToElectron(eventbus.WSEventType{
EventType: eventbus.WSEvent_ElectronUpdateActiveTab,
Data: &waveobj.ActiveTabUpdate{WorkspaceId: workspaceId, NewActiveTabId: newActiveTabId},
})
}
func UpdateWorkspaceTabIds(ctx context.Context, workspaceId string, tabIds []string) error {
ws, _ := wstore.DBGet[*waveobj.Workspace](ctx, workspaceId)
if ws == nil {

View File

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

View File

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

View File

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

View File

@ -489,7 +489,7 @@ func (ws *WshServer) FileAppendIJsonCommand(ctx context.Context, data wshrpc.Com
}
func (ws *WshServer) DeleteSubBlockCommand(ctx context.Context, data wshrpc.CommandDeleteBlockData) error {
err := wcore.DeleteBlock(ctx, data.BlockId)
err := wcore.DeleteBlock(ctx, data.BlockId, false)
if err != nil {
return fmt.Errorf("error deleting block: %w", err)
}
@ -505,7 +505,7 @@ func (ws *WshServer) DeleteBlockCommand(ctx context.Context, data wshrpc.Command
if tabId == "" {
return fmt.Errorf("no tab found for block")
}
err = wcore.DeleteBlock(ctx, data.BlockId)
err = wcore.DeleteBlock(ctx, data.BlockId, true)
if err != nil {
return fmt.Errorf("error deleting block: %w", err)
}

View File

@ -7,7 +7,6 @@ import (
"context"
"fmt"
"github.com/google/uuid"
"github.com/wavetermdev/waveterm/pkg/util/utilfn"
"github.com/wavetermdev/waveterm/pkg/waveobj"
)
@ -32,81 +31,6 @@ func UpdateTabName(ctx context.Context, tabId, name string) error {
})
}
func CreateSubBlock(ctx context.Context, parentBlockId string, blockDef *waveobj.BlockDef) (*waveobj.Block, error) {
return WithTxRtn(ctx, func(tx *TxWrap) (*waveobj.Block, error) {
parentBlock, _ := DBGet[*waveobj.Block](tx.Context(), parentBlockId)
if parentBlock == nil {
return nil, fmt.Errorf("parent block not found: %q", parentBlockId)
}
blockId := uuid.NewString()
blockData := &waveobj.Block{
OID: blockId,
ParentORef: waveobj.MakeORef(waveobj.OType_Block, parentBlockId).String(),
BlockDef: blockDef,
RuntimeOpts: nil,
Meta: blockDef.Meta,
}
DBInsert(tx.Context(), blockData)
parentBlock.SubBlockIds = append(parentBlock.SubBlockIds, blockId)
DBUpdate(tx.Context(), parentBlock)
return blockData, nil
})
}
func CreateBlock(ctx context.Context, tabId string, blockDef *waveobj.BlockDef, rtOpts *waveobj.RuntimeOpts) (*waveobj.Block, error) {
return WithTxRtn(ctx, func(tx *TxWrap) (*waveobj.Block, error) {
tab, _ := DBGet[*waveobj.Tab](tx.Context(), tabId)
if tab == nil {
return nil, fmt.Errorf("tab not found: %q", tabId)
}
blockId := uuid.NewString()
blockData := &waveobj.Block{
OID: blockId,
ParentORef: waveobj.MakeORef(waveobj.OType_Tab, tabId).String(),
BlockDef: blockDef,
RuntimeOpts: rtOpts,
Meta: blockDef.Meta,
}
DBInsert(tx.Context(), blockData)
tab.BlockIds = append(tab.BlockIds, blockId)
DBUpdate(tx.Context(), tab)
return blockData, nil
})
}
func DeleteBlock(ctx context.Context, blockId string) error {
return WithTx(ctx, func(tx *TxWrap) error {
block, err := DBGet[*waveobj.Block](tx.Context(), blockId)
if err != nil {
return fmt.Errorf("error getting block: %w", err)
}
if block == nil {
return nil
}
if len(block.SubBlockIds) > 0 {
return fmt.Errorf("block has subblocks, must delete subblocks first")
}
parentORef := waveobj.ParseORefNoErr(block.ParentORef)
if parentORef != nil {
if parentORef.OType == waveobj.OType_Tab {
tab, _ := DBGet[*waveobj.Tab](tx.Context(), parentORef.OID)
if tab != nil {
tab.BlockIds = utilfn.RemoveElemFromSlice(tab.BlockIds, blockId)
DBUpdate(tx.Context(), tab)
}
} else if parentORef.OType == waveobj.OType_Block {
parentBlock, _ := DBGet[*waveobj.Block](tx.Context(), parentORef.OID)
if parentBlock != nil {
parentBlock.SubBlockIds = utilfn.RemoveElemFromSlice(parentBlock.SubBlockIds, blockId)
DBUpdate(tx.Context(), parentBlock)
}
}
}
DBDelete(tx.Context(), waveobj.OType_Block, blockId)
return nil
})
}
// must delete all blocks individually first
// also deletes LayoutState
func DeleteTab(ctx context.Context, workspaceId string, tabId string) error {