From eab6afb91a3682dcc6cba68a25611d98ef0aed2c Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 16 May 2024 00:29:58 -0700 Subject: [PATCH] create block using a blockdef. better controller control. preview that takes a file. atom caching per block. lots of updates --- frontend/app/app.tsx | 35 +--- frontend/app/block/block.tsx | 4 +- frontend/app/store/global.ts | 62 +++---- frontend/app/view/preview.tsx | 73 ++++++--- frontend/app/view/term.tsx | 54 ++----- frontend/app/view/view.less | 10 ++ frontend/app/workspace/workspace.less | 76 +++++++++ frontend/app/workspace/workspace.tsx | 112 +++++++++++++ frontend/types/custom.d.ts | 37 ++++- package.json | 1 + pkg/blockcontroller/blockcommand.go | 31 +--- pkg/blockcontroller/blockcontroller.go | 195 ++++++++++++++++------- pkg/service/blockservice/blockservice.go | 37 ++++- pkg/service/fileservice/fileservice.go | 63 ++++++-- pkg/shellexec/shellexec.go | 7 +- pkg/util/utilfn/utilfn.go | 36 +++++ pkg/wavebase/wavebase.go | 45 ++++++ public/style.less | 29 ---- yarn.lock | 5 + 19 files changed, 654 insertions(+), 258 deletions(-) create mode 100644 frontend/app/workspace/workspace.less create mode 100644 frontend/app/workspace/workspace.tsx 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"