diff --git a/frontend/app/app.tsx b/frontend/app/app.tsx
index a5f629451..ab41992da 100644
--- a/frontend/app/app.tsx
+++ b/frontend/app/app.tsx
@@ -5,10 +5,10 @@ import * as React from "react";
import * as jotai from "jotai";
import { Provider } from "jotai";
import { clsx } from "clsx";
-import { TabContent } from "@/app/tab/tab";
+import { Workspace } from "@/app/workspace/workspace";
import { globalStore, atoms } from "@/store/global";
-import "/public/style.less";
+import "../../public/style.less";
const App = () => {
return (
@@ -18,37 +18,6 @@ const App = () => {
);
};
-const Tab = ({ tab }: { tab: TabData }) => {
- const [activeTab, setActiveTab] = jotai.useAtom(atoms.activeTabId);
- return (
-
setActiveTab(tab.tabid)}>
- {tab.name}
-
- );
-};
-
-const TabBar = () => {
- const [activeTab, setActiveTab] = jotai.useAtom(atoms.activeTabId);
- const tabs = jotai.useAtomValue(atoms.tabsAtom);
- return (
-
- {tabs.map((tab, idx) => {
- return ;
- })}
-
- );
-};
-
-const Workspace = () => {
- const activeTabId = jotai.useAtomValue(atoms.activeTabId);
- return (
-
-
-
-
- );
-};
-
const AppInner = () => {
return (
diff --git a/frontend/app/block/block.tsx b/frontend/app/block/block.tsx
index d6232ee0d..6f0cf590e 100644
--- a/frontend/app/block/block.tsx
+++ b/frontend/app/block/block.tsx
@@ -3,7 +3,7 @@
import * as React from "react";
import * as jotai from "jotai";
-import { atoms } from "@/store/global";
+import { atoms, blockDataMap } from "@/store/global";
import { TerminalView } from "@/app/view/term";
import { PreviewView } from "@/app/view/preview";
@@ -26,7 +26,7 @@ const Block = ({ blockId }: { blockId: string }) => {
}
}, [blockRef.current]);
let blockElem: JSX.Element = null;
- const blockAtom = atoms.blockAtomFamily(blockId);
+ const blockAtom = blockDataMap.get(blockId);
const blockData = jotai.useAtomValue(blockAtom);
if (blockData.view === "term") {
blockElem =
;
diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts
index c9e64297f..6abc56949 100644
--- a/frontend/app/store/global.ts
+++ b/frontend/app/store/global.ts
@@ -7,42 +7,20 @@ import { v4 as uuidv4 } from "uuid";
import * as rxjs from "rxjs";
import type { WailsEvent } from "@wailsio/runtime/types/events";
import { Events } from "@wailsio/runtime";
+import { produce } from "immer";
const globalStore = jotai.createStore();
const tabId1 = uuidv4();
-const tabId2 = uuidv4();
-const blockId1 = uuidv4();
-const blockId2 = uuidv4();
-const blockId3 = uuidv4();
-
-const tabArr: TabData[] = [
- { name: "Tab 1", tabid: tabId1, blockIds: [blockId1, blockId2] },
- { name: "Tab 2", tabid: tabId2, blockIds: [blockId3] },
-];
-
-const blockAtomFamily = atomFamily
>((blockId: string) => {
- if (blockId === blockId1) {
- return jotai.atom({ blockid: blockId1, view: "term" });
- }
- if (blockId === blockId2) {
- return jotai.atom({
- blockid: blockId2,
- view: "preview",
- meta: { mimetype: "text/markdown", file: "README.md" },
- });
- }
- if (blockId === blockId3) {
- return jotai.atom({ blockid: blockId3, view: "term" });
- }
- return jotai.atom(null);
-});
+const tabArr: TabData[] = [{ name: "Tab 1", tabid: tabId1, blockIds: [] }];
+const blockDataMap = new Map>();
+const blockAtomCache = new Map>>();
const atoms = {
activeTabId: jotai.atom(tabId1),
tabsAtom: jotai.atom(tabArr),
- blockAtomFamily,
+ blockDataMap: blockDataMap,
};
type SubjectWithRef = rxjs.Subject & { refCount: number; release: () => void };
@@ -81,4 +59,32 @@ Events.On("block:ptydata", (event: any) => {
subject.next(data);
});
-export { globalStore, atoms, getBlockSubject };
+function addBlockIdToTab(tabId: string, blockId: string) {
+ let tabArr = globalStore.get(atoms.tabsAtom);
+ const newTabArr = produce(tabArr, (draft) => {
+ const tab = draft.find((tab) => tab.tabid == tabId);
+ tab.blockIds.push(blockId);
+ });
+ globalStore.set(atoms.tabsAtom, newTabArr);
+}
+
+function removeBlock(blockId: string) {
+ blockDataMap.delete(blockId);
+ blockAtomCache.delete(blockId);
+}
+
+function useBlockAtom(blockId: string, name: string, makeFn: () => jotai.Atom): jotai.Atom {
+ let blockCache = blockAtomCache.get(blockId);
+ if (blockCache == null) {
+ blockCache = new Map>();
+ blockAtomCache.set(blockId, blockCache);
+ }
+ let atom = blockCache.get(name);
+ if (atom == null) {
+ atom = makeFn();
+ blockCache.set(name, atom);
+ }
+ return atom as jotai.Atom;
+}
+
+export { globalStore, atoms, getBlockSubject, addBlockIdToTab, blockDataMap, useBlockAtom };
diff --git a/frontend/app/view/preview.tsx b/frontend/app/view/preview.tsx
index bbfa11882..13457ffbf 100644
--- a/frontend/app/view/preview.tsx
+++ b/frontend/app/view/preview.tsx
@@ -3,33 +3,16 @@
import * as React from "react";
import * as jotai from "jotai";
-import { atoms } from "@/store/global";
+import { atoms, blockDataMap, useBlockAtom } from "@/store/global";
import { Markdown } from "@/element/markdown";
import * as FileService from "@/bindings/pkg/service/fileservice/FileService";
import * as util from "@/util/util";
+import { loadable } from "jotai/utils";
import "./view.less";
-const markdownText = `
-# Markdown Preview
-
-* list item 1
-* list item 2
-* item 3
-
-\`\`\`
-let foo = "bar";
-console.log(foo);
-\`\`\`
-`;
-
-const readmeAtom = jotai.atom(async () => {
- const readme = await FileService.ReadFile("README.md");
- return util.base64ToString(readme);
-});
-
-const MarkdownPreview = ({ blockData }: { blockData: BlockData }) => {
- const readmeText = jotai.useAtomValue(readmeAtom);
+const MarkdownPreview = ({ contentAtom }: { contentAtom: jotai.Atom> }) => {
+ const readmeText = jotai.useAtomValue(contentAtom);
return (
@@ -37,14 +20,54 @@ const MarkdownPreview = ({ blockData }: { blockData: BlockData }) => {
);
};
+let counter = 0;
+
const PreviewView = ({ blockId }: { blockId: string }) => {
- const blockData: BlockData = jotai.useAtomValue(atoms.blockAtomFamily(blockId));
- if (blockData.meta?.mimetype === "text/markdown") {
- return
;
+ const blockDataAtom: jotai.Atom
= blockDataMap.get(blockId);
+ const fileNameAtom = useBlockAtom(blockId, "preview:filename", () =>
+ jotai.atom((get) => {
+ return get(blockDataAtom)?.meta?.file;
+ })
+ );
+ const fullFileAtom = useBlockAtom(blockId, "preview:fullfile", () =>
+ jotai.atom>(async (get) => {
+ const fileName = get(fileNameAtom);
+ if (fileName == null) {
+ return null;
+ }
+ const file = await FileService.ReadFile(fileName);
+ return file;
+ })
+ );
+ const fileMimeTypeAtom = useBlockAtom(blockId, "preview:mimetype", () =>
+ jotai.atom>(async (get) => {
+ const fullFile = await get(fullFileAtom);
+ return fullFile?.info?.mimetype;
+ })
+ );
+ const fileContentAtom = useBlockAtom(blockId, "preview:filecontent", () =>
+ jotai.atom>(async (get) => {
+ const fullFile = await get(fullFileAtom);
+ return util.base64ToString(fullFile?.data64);
+ })
+ );
+ let mimeType = jotai.useAtomValue(fileMimeTypeAtom);
+ if (mimeType == null) {
+ mimeType = "";
+ }
+ if (mimeType === "text/markdown") {
+ return ;
+ }
+ if (mimeType.startsWith("text/")) {
+ return (
+
+
{jotai.useAtomValue(fileContentAtom)}
+
+ );
}
return (
-
Preview
+
Preview ({mimeType})
);
};
diff --git a/frontend/app/view/term.tsx b/frontend/app/view/term.tsx
index 2cc1f81c8..53544e2a4 100644
--- a/frontend/app/view/term.tsx
+++ b/frontend/app/view/term.tsx
@@ -44,8 +44,7 @@ function getThemeFromCSSVars(el: Element): ITheme {
const TerminalView = ({ blockId }: { blockId: string }) => {
const connectElemRef = React.useRef(null);
- const [term, setTerm] = React.useState(null);
- const [blockStarted, setBlockStarted] = React.useState(false);
+ const termRef = React.useRef(null);
React.useEffect(() => {
if (!connectElemRef.current) {
@@ -59,26 +58,30 @@ const TerminalView = ({ blockId }: { blockId: string }) => {
fontWeight: "normal",
fontWeightBold: "bold",
});
- setTerm(term);
+ termRef.current = term;
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.open(connectElemRef.current);
fitAddon.fit();
- term.write("Hello, world!\r\n");
- console.log(term);
+ BlockService.SendCommand(blockId, {
+ command: "controller:input",
+ termsize: { rows: term.rows, cols: term.cols },
+ });
term.onData((data) => {
const b64data = btoa(data);
- const inputCmd = { command: "input", blockid: blockId, inputdata64: b64data };
+ const inputCmd = { command: "controller:input", blockid: blockId, inputdata64: b64data };
BlockService.SendCommand(blockId, inputCmd);
});
-
// resize observer
const rszObs = new ResizeObserver(() => {
const oldRows = term.rows;
const oldCols = term.cols;
fitAddon.fit();
if (oldRows !== term.rows || oldCols !== term.cols) {
- BlockService.SendCommand(blockId, { command: "input", termsize: { rows: term.rows, cols: term.cols } });
+ BlockService.SendCommand(blockId, {
+ command: "controller:input",
+ termsize: { rows: term.rows, cols: term.cols },
+ });
}
});
rszObs.observe(connectElemRef.current);
@@ -98,43 +101,8 @@ const TerminalView = ({ blockId }: { blockId: string }) => {
};
}, [connectElemRef.current]);
- async function handleRunClick() {
- try {
- if (!blockStarted) {
- await BlockService.StartBlock(blockId);
- setBlockStarted(true);
- }
- let termSize = { rows: term.rows, cols: term.cols };
- await BlockService.SendCommand(blockId, { command: "run", cmdstr: "ls -l", termsize: termSize });
- } catch (e) {
- console.log("run click error: ", e);
- }
- }
-
- async function handleStartTerminalClick() {
- try {
- if (!blockStarted) {
- await BlockService.StartBlock(blockId);
- setBlockStarted(true);
- }
- let termSize = { rows: term.rows, cols: term.cols };
- await BlockService.SendCommand(blockId, { command: "runshell", termsize: termSize });
- } catch (e) {
- console.log("start terminal click error: ", e);
- }
- }
-
return (
-
-
Terminal
-
-
-
);
diff --git a/frontend/app/view/view.less b/frontend/app/view/view.less
index b75ad0643..172ee8f37 100644
--- a/frontend/app/view/view.less
+++ b/frontend/app/view/view.less
@@ -40,4 +40,14 @@
align-items: start;
justify-content: start;
}
+
+ &.view-preview-text {
+ align-items: start;
+ justify-content: start;
+ overflow: auto;
+
+ pre {
+ font: var(--fixed-font);
+ }
+ }
}
diff --git a/frontend/app/workspace/workspace.less b/frontend/app/workspace/workspace.less
new file mode 100644
index 000000000..76ddc8a3a
--- /dev/null
+++ b/frontend/app/workspace/workspace.less
@@ -0,0 +1,76 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+.workspace {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ flex-grow: 1;
+ overflow: hidden;
+
+ .workspace-tabcontent {
+ display: flex;
+ flex-direction: row;
+ flex-grow: 1;
+ overflow: hidden;
+ }
+
+ .workspace-widgets {
+ display: flex;
+ flex-direction: column;
+ width: 40px;
+ overflow: hidden;
+ background-color: var(--panel-bg-color);
+ border-left: 1px solid var(--border-color);
+
+ .widget {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ width: 100%;
+ padding: 10px 2px 10px 0;
+ color: var(--secondary-text-color);
+ font-size: 20px;
+ &:hover:not(.no-hover) {
+ background-color: var(--highlight-bg-color);
+ cursor: pointer;
+ color: white;
+ }
+ }
+ }
+}
+
+.tab-bar {
+ display: flex;
+ flex-direction: row;
+ height: 35px;
+ border-bottom: 1px solid var(--border-color);
+ flex-shrink: 0;
+
+ .tab {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ width: 100px;
+ height: 100%;
+ border-right: 1px solid var(--border-color);
+ cursor: pointer;
+ &.active {
+ background-color: var(--highlight-bg-color);
+ }
+ }
+
+ .tab-add {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ width: 40px;
+ height: 100%;
+ cursor: pointer;
+ border-left: 1px solid transparent;
+ &:hover {
+ border-left: 1px solid white;
+ background-color: var(--highlight-bg-color);
+ }
+ }
+}
diff --git a/frontend/app/workspace/workspace.tsx b/frontend/app/workspace/workspace.tsx
new file mode 100644
index 000000000..e08d37325
--- /dev/null
+++ b/frontend/app/workspace/workspace.tsx
@@ -0,0 +1,112 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import * as React from "react";
+import * as jotai from "jotai";
+import { TabContent } from "@/app/tab/tab";
+import { clsx } from "clsx";
+import { atoms, addBlockIdToTab, blockDataMap } from "@/store/global";
+import { v4 as uuidv4 } from "uuid";
+import * as BlockService from "@/bindings/pkg/service/blockservice/BlockService";
+
+import "./workspace.less";
+
+function Tab({ tab }: { tab: TabData }) {
+ const [activeTab, setActiveTab] = jotai.useAtom(atoms.activeTabId);
+ return (
+ setActiveTab(tab.tabid)}>
+ {tab.name}
+
+ );
+}
+
+function TabBar() {
+ const [tabData, setTabData] = jotai.useAtom(atoms.tabsAtom);
+ const [activeTab, setActiveTab] = jotai.useAtom(atoms.activeTabId);
+ const tabs = jotai.useAtomValue(atoms.tabsAtom);
+
+ function handleAddTab() {
+ const newTabId = uuidv4();
+ const newTabName = "Tab " + (tabData.length + 1);
+ setTabData([...tabData, { name: newTabName, tabid: newTabId, blockIds: [] }]);
+ setActiveTab(newTabId);
+ }
+
+ return (
+
+ {tabs.map((tab, idx) => {
+ return
;
+ })}
+
handleAddTab()}>
+
+
+
+ );
+}
+
+function Widgets() {
+ const activeTabId = jotai.useAtomValue(atoms.activeTabId);
+
+ async function createBlock(blockDef: BlockDef) {
+ const rtOpts = { termsize: { rows: 25, cols: 80 } };
+ const rtnBlock: BlockData = await BlockService.CreateBlock(blockDef, rtOpts);
+ const newBlockAtom = jotai.atom(rtnBlock);
+ blockDataMap.set(rtnBlock.blockid, newBlockAtom);
+ addBlockIdToTab(activeTabId, rtnBlock.blockid);
+ }
+
+ async function clickTerminal() {
+ const termBlockDef = {
+ controller: "shell",
+ view: "term",
+ };
+ createBlock(termBlockDef);
+ }
+
+ async function clickPreview(fileName: string) {
+ const markdownDef = {
+ view: "preview",
+ meta: { file: fileName },
+ };
+ createBlock(markdownDef);
+ }
+
+ async function clickPlot() {
+ console.log("TODO plot");
+ }
+
+ return (
+
+
clickTerminal()}>
+
+
+
clickPreview("README.md")}>
+
+
+
clickPreview("go.mod")}>
+
+
+
clickPlot()}>
+
+
+
+
+
+
+ );
+}
+
+function Workspace() {
+ const activeTabId = jotai.useAtomValue(atoms.activeTabId);
+ return (
+
+ );
+}
+
+export { Workspace };
diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts
index 0dc807a92..a67689421 100644
--- a/frontend/types/custom.d.ts
+++ b/frontend/types/custom.d.ts
@@ -2,6 +2,8 @@
// SPDX-License-Identifier: Apache-2.0
declare global {
+ type MetaDataType = Record;
+
type TabData = {
name: string;
tabid: string;
@@ -10,8 +12,41 @@ declare global {
type BlockData = {
blockid: string;
+ blockdef: BlockDef;
+ controller: string;
+ controllerstatus: string;
view: string;
- meta?: Record;
+ meta?: MetaDataType;
+ };
+
+ type FileDef = {
+ filetype?: string;
+ path?: string;
+ url?: string;
+ content?: string;
+ meta?: MetaDataType;
+ };
+
+ type BlockDef = {
+ controller?: string;
+ view: string;
+ files?: FileDef[];
+ meta?: MetaDataType;
+ };
+
+ type FileInfo = {
+ path: string;
+ notfound: boolean;
+ size: number;
+ mode: number;
+ modtime: number;
+ isdir: boolean;
+ mimetype: string;
+ };
+
+ type FullFile = {
+ info: FileInfo;
+ data64: string;
};
}
diff --git a/package.json b/package.json
index f4b68f3ac..05c224082 100644
--- a/package.json
+++ b/package.json
@@ -23,6 +23,7 @@
"@xterm/xterm": "^5.5.0",
"base64-js": "^1.5.1",
"clsx": "^2.1.1",
+ "immer": "^10.1.1",
"jotai": "^2.8.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
diff --git a/pkg/blockcontroller/blockcommand.go b/pkg/blockcontroller/blockcommand.go
index bdff3f0f1..d25662c71 100644
--- a/pkg/blockcontroller/blockcommand.go
+++ b/pkg/blockcontroller/blockcommand.go
@@ -14,17 +14,13 @@ import (
const CommandKey = "command"
const (
- BlockCommand_Message = "message"
- BlockCommand_Run = "run"
- BlockCommand_Input = "input"
- BlockCommand_RunShell = "runshell"
+ BlockCommand_Message = "message"
+ BlockCommand_Input = "controller:input"
)
var CommandToTypeMap = map[string]reflect.Type{
- BlockCommand_Message: reflect.TypeOf(MessageCommand{}),
- BlockCommand_Run: reflect.TypeOf(RunCommand{}),
- BlockCommand_Input: reflect.TypeOf(InputCommand{}),
- BlockCommand_RunShell: reflect.TypeOf(RunShellCommand{}),
+ BlockCommand_Message: reflect.TypeOf(MessageCommand{}),
+ BlockCommand_Input: reflect.TypeOf(InputCommand{}),
}
type BlockCommand interface {
@@ -61,16 +57,6 @@ func (mc *MessageCommand) GetCommand() string {
return BlockCommand_Message
}
-type RunCommand struct {
- Command string `json:"command"`
- CmdStr string `json:"cmdstr"`
- TermSize shellexec.TermSize `json:"termsize"`
-}
-
-func (rc *RunCommand) GetCommand() string {
- return BlockCommand_Run
-}
-
type InputCommand struct {
Command string `json:"command"`
InputData64 string `json:"inputdata64"`
@@ -81,12 +67,3 @@ type InputCommand struct {
func (ic *InputCommand) GetCommand() string {
return BlockCommand_Input
}
-
-type RunShellCommand struct {
- Command string `json:"command"`
- TermSize shellexec.TermSize `json:"termsize"`
-}
-
-func (rsc *RunShellCommand) GetCommand() string {
- return BlockCommand_RunShell
-}
diff --git a/pkg/blockcontroller/blockcontroller.go b/pkg/blockcontroller/blockcontroller.go
index 6bf657b84..375f0262e 100644
--- a/pkg/blockcontroller/blockcontroller.go
+++ b/pkg/blockcontroller/blockcontroller.go
@@ -5,30 +5,121 @@ package blockcontroller
import (
"encoding/base64"
+ "encoding/json"
"fmt"
"io"
"log"
- "os/exec"
"sync"
"github.com/creack/pty"
+ "github.com/google/uuid"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wavetermdev/thenextwave/pkg/eventbus"
"github.com/wavetermdev/thenextwave/pkg/shellexec"
- "github.com/wavetermdev/thenextwave/pkg/util/shellutil"
+)
+
+const (
+ BlockController_Shell = "shell"
+ BlockController_Cmd = "cmd"
)
var globalLock = &sync.Mutex{}
var blockControllerMap = make(map[string]*BlockController)
+var blockDataMap = make(map[string]*BlockData)
+
+type BlockData struct {
+ Lock *sync.Mutex `json:"-"`
+ BlockId string `json:"blockid"`
+ BlockDef *BlockDef `json:"blockdef"`
+ Controller string `json:"controller"`
+ ControllerStatus string `json:"controllerstatus"`
+ View string `json:"view"`
+ Meta map[string]any `json:"meta,omitempty"`
+ RuntimeOpts *RuntimeOpts `json:"runtimeopts,omitempty"`
+}
+
+type FileDef struct {
+ FileType string `json:"filetype,omitempty"`
+ Path string `json:"path,omitempty"`
+ Url string `json:"url,omitempty"`
+ Content string `json:"content,omitempty"`
+ Meta map[string]any `json:"meta,omitempty"`
+}
+
+type BlockDef struct {
+ Controller string `json:"controller"`
+ View string `json:"view,omitempty"`
+ Files map[string]*FileDef `json:"files,omitempty"`
+ Meta map[string]any `json:"meta,omitempty"`
+}
+
+type WinSize struct {
+ Width int `json:"width"`
+ Height int `json:"height"`
+}
+
+type RuntimeOpts struct {
+ TermSize shellexec.TermSize `json:"termsize,omitempty"`
+ WinSize WinSize `json:"winsize,omitempty"`
+}
type BlockController struct {
- Lock *sync.Mutex
- BlockId string
- InputCh chan BlockCommand
+ Lock *sync.Mutex
+ BlockId string
+ BlockDef *BlockDef
+ InputCh chan BlockCommand
+
ShellProc *shellexec.ShellProc
ShellInputCh chan *InputCommand
}
+func jsonDeepCopy(val map[string]any) (map[string]any, error) {
+ barr, err := json.Marshal(val)
+ if err != nil {
+ return nil, err
+ }
+ var rtn map[string]any
+ err = json.Unmarshal(barr, &rtn)
+ if err != nil {
+ return nil, err
+ }
+ return rtn, nil
+}
+
+func CreateBlock(bdef *BlockDef, rtOpts *RuntimeOpts) (*BlockData, error) {
+ blockId := uuid.New().String()
+ blockData := &BlockData{
+ Lock: &sync.Mutex{},
+ BlockId: blockId,
+ BlockDef: bdef,
+ Controller: bdef.Controller,
+ View: bdef.View,
+ RuntimeOpts: rtOpts,
+ }
+ var err error
+ blockData.Meta, err = jsonDeepCopy(bdef.Meta)
+ if err != nil {
+ return nil, fmt.Errorf("error copying meta: %w", err)
+ }
+ setBlockData(blockData)
+ if blockData.Controller != "" {
+ StartBlockController(blockId, blockData)
+ }
+ return blockData, nil
+}
+
+func GetBlockData(blockId string) *BlockData {
+ globalLock.Lock()
+ defer globalLock.Unlock()
+ return blockDataMap[blockId]
+}
+
+func setBlockData(bd *BlockData) {
+ globalLock.Lock()
+ defer globalLock.Unlock()
+ blockDataMap[bd.BlockId] = bd
+}
+
func (bc *BlockController) setShellProc(shellProc *shellexec.ShellProc) error {
bc.Lock.Lock()
defer bc.Lock.Unlock()
@@ -45,34 +136,11 @@ func (bc *BlockController) getShellProc() *shellexec.ShellProc {
return bc.ShellProc
}
-func (bc *BlockController) DoRunCommand(rc *RunCommand) error {
- cmdStr := rc.CmdStr
- shellPath := shellutil.DetectLocalShellPath()
- ecmd := exec.Command(shellPath, "-c", cmdStr)
- log.Printf("running shell command: %q %q\n", shellPath, cmdStr)
- barr, err := shellexec.RunSimpleCmdInPty(ecmd, rc.TermSize)
- if err != nil {
- return err
- }
- for len(barr) > 0 {
- part := barr
- if len(part) > 4096 {
- part = part[:4096]
- }
- eventbus.SendEvent(application.WailsEvent{
- Name: "block:ptydata",
- Data: map[string]any{
- "blockid": bc.BlockId,
- "blockfile": "main",
- "ptydata": base64.StdEncoding.EncodeToString(part),
- },
- })
- barr = barr[len(part):]
- }
- return nil
+type RunShellOpts struct {
+ TermSize shellexec.TermSize `json:"termsize,omitempty"`
}
-func (bc *BlockController) DoRunShellCommand(rc *RunShellCommand) error {
+func (bc *BlockController) DoRunShellCommand(rc *RunShellOpts) error {
if bc.getShellProc() != nil {
return nil
}
@@ -95,15 +163,18 @@ func (bc *BlockController) DoRunShellCommand(rc *RunShellCommand) error {
bc.ShellProc = nil
bc.ShellInputCh = nil
}()
+ seqNum := 0
buf := make([]byte, 4096)
for {
nr, err := bc.ShellProc.Pty.Read(buf)
+ seqNum++
eventbus.SendEvent(application.WailsEvent{
Name: "block:ptydata",
Data: map[string]any{
"blockid": bc.BlockId,
"blockfile": "main",
"ptydata": base64.StdEncoding.EncodeToString(buf[:nr]),
+ "seqnum": seqNum,
},
})
if err == io.EOF {
@@ -127,6 +198,7 @@ func (bc *BlockController) DoRunShellCommand(rc *RunShellCommand) error {
bc.ShellProc.Pty.Write(inputBuf[:nw])
}
if ic.TermSize != nil {
+ log.Printf("SETTERMSIZE: %dx%d\n", ic.TermSize.Rows, ic.TermSize.Cols)
err := pty.Setsize(bc.ShellProc.Pty, &pty.Winsize{Rows: uint16(ic.TermSize.Rows), Cols: uint16(ic.TermSize.Cols)})
if err != nil {
log.Printf("error setting term size: %v\n", err)
@@ -137,8 +209,14 @@ func (bc *BlockController) DoRunShellCommand(rc *RunShellCommand) error {
return nil
}
-func (bc *BlockController) Run() {
+func (bc *BlockController) Run(bdata *BlockData) {
defer func() {
+ bdata.WithLock(func() {
+ // if the controller had an error status, don't change it
+ if bdata.ControllerStatus == "running" {
+ bdata.ControllerStatus = "done"
+ }
+ })
eventbus.SendEvent(application.WailsEvent{
Name: "block:done",
Data: nil,
@@ -147,6 +225,17 @@ func (bc *BlockController) Run() {
defer globalLock.Unlock()
delete(blockControllerMap, bc.BlockId)
}()
+ bdata.WithLock(func() {
+ bdata.ControllerStatus = "running"
+ })
+
+ // only controller is "shell" for now
+ go func() {
+ err := bc.DoRunShellCommand(&RunShellOpts{TermSize: bdata.RuntimeOpts.TermSize})
+ if err != nil {
+ log.Printf("error running shell: %v\n", err)
+ }
+ }()
messageCount := 0
for genCmd := range bc.InputCh {
@@ -162,42 +251,35 @@ func (bc *BlockController) Run() {
"ptydata": base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("message %d\r\n", messageCount))),
},
})
- case *RunCommand:
- fmt.Printf("RUN: %s | %q\n", bc.BlockId, cmd.CmdStr)
- go func() {
- err := bc.DoRunCommand(cmd)
- if err != nil {
- log.Printf("error running shell command: %v\n", err)
- }
- }()
case *InputCommand:
fmt.Printf("INPUT: %s | %q\n", bc.BlockId, cmd.InputData64)
if bc.ShellInputCh != nil {
bc.ShellInputCh <- cmd
}
-
- case *RunShellCommand:
- fmt.Printf("RUNSHELL: %s\n", bc.BlockId)
- if bc.ShellProc != nil {
- continue
- }
- go func() {
- err := bc.DoRunShellCommand(cmd)
- if err != nil {
- log.Printf("error running shell: %v\n", err)
- }
- }()
default:
fmt.Printf("unknown command type %T\n", cmd)
}
}
}
-func StartBlockController(blockId string) *BlockController {
+func (b *BlockData) WithLock(f func()) {
+ b.Lock.Lock()
+ defer b.Lock.Unlock()
+ f()
+}
+
+func StartBlockController(blockId string, bdata *BlockData) {
+ if bdata.Controller != BlockController_Shell {
+ log.Printf("unknown controller %q\n", bdata.Controller)
+ bdata.WithLock(func() {
+ bdata.ControllerStatus = "error"
+ })
+ return
+ }
globalLock.Lock()
defer globalLock.Unlock()
- if existingBC, ok := blockControllerMap[blockId]; ok {
- return existingBC
+ if _, ok := blockControllerMap[blockId]; ok {
+ return
}
bc := &BlockController{
Lock: &sync.Mutex{},
@@ -205,8 +287,7 @@ func StartBlockController(blockId string) *BlockController {
InputCh: make(chan BlockCommand),
}
blockControllerMap[blockId] = bc
- go bc.Run()
- return bc
+ go bc.Run(bdata)
}
func GetBlockController(blockId string) *BlockController {
diff --git a/pkg/service/blockservice/blockservice.go b/pkg/service/blockservice/blockservice.go
index 257563b3a..d70a64123 100644
--- a/pkg/service/blockservice/blockservice.go
+++ b/pkg/service/blockservice/blockservice.go
@@ -7,13 +7,44 @@ import (
"fmt"
"github.com/wavetermdev/thenextwave/pkg/blockcontroller"
+ "github.com/wavetermdev/thenextwave/pkg/util/utilfn"
)
type BlockService struct{}
-func (bs *BlockService) StartBlock(blockId string) error {
- blockcontroller.StartBlockController(blockId)
- return nil
+func (bs *BlockService) CreateBlock(bdefMap map[string]any, rtOptsMap map[string]any) (map[string]any, error) {
+ var bdef blockcontroller.BlockDef
+ err := utilfn.JsonMapToStruct(bdefMap, &bdef)
+ if err != nil {
+ return nil, fmt.Errorf("error unmarshalling BlockDef: %w", err)
+ }
+ var rtOpts blockcontroller.RuntimeOpts
+ err = utilfn.JsonMapToStruct(rtOptsMap, &rtOpts)
+ if err != nil {
+ return nil, fmt.Errorf("error unmarshalling RuntimeOpts: %w", err)
+ }
+ blockData, err := blockcontroller.CreateBlock(&bdef, &rtOpts)
+ if err != nil {
+ return nil, fmt.Errorf("error creating block: %w", err)
+ }
+ rtnMap, err := utilfn.StructToJsonMap(blockData)
+ if err != nil {
+ return nil, fmt.Errorf("error marshalling BlockData: %w", err)
+ }
+ return rtnMap, nil
+}
+
+func (bs *BlockService) GetBlockData(blockId string) (map[string]any, error) {
+ blockData := blockcontroller.GetBlockData(blockId)
+ if blockData == nil {
+ return nil, nil
+ }
+ rtnMap, err := utilfn.StructToJsonMap(blockData)
+ if err != nil {
+ return nil, fmt.Errorf("error marshalling BlockData: %w", err)
+ }
+ return rtnMap, nil
+
}
func (bs *BlockService) SendCommand(blockId string, cmdMap map[string]any) error {
diff --git a/pkg/service/fileservice/fileservice.go b/pkg/service/fileservice/fileservice.go
index da188bfc5..306d2465d 100644
--- a/pkg/service/fileservice/fileservice.go
+++ b/pkg/service/fileservice/fileservice.go
@@ -7,19 +7,64 @@ import (
"encoding/base64"
"fmt"
"os"
- "time"
+ "path/filepath"
+ "github.com/wavetermdev/thenextwave/pkg/util/utilfn"
"github.com/wavetermdev/thenextwave/pkg/wavebase"
)
type FileService struct{}
-func (fs *FileService) ReadFile(path string) (string, error) {
- path = wavebase.ExpandHomeDir(path)
- barr, err := os.ReadFile(path)
- if err != nil {
- return "", fmt.Errorf("cannot read file %q: %w", path, err)
- }
- time.Sleep(2 * time.Second)
- return base64.StdEncoding.EncodeToString(barr), nil
+type FileInfo struct {
+ Path string `json:"path"` // cleaned path
+ NotFound bool `json:"notfound,omitempty"`
+ Size int64 `json:"size"`
+ Mode os.FileMode `json:"mode"`
+ ModTime int64 `json:"modtime"`
+ IsDir bool `json:"isdir,omitempty"`
+ MimeType string `json:"mimetype,omitempty"`
+}
+
+type FullFile struct {
+ Info *FileInfo `json:"info"`
+ Data64 string `json:"data64,omitempty"` // base64 encoded
+}
+
+func (fs *FileService) StatFile(path string) (*FileInfo, error) {
+ cleanedPath := filepath.Clean(wavebase.ExpandHomeDir(path))
+ finfo, err := os.Stat(cleanedPath)
+ if os.IsNotExist(err) {
+ return &FileInfo{Path: wavebase.ReplaceHomeDir(path), NotFound: true}, nil
+ }
+ if err != nil {
+ return nil, fmt.Errorf("cannot stat file %q: %w", path, err)
+ }
+ mimeType := utilfn.DetectMimeType(path)
+ return &FileInfo{
+ Path: wavebase.ReplaceHomeDir(path),
+ Size: finfo.Size(),
+ Mode: finfo.Mode(),
+ ModTime: finfo.ModTime().UnixMilli(),
+ IsDir: finfo.IsDir(),
+ MimeType: mimeType,
+ }, nil
+}
+
+func (fs *FileService) ReadFile(path string) (*FullFile, error) {
+ finfo, err := fs.StatFile(path)
+ if err != nil {
+ return nil, fmt.Errorf("cannot stat file %q: %w", path, err)
+ }
+ if finfo.NotFound {
+ return &FullFile{Info: finfo}, nil
+ }
+ cleanedPath := filepath.Clean(wavebase.ExpandHomeDir(path))
+ barr, err := os.ReadFile(cleanedPath)
+ if err != nil {
+ return nil, fmt.Errorf("cannot read file %q: %w", path, err)
+ }
+ return &FullFile{
+ Info: finfo,
+ Data64: base64.StdEncoding.EncodeToString(barr),
+ }, nil
}
diff --git a/pkg/shellexec/shellexec.go b/pkg/shellexec/shellexec.go
index ef041327f..890f946c1 100644
--- a/pkg/shellexec/shellexec.go
+++ b/pkg/shellexec/shellexec.go
@@ -13,6 +13,7 @@ import (
"github.com/creack/pty"
"github.com/wavetermdev/thenextwave/pkg/util/shellutil"
+ "github.com/wavetermdev/thenextwave/pkg/wavebase"
)
type TermSize struct {
@@ -37,7 +38,11 @@ func StartShellProc(termSize TermSize) (*ShellProc, error) {
shellPath := shellutil.DetectLocalShellPath()
ecmd := exec.Command(shellPath, "-i", "-l")
ecmd.Env = os.Environ()
- shellutil.UpdateCmdEnv(ecmd, shellutil.WaveshellEnvVars(shellutil.DefaultTermType))
+ envToAdd := shellutil.WaveshellEnvVars(shellutil.DefaultTermType)
+ if os.Getenv("LANG") == "" {
+ envToAdd["LANG"] = wavebase.DetermineLang()
+ }
+ shellutil.UpdateCmdEnv(ecmd, envToAdd)
cmdPty, cmdTty, err := pty.Open()
if err != nil {
return nil, fmt.Errorf("opening new pty: %w", err)
diff --git a/pkg/util/utilfn/utilfn.go b/pkg/util/utilfn/utilfn.go
index d83bf8574..ae568d63c 100644
--- a/pkg/util/utilfn/utilfn.go
+++ b/pkg/util/utilfn/utilfn.go
@@ -13,9 +13,11 @@ import (
"io"
"math"
mathrand "math/rand"
+ "mime"
"net/http"
"os"
"os/exec"
+ "path/filepath"
"regexp"
"sort"
"strings"
@@ -616,9 +618,22 @@ func CopyToChannel(outputCh chan<- []byte, reader io.Reader) error {
}
}
+// TODO more
+var StaticMimeTypeMap = map[string]string{
+ ".md": "text/markdown",
+ ".json": "application/json",
+}
+
// on error just returns ""
// does not return "application/octet-stream" as this is considered a detection failure
func DetectMimeType(path string) string {
+ ext := filepath.Ext(path)
+ if mimeType, ok := StaticMimeTypeMap[ext]; ok {
+ return mimeType
+ }
+ if mimeType := mime.TypeByExtension(ext); mimeType != "" {
+ return mimeType
+ }
fd, err := os.Open(path)
if err != nil {
return ""
@@ -673,3 +688,24 @@ func GetFirstLine(s string) string {
}
return s[0:idx]
}
+
+func JsonMapToStruct(m map[string]any, v interface{}) error {
+ barr, err := json.Marshal(m)
+ if err != nil {
+ return err
+ }
+ return json.Unmarshal(barr, v)
+}
+
+func StructToJsonMap(v interface{}) (map[string]any, error) {
+ barr, err := json.Marshal(v)
+ if err != nil {
+ return nil, err
+ }
+ var m map[string]any
+ err = json.Unmarshal(barr, &m)
+ if err != nil {
+ return nil, err
+ }
+ return m, nil
+}
diff --git a/pkg/wavebase/wavebase.go b/pkg/wavebase/wavebase.go
index d828e5800..4f5d74b62 100644
--- a/pkg/wavebase/wavebase.go
+++ b/pkg/wavebase/wavebase.go
@@ -4,13 +4,18 @@
package wavebase
import (
+ "context"
"errors"
"fmt"
"io/fs"
+ "log"
"os"
+ "os/exec"
"path"
+ "runtime"
"strings"
"sync"
+ "time"
)
const WaveVersion = "v0.1.0"
@@ -46,6 +51,17 @@ func ExpandHomeDir(pathStr string) string {
return path.Join(homeDir, pathStr[2:])
}
+func ReplaceHomeDir(pathStr string) string {
+ homeDir := GetHomeDir()
+ if pathStr == homeDir {
+ return "~"
+ }
+ if strings.HasPrefix(pathStr, homeDir+"/") {
+ return "~" + pathStr[len(homeDir):]
+ }
+ return pathStr
+}
+
func GetWaveHomeDir() string {
homeVar := os.Getenv(WaveHomeVarName)
if homeVar != "" {
@@ -92,3 +108,32 @@ func TryMkdirs(dirName string, perm os.FileMode, dirDesc string) error {
}
return nil
}
+
+var osLangOnce = &sync.Once{}
+var osLang string
+
+func determineLang() string {
+ ctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancelFn()
+ if runtime.GOOS == "darwin" {
+ out, err := exec.CommandContext(ctx, "defaults", "read", "-g", "AppleLocale").CombinedOutput()
+ if err != nil {
+ log.Printf("error executing 'defaults read -g AppleLocale': %v\n", err)
+ return ""
+ }
+ strOut := string(out)
+ truncOut := strings.Split(strOut, "@")[0]
+ return strings.TrimSpace(truncOut) + ".UTF-8"
+ } else {
+ // this is specifically to get the wavesrv LANG so waveshell
+ // on a remote uses the same LANG
+ return os.Getenv("LANG")
+ }
+}
+
+func DetermineLang() string {
+ osLangOnce.Do(func() {
+ osLang = determineLang()
+ })
+ return osLang
+}
diff --git a/public/style.less b/public/style.less
index 2af4d1919..766c4de14 100644
--- a/public/style.less
+++ b/public/style.less
@@ -36,32 +36,3 @@ body {
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
-
-.workspace {
- display: flex;
- flex-direction: column;
- width: 100%;
- flex-grow: 1;
- overflow: hidden;
-}
-
-.tab-bar {
- display: flex;
- flex-direction: row;
- height: 35px;
- border-bottom: 1px solid var(--border-color);
- flex-shrink: 0;
-
- .tab {
- display: flex;
- justify-content: center;
- align-items: center;
- width: 100px;
- height: 100%;
- border-right: 1px solid var(--border-color);
- cursor: pointer;
- &.active {
- background-color: var(--highlight-bg-color);
- }
- }
-}
diff --git a/yarn.lock b/yarn.lock
index f5dc07561..975cbd4b6 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -840,6 +840,11 @@ image-size@~0.5.0:
resolved "https://registry.yarnpkg.com/image-size/-/image-size-0.5.5.tgz#09dfd4ab9d20e29eb1c3e80b8990378df9e3cb9c"
integrity sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ==
+immer@^10.1.1:
+ version "10.1.1"
+ resolved "https://registry.yarnpkg.com/immer/-/immer-10.1.1.tgz#206f344ea372d8ea176891545ee53ccc062db7bc"
+ integrity sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==
+
inline-style-parser@0.2.3:
version "0.2.3"
resolved "https://registry.yarnpkg.com/inline-style-parser/-/inline-style-parser-0.2.3.tgz#e35c5fb45f3a83ed7849fe487336eb7efa25971c"