Merge pull request #7 from wavetermdev/sawka/wstore

WaveObj + objectservice + WaveObjectStore

New distributed object system for Wave. Every object has an otype + oid (oref), and can be persisted to the DB and gotten from the DB using those attributes. The frontend tracks a cache of objects (based on orefs) and the store is integrated with jotai atoms for use with react components.

The WaveObjectStore passes a new UIContext through to the backend, and will seamlessly handle updated/deleted objects passed back (updating the store).

The backend DB operations are now all generic. As the updates happen they update the context adding the updated/deleted objects to seamlessly pass them back to the client.

Simplified global.ts store. Have a new way to force an object to be present in the cache at the top level (await loadAndPin) along with integration with nice loading flags for async fetching (or integration with React.Suspense)
This commit is contained in:
Mike Sawka
2024-05-27 16:40:26 -07:00
committed by GitHub
32 changed files with 2076 additions and 353 deletions
+1 -1
View File
@@ -34,7 +34,7 @@ Now to run the dev version of the app:
wails3 dev
```
You should see a very poorly laid out app :)
You should see the app!
Now to build a MacOS application:
+26
View File
@@ -0,0 +1,26 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
package main
import (
"fmt"
"reflect"
"github.com/wavetermdev/thenextwave/pkg/waveobj"
"github.com/wavetermdev/thenextwave/pkg/wstore"
)
func main() {
tsTypesMap := make(map[reflect.Type]string)
var waveObj waveobj.WaveObj
waveobj.GenerateTSType(reflect.TypeOf(waveobj.ORef{}), tsTypesMap)
waveobj.GenerateTSType(reflect.TypeOf(&waveObj).Elem(), tsTypesMap)
for _, rtype := range wstore.AllWaveObjTypes() {
waveobj.GenerateTSType(rtype, tsTypesMap)
}
for _, ts := range tsTypesMap {
fmt.Print(ts)
fmt.Print("\n")
}
}
+3
View File
@@ -7,3 +7,6 @@ import "embed"
//go:embed migrations-blockstore/*.sql
var BlockstoreMigrationFS embed.FS
//go:embed migrations-wstore/*.sql
var WStoreMigrationFS embed.FS
+14 -5
View File
@@ -1,20 +1,29 @@
CREATE TABLE db_client (
clientid varchar(36) PRIMARY KEY, -- unnecessary, but useful to have a PK
oid varchar(36) PRIMARY KEY,
version int NOT NULL,
data json NOT NULL
);
CREATE TABLE db_window (
oid varchar(36) PRIMARY KEY,
version int NOT NULL,
data json NOT NULL
);
CREATE TABLE db_workspace (
workspaceid varchar(36) PRIMARY KEY,
oid varchar(36) PRIMARY KEY,
version int NOT NULL,
data json NOT NULL
);
CREATE TABLE db_tab (
tabid varchar(36) PRIMARY KEY,
oid varchar(36) PRIMARY KEY,
version int NOT NULL,
data json NOT NULL
);
CREATE TABLE db_block (
blockid varchar(36) PRIMARY KEY,
tabid varchar(36) NOT NULL, -- the tab this block belongs to
oid varchar(36) PRIMARY KEY,
version int NOT NULL,
data json NOT NULL
);
+11
View File
@@ -9,6 +9,7 @@ import { Workspace } from "@/app/workspace/workspace";
import { globalStore, atoms } from "@/store/global";
import "../../public/style.less";
import { CenteredDiv } from "./element/quickelems";
const App = () => {
return (
@@ -19,6 +20,16 @@ const App = () => {
};
const AppInner = () => {
const client = jotai.useAtomValue(atoms.client);
const windowData = jotai.useAtomValue(atoms.waveWindow);
if (client == null || windowData == null) {
return (
<div className="mainapp">
<div className="titlebar"></div>
<CenteredDiv>invalid configuration, client or window was not loaded</CenteredDiv>
</div>
);
}
return (
<div className="mainapp">
<div className="titlebar"></div>
+7 -6
View File
@@ -3,8 +3,7 @@
import * as React from "react";
import * as jotai from "jotai";
import { atoms, blockDataMap, removeBlockFromTab } from "@/store/global";
import * as WOS from "@/store/wos";
import { TerminalView } from "@/app/view/term";
import { PreviewView } from "@/app/view/preview";
import { PlotView } from "@/app/view/plotview";
@@ -17,7 +16,7 @@ const Block = ({ tabId, blockId }: { tabId: string; blockId: string }) => {
const [dims, setDims] = React.useState({ width: 0, height: 0 });
function handleClose() {
removeBlockFromTab(tabId, blockId);
WOS.DeleteBlock(blockId);
}
React.useEffect(() => {
@@ -31,10 +30,12 @@ const Block = ({ tabId, blockId }: { tabId: string; blockId: string }) => {
setDims({ width: newWidth, height: newHeight });
}
}, [blockRef.current]);
let blockElem: JSX.Element = null;
const blockAtom = blockDataMap.get(blockId);
const blockData = jotai.useAtomValue(blockAtom);
if (blockData.view === "term") {
const [blockData, blockDataLoading] = WOS.useWaveObjectValue<Block>(WOS.makeORef("block", blockId));
if (blockDataLoading) {
blockElem = <CenteredDiv>Loading...</CenteredDiv>;
} else if (blockData.view === "term") {
blockElem = <TerminalView blockId={blockId} />;
} else if (blockData.view === "preview") {
blockElem = <PreviewView blockId={blockId} />;
+5 -1
View File
@@ -3,6 +3,10 @@
import "./quickelems.less";
function CenteredLoadingDiv() {
return <CenteredDiv>loading...</CenteredDiv>;
}
function CenteredDiv({ children }: { children: React.ReactNode }) {
return (
<div className="centered-div">
@@ -11,4 +15,4 @@ function CenteredDiv({ children }: { children: React.ReactNode }) {
);
}
export { CenteredDiv as CenteredDiv };
export { CenteredDiv, CenteredLoadingDiv };
+47 -39
View File
@@ -2,26 +2,56 @@
// SPDX-License-Identifier: Apache-2.0
import * as jotai from "jotai";
import { atomFamily } from "jotai/utils";
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";
import { BlockService } from "@/bindings/blockservice";
import * as WOS from "./wos";
const globalStore = jotai.createStore();
const tabId1 = uuidv4();
const tabArr: TabData[] = [{ name: "Tab 1", tabid: tabId1, blockIds: [] }];
const blockDataMap = new Map<string, jotai.Atom<BlockData>>();
const blockAtomCache = new Map<string, Map<string, jotai.Atom<any>>>();
const urlParams = new URLSearchParams(window.location.search);
const globalWindowId = urlParams.get("windowid");
const globalClientId = urlParams.get("clientid");
const windowIdAtom = jotai.atom(null) as jotai.PrimitiveAtom<string>;
const clientIdAtom = jotai.atom(null) as jotai.PrimitiveAtom<string>;
globalStore.set(windowIdAtom, globalWindowId);
globalStore.set(clientIdAtom, globalClientId);
const uiContextAtom = jotai.atom((get) => {
const windowData = get(windowDataAtom);
const uiContext: UIContext = {
windowid: get(atoms.windowId),
activetabid: windowData.activetabid,
};
return uiContext;
}) as jotai.Atom<UIContext>;
const clientAtom: jotai.Atom<Client> = jotai.atom((get) => {
const clientId = get(clientIdAtom);
if (clientId == null) {
return null;
}
return WOS.getStaticObjectValue(WOS.makeORef("client", clientId), get);
});
const windowDataAtom: jotai.Atom<WaveWindow> = jotai.atom((get) => {
const windowId = get(windowIdAtom);
if (windowId == null) {
return null;
}
return WOS.getStaticObjectValue(WOS.makeORef("window", windowId), get);
});
const workspaceAtom: jotai.Atom<Workspace> = jotai.atom((get) => {
const windowData = get(windowDataAtom);
if (windowData == null) {
return null;
}
return WOS.getStaticObjectValue(WOS.makeORef("workspace", windowData.workspaceid), get);
});
const atoms = {
activeTabId: jotai.atom<string>(tabId1),
tabsAtom: jotai.atom<TabData[]>(tabArr),
blockDataMap: blockDataMap,
// initialized in wave.ts (will not be null inside of application)
windowId: windowIdAtom,
clientId: clientIdAtom,
uiContext: uiContextAtom,
client: clientAtom,
waveWindow: windowDataAtom,
workspace: workspaceAtom,
};
type SubjectWithRef<T> = rxjs.Subject<T> & { refCount: number; release: () => void };
@@ -60,19 +90,7 @@ Events.On("block:ptydata", (event: any) => {
subject.next(data);
});
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);
}
const blockAtomCache = new Map<string, Map<string, jotai.Atom<any>>>();
function useBlockAtom<T>(blockId: string, name: string, makeFn: () => jotai.Atom<T>): jotai.Atom<T> {
let blockCache = blockAtomCache.get(blockId);
@@ -84,19 +102,9 @@ function useBlockAtom<T>(blockId: string, name: string, makeFn: () => jotai.Atom
if (atom == null) {
atom = makeFn();
blockCache.set(name, atom);
console.log("New BlockAtom", blockId, name);
}
return atom as jotai.Atom<T>;
}
function removeBlockFromTab(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 = tab.blockIds.filter((id) => id !== blockId);
});
globalStore.set(atoms.tabsAtom, newTabArr);
removeBlock(blockId);
BlockService.CloseBlock(blockId);
}
export { globalStore, atoms, getBlockSubject, addBlockIdToTab, blockDataMap, useBlockAtom, removeBlockFromTab };
export { globalStore, atoms, getBlockSubject, useBlockAtom, WOS };
+287
View File
@@ -0,0 +1,287 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
// WaveObjectStore
import * as React from "react";
import * as jotai from "jotai";
import { Events } from "@wailsio/runtime";
import { Call as $Call } from "@wailsio/runtime";
import { globalStore, atoms } from "./global";
type WaveObjectDataItemType<T extends WaveObj> = {
value: T;
loading: boolean;
};
type WaveObjectValue<T extends WaveObj> = {
pendingPromise: Promise<T>;
dataAtom: jotai.PrimitiveAtom<WaveObjectDataItemType<T>>;
refCount: number;
holdTime: number;
};
function splitORef(oref: string): [string, string] {
let parts = oref.split(":");
if (parts.length != 2) {
throw new Error("invalid oref");
}
return [parts[0], parts[1]];
}
function isBlank(str: string): boolean {
return str == null || str == "";
}
function isBlankNum(num: number): boolean {
return num == null || isNaN(num) || num == 0;
}
function isValidWaveObj(val: WaveObj): boolean {
if (val == null) {
return false;
}
if (isBlank(val.otype) || isBlank(val.oid) || isBlankNum(val.version)) {
return false;
}
return true;
}
function makeORef(otype: string, oid: string): string {
if (isBlank(otype) || isBlank(oid)) {
return null;
}
return `${otype}:${oid}`;
}
function GetObject<T>(oref: string): Promise<T> {
let prtn = $Call.ByName(
"github.com/wavetermdev/thenextwave/pkg/service/objectservice.ObjectService.GetObject",
oref
);
return prtn;
}
const waveObjectValueCache = new Map<string, WaveObjectValue<any>>();
function clearWaveObjectCache() {
waveObjectValueCache.clear();
}
const defaultHoldTime = 5000; // 5-seconds
function createWaveValueObject<T extends WaveObj>(oref: string, shouldFetch: boolean): WaveObjectValue<T> {
const wov = { pendingPromise: null, dataAtom: null, refCount: 0, holdTime: Date.now() + 5000 };
wov.dataAtom = jotai.atom({ value: null, loading: true });
if (!shouldFetch) {
return wov;
}
let startTs = Date.now();
let localPromise = GetObject<T>(oref);
wov.pendingPromise = localPromise;
localPromise.then((val) => {
if (wov.pendingPromise != localPromise) {
return;
}
const [otype, oid] = splitORef(oref);
if (val != null) {
if (val["otype"] != otype) {
throw new Error("GetObject returned wrong type");
}
if (val["oid"] != oid) {
throw new Error("GetObject returned wrong id");
}
}
wov.pendingPromise = null;
globalStore.set(wov.dataAtom, { value: val, loading: false });
console.log("WaveObj resolved", oref, Date.now() - startTs + "ms");
});
return wov;
}
function loadAndPinWaveObject<T>(oref: string): Promise<T> {
let wov = waveObjectValueCache.get(oref);
if (wov == null) {
wov = createWaveValueObject(oref, true);
waveObjectValueCache.set(oref, wov);
}
wov.refCount++;
if (wov.pendingPromise == null) {
const dataValue = globalStore.get(wov.dataAtom);
return Promise.resolve(dataValue.value);
}
return wov.pendingPromise;
}
function useWaveObjectValueWithSuspense<T>(oref: string): T {
let wov = waveObjectValueCache.get(oref);
if (wov == null) {
wov = createWaveValueObject(oref, true);
waveObjectValueCache.set(oref, wov);
}
React.useEffect(() => {
wov.refCount++;
return () => {
wov.refCount--;
};
}, [oref]);
const dataValue = jotai.useAtomValue(wov.dataAtom);
if (dataValue.loading) {
throw wov.pendingPromise;
}
return dataValue.value;
}
function useWaveObjectValue<T>(oref: string): [T, boolean] {
let wov = waveObjectValueCache.get(oref);
if (wov == null) {
wov = createWaveValueObject(oref, true);
waveObjectValueCache.set(oref, wov);
}
React.useEffect(() => {
wov.refCount++;
return () => {
wov.refCount--;
};
}, [oref]);
const atomVal = jotai.useAtomValue(wov.dataAtom);
return [atomVal.value, atomVal.loading];
}
function useWaveObject<T>(oref: string): [T, boolean, (T) => void] {
let wov = waveObjectValueCache.get(oref);
if (wov == null) {
wov = createWaveValueObject(oref, true);
waveObjectValueCache.set(oref, wov);
}
React.useEffect(() => {
wov.refCount++;
return () => {
wov.refCount--;
};
}, [oref]);
const [atomVal, setAtomVal] = jotai.useAtom(wov.dataAtom);
const simpleSet = (val: T) => {
setAtomVal({ value: val, loading: false });
};
return [atomVal.value, atomVal.loading, simpleSet];
}
function updateWaveObject(update: WaveObjUpdate) {
if (update == null) {
return;
}
let oref = makeORef(update.otype, update.oid);
let wov = waveObjectValueCache.get(oref);
if (wov == null) {
wov = createWaveValueObject(oref, false);
waveObjectValueCache.set(oref, wov);
}
if (update.updatetype == "delete") {
console.log("WaveObj deleted", oref);
globalStore.set(wov.dataAtom, { value: null, loading: false });
} else {
if (!isValidWaveObj(update.obj)) {
console.log("invalid wave object update", update);
return;
}
let curValue: WaveObjectDataItemType<WaveObj> = globalStore.get(wov.dataAtom);
if (curValue.value != null && curValue.value.version >= update.obj.version) {
return;
}
console.log("WaveObj updated", oref);
globalStore.set(wov.dataAtom, { value: update.obj, loading: false });
}
wov.holdTime = Date.now() + defaultHoldTime;
return;
}
function updateWaveObjects(vals: WaveObjUpdate[]) {
for (let val of vals) {
updateWaveObject(val);
}
}
function cleanWaveObjectCache() {
let now = Date.now();
for (let [oref, wov] of waveObjectValueCache) {
if (wov.refCount == 0 && wov.holdTime < now) {
waveObjectValueCache.delete(oref);
}
}
}
Events.On("waveobj:update", (event: any) => {
const data: WaveObjUpdate[] = event?.data;
if (data == null) {
return;
}
if (!Array.isArray(data)) {
console.log("invalid waveobj:update, not an array", data);
return;
}
if (data.length == 0) {
return;
}
updateWaveObjects(data);
});
function wrapObjectServiceCall<T>(fnName: string, ...args: any[]): Promise<T> {
const uiContext = globalStore.get(atoms.uiContext);
const startTs = Date.now();
let prtn = $Call.ByName(
"github.com/wavetermdev/thenextwave/pkg/service/objectservice.ObjectService." + fnName,
uiContext,
...args
);
prtn = prtn.then((val) => {
console.log("Call", fnName, Date.now() - startTs + "ms");
if (val.updates) {
updateWaveObjects(val.updates);
}
return val;
});
return prtn;
}
function getStaticObjectValue<T>(oref: string, getFn: jotai.Getter): T {
let wov = waveObjectValueCache.get(oref);
if (wov == null) {
return null;
}
const atomVal = getFn(wov.dataAtom);
return atomVal.value;
}
export function AddTabToWorkspace(tabName: string, activateTab: boolean): Promise<{ tabId: string }> {
return wrapObjectServiceCall("AddTabToWorkspace", tabName, activateTab);
}
export function SetActiveTab(tabId: string): Promise<void> {
return wrapObjectServiceCall("SetActiveTab", tabId);
}
export function CreateBlock(blockDef: BlockDef, rtOpts: RuntimeOpts): Promise<{ blockId: string }> {
return wrapObjectServiceCall("CreateBlock", blockDef, rtOpts);
}
export function DeleteBlock(blockId: string): Promise<void> {
return wrapObjectServiceCall("DeleteBlock", blockId);
}
export function CloseTab(tabId: string): Promise<void> {
return wrapObjectServiceCall("CloseTab", tabId);
}
export {
makeORef,
useWaveObject,
useWaveObjectValue,
useWaveObjectValueWithSuspense,
loadAndPinWaveObject,
clearWaveObjectCache,
updateWaveObject,
updateWaveObjects,
cleanWaveObjectCache,
getStaticObjectValue,
};
+13 -5
View File
@@ -5,21 +5,29 @@ import * as React from "react";
import * as jotai from "jotai";
import { Block } from "@/app/block/block";
import { atoms } from "@/store/global";
import * as WOS from "@/store/wos";
import "./tab.less";
import { CenteredDiv, CenteredLoadingDiv } from "../element/quickelems";
const TabContent = ({ tabId }: { tabId: string }) => {
const tabs = jotai.useAtomValue(atoms.tabsAtom);
const tabData = tabs.find((tab) => tab.tabid === tabId);
const [tabData, tabLoading] = WOS.useWaveObjectValue<Tab>(WOS.makeORef("tab", tabId));
if (tabLoading) {
return <CenteredLoadingDiv />;
}
if (!tabData) {
return <div className="tabcontent">Tab not found</div>;
return (
<div className="tabcontent">
<CenteredDiv>Tab Not Found</CenteredDiv>
</div>
);
}
return (
<div className="tabcontent">
{tabData.blockIds.map((blockId: string) => {
{tabData.blockids.map((blockId: string) => {
return (
<div key={blockId} className="block-container">
<Block tabId={tabId} blockId={blockId} />
<Block key={blockId} tabId={tabId} blockId={blockId} />
</div>
);
})}
+12 -3
View File
@@ -3,14 +3,16 @@
import * as React from "react";
import * as jotai from "jotai";
import { atoms, blockDataMap, useBlockAtom } from "@/store/global";
import { atoms, useBlockAtom } from "@/store/global";
import { Markdown } from "@/element/markdown";
import { FileService, FileInfo, FullFile } from "@/bindings/fileservice";
import * as util from "@/util/util";
import { CenteredDiv } from "../element/quickelems";
import { DirectoryTable } from "@/element/directorytable";
import * as WOS from "@/store/wos";
import "./view.less";
import { first } from "rxjs";
const MaxFileSize = 1024 * 1024 * 10; // 10MB
@@ -61,10 +63,17 @@ function DirectoryPreview({ contentAtom }: { contentAtom: jotai.Atom<Promise<str
}
function PreviewView({ blockId }: { blockId: string }) {
const blockDataAtom: jotai.Atom<BlockData> = blockDataMap.get(blockId);
const blockData = WOS.useWaveObjectValueWithSuspense<Block>(WOS.makeORef("block", blockId));
if (blockData == null) {
return (
<div className="view-preview">
<CenteredDiv>Block Not Found</CenteredDiv>
</div>
);
}
const fileNameAtom = useBlockAtom(blockId, "preview:filename", () =>
jotai.atom<string>((get) => {
return get(blockDataAtom)?.meta?.file;
return blockData?.meta?.file;
})
);
const statFileAtom = useBlockAtom(blockId, "preview:statfile", () =>
+15
View File
@@ -55,9 +55,24 @@
height: 100%;
border-right: 1px solid var(--border-color);
cursor: pointer;
position: relative;
&.active {
background-color: var(--highlight-bg-color);
}
&.active:hover .tab-close {
display: block;
}
.tab-close {
position: absolute;
display: none;
padding: 5px;
right: 2px;
top: 5px;
cursor: pointer;
}
}
.tab-add {
+47 -32
View File
@@ -5,37 +5,46 @@ 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 { BlockService } from "@/bindings/blockservice";
import { atoms } from "@/store/global";
import * as WOS from "@/store/wos";
import { CenteredLoadingDiv, CenteredDiv } from "../element/quickelems";
import "./workspace.less";
function Tab({ tab }: { tab: TabData }) {
const [activeTab, setActiveTab] = jotai.useAtom(atoms.activeTabId);
function Tab({ tabId }: { tabId: string }) {
const windowData = jotai.useAtomValue(atoms.waveWindow);
const [tabData, tabLoading] = WOS.useWaveObjectValue<Tab>(WOS.makeORef("tab", tabId));
function setActiveTab() {
WOS.SetActiveTab(tabId);
}
function handleCloseTab() {
WOS.CloseTab(tabId);
}
return (
<div className={clsx("tab", { active: activeTab === tab.tabid })} onClick={() => setActiveTab(tab.tabid)}>
{tab.name}
<div
className={clsx("tab", { active: tabData != null && windowData.activetabid === tabData.oid })}
onClick={() => setActiveTab()}
>
<div className="tab-close" onClick={() => handleCloseTab()}>
<div>
<i className="fa fa-solid fa-xmark" />
</div>
</div>
{tabData?.name ?? "..."}
</div>
);
}
function TabBar() {
const [tabData, setTabData] = jotai.useAtom(atoms.tabsAtom);
const [activeTab, setActiveTab] = jotai.useAtom(atoms.activeTabId);
const tabs = jotai.useAtomValue(atoms.tabsAtom);
function TabBar({ workspace }: { workspace: Workspace }) {
function handleAddTab() {
const newTabId = uuidv4();
const newTabName = "Tab " + (tabData.length + 1);
setTabData([...tabData, { name: newTabName, tabid: newTabId, blockIds: [] }]);
setActiveTab(newTabId);
const newTabName = `Tab-${workspace.tabids.length + 1}`;
WOS.AddTabToWorkspace(newTabName, true);
}
const tabIds = workspace?.tabids ?? [];
return (
<div className="tab-bar">
{tabs.map((tab, idx) => {
return <Tab key={idx} tab={tab} />;
{tabIds.map((tabid, idx) => {
return <Tab key={idx} tabId={tabid} />;
})}
<div className="tab-add" onClick={() => handleAddTab()}>
<i className="fa fa-solid fa-plus fa-fw" />
@@ -45,14 +54,12 @@ function TabBar() {
}
function Widgets() {
const activeTabId = jotai.useAtomValue(atoms.activeTabId);
const windowData = jotai.useAtomValue(atoms.waveWindow);
const activeTabId = windowData.activetabid;
async function createBlock(blockDef: BlockDef) {
const rtOpts = { termsize: { rows: 25, cols: 80 } };
const rtnBlock: BlockData = (await BlockService.CreateBlock(blockDef, rtOpts)) as BlockData;
const newBlockAtom = jotai.atom(rtnBlock);
blockDataMap.set(rtnBlock.blockid, newBlockAtom);
addBlockIdToTab(activeTabId, rtnBlock.blockid);
const rtOpts: RuntimeOpts = { termsize: { rows: 25, cols: 80 } };
await WOS.CreateBlock(blockDef, rtOpts);
}
async function clickTerminal() {
@@ -72,7 +79,7 @@ function Widgets() {
}
async function clickPlot() {
const plotDef = {
const plotDef: BlockDef = {
view: "plot",
};
createBlock(plotDef);
@@ -105,17 +112,25 @@ function Widgets() {
);
}
function Workspace() {
const activeTabId = jotai.useAtomValue(atoms.activeTabId);
function WorkspaceElem() {
const windowData = jotai.useAtomValue(atoms.waveWindow);
const activeTabId = windowData?.activetabid;
const ws = jotai.useAtomValue(atoms.workspace);
return (
<div className="workspace">
<TabBar />
<TabBar workspace={ws} />
<div className="workspace-tabcontent">
<TabContent key={activeTabId} tabId={activeTabId} />
<Widgets />
{activeTabId == "" ? (
<CenteredDiv>No Active Tab</CenteredDiv>
) : (
<>
<TabContent key={windowData.workspaceid} tabId={activeTabId} />
<Widgets />
</>
)}
</div>
</div>
);
}
export { Workspace };
export { WorkspaceElem as Workspace };
+85 -16
View File
@@ -2,21 +2,42 @@
// SPDX-License-Identifier: Apache-2.0
declare global {
type MetaDataType = Record<string, any>;
type TabData = {
name: string;
tabid: string;
blockIds: string[];
type UIContext = {
windowid: string;
activetabid: string;
};
type BlockData = {
blockid: string;
type ORef = {
otype: string;
oid: string;
};
type WaveObj = {
otype: string;
oid: string;
version: number;
};
type WaveObjUpdate = {
updatetype: "update" | "delete";
otype: string;
oid: string;
obj?: WaveObj;
};
type Block = WaveObj & {
blockdef: BlockDef;
controller: string;
controllerstatus: string;
view: string;
meta?: MetaDataType;
meta?: { [key: string]: any };
runtimeopts?: RuntimeOpts;
};
type BlockDef = {
controller?: string;
view?: string;
files?: { [key: string]: FileDef };
meta?: { [key: string]: any };
};
type FileDef = {
@@ -24,14 +45,62 @@ declare global {
path?: string;
url?: string;
content?: string;
meta?: MetaDataType;
meta?: { [key: string]: any };
};
type BlockDef = {
controller?: string;
view: string;
files?: FileDef[];
meta?: MetaDataType;
type TermSize = {
rows: number;
cols: number;
};
type Client = {
otype: string;
oid: string;
version: number;
mainwindowid: string;
};
type Tab = {
otype: string;
oid: string;
version: number;
name: string;
blockids: string[];
};
type Point = {
x: number;
y: number;
};
type WinSize = {
width: number;
height: number;
};
type Workspace = {
otype: string;
oid: string;
version: number;
name: string;
tabids: string[];
};
type RuntimeOpts = {
termsize?: TermSize;
winsize?: WinSize;
};
type WaveWindow = {
otype: string;
oid: string;
version: number;
workspaceid: string;
activetabid: string;
activeblockmap: { [key: string]: string };
pos: Point;
winsize: WinSize;
lastfocusts: number;
};
}
+21 -1
View File
@@ -5,14 +5,34 @@ import * as React from "react";
import { createRoot } from "react-dom/client";
import { App } from "./app/app";
import { loadFonts } from "./util/fontutil";
import { ClientService } from "@/bindings/clientservice";
import { Client } from "@/gopkg/wstore";
import { globalStore, atoms } from "@/store/global";
import * as WOS from "@/store/wos";
import * as wailsRuntime from "@wailsio/runtime";
import * as wstore from "@/gopkg/wstore";
import * as gdata from "@/store/global";
import { immerable } from "immer";
const urlParams = new URLSearchParams(window.location.search);
const windowId = urlParams.get("windowid");
const clientId = urlParams.get("clientid");
loadFonts();
document.addEventListener("DOMContentLoaded", () => {
console.log("Wave Starting");
document.addEventListener("DOMContentLoaded", async () => {
console.log("DOMContentLoaded");
// ensures client/window are loaded into the cache before rendering
await WOS.loadAndPinWaveObject<Client>(WOS.makeORef("client", clientId));
const waveWindow = await WOS.loadAndPinWaveObject<WaveWindow>(WOS.makeORef("window", windowId));
await WOS.loadAndPinWaveObject<Workspace>(WOS.makeORef("workspace", waveWindow.workspaceid));
let reactElem = React.createElement(App, null, null);
let elem = document.getElementById("main");
let root = createRoot(elem);
document.fonts.ready.then(() => {
console.log("Wave First Render");
root.render(reactElem);
});
});
+1
View File
@@ -10,6 +10,7 @@ require (
github.com/google/uuid v1.4.0
github.com/jmoiron/sqlx v1.4.0
github.com/mattn/go-sqlite3 v1.14.22
github.com/mitchellh/mapstructure v1.5.0
github.com/sawka/txwrap v0.2.0
github.com/wailsapp/wails/v3 v3.0.0-alpha.0
github.com/wavetermdev/waveterm/wavesrv v0.0.0-20240508181017-d07068c09d94
+2
View File
@@ -91,6 +91,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI=
github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M=
github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4=
+52 -9
View File
@@ -6,17 +6,23 @@ package main
// Note, main.go needs to be in the root of the project for the go:embed directive to work.
import (
"context"
"embed"
"fmt"
"log"
"net/http"
"runtime"
"strings"
"time"
"github.com/wavetermdev/thenextwave/pkg/blockstore"
"github.com/wavetermdev/thenextwave/pkg/eventbus"
"github.com/wavetermdev/thenextwave/pkg/service/blockservice"
"github.com/wavetermdev/thenextwave/pkg/service/clientservice"
"github.com/wavetermdev/thenextwave/pkg/service/fileservice"
"github.com/wavetermdev/thenextwave/pkg/service/objectservice"
"github.com/wavetermdev/thenextwave/pkg/wavebase"
"github.com/wavetermdev/thenextwave/pkg/wstore"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/events"
@@ -32,10 +38,10 @@ func createAppMenu(app *application.App) *application.Menu {
menu := application.NewMenu()
menu.AddRole(application.AppMenu)
fileMenu := menu.AddSubmenu("File")
newWindow := fileMenu.Add("New Window")
newWindow.OnClick(func(appContext *application.Context) {
createWindow(app)
})
// newWindow := fileMenu.Add("New Window")
// newWindow.OnClick(func(appContext *application.Context) {
// createWindow(app)
// })
closeWindow := fileMenu.Add("Close Window")
closeWindow.OnClick(func(appContext *application.Context) {
app.CurrentWindow().Close()
@@ -47,7 +53,11 @@ func createAppMenu(app *application.App) *application.Menu {
return menu
}
func createWindow(app *application.App) {
func createWindow(windowData *wstore.Window, app *application.App) {
client, err := wstore.DBGetSingleton[*wstore.Client](context.Background())
if err != nil {
panic(fmt.Errorf("error getting client data: %w", err))
}
window := app.NewWebviewWindowWithOptions(application.WebviewWindowOptions{
Title: "Wave Terminal",
Mac: application.MacWindow{
@@ -55,13 +65,18 @@ func createWindow(app *application.App) {
Backdrop: application.MacBackdropTranslucent,
TitleBar: application.MacTitleBarHiddenInset,
},
BackgroundColour: application.NewRGB(27, 38, 54),
URL: "/public/index.html",
BackgroundColour: application.NewRGB(0, 0, 0),
URL: "/public/index.html?windowid=" + windowData.OID + "&clientid=" + client.OID,
X: windowData.Pos.X,
Y: windowData.Pos.Y,
Width: windowData.WinSize.Width,
Height: windowData.WinSize.Height,
})
eventbus.RegisterWailsWindow(window)
eventbus.RegisterWailsWindow(window, windowData.OID)
window.On(events.Common.WindowClosing, func(event *application.WindowEvent) {
eventbus.UnregisterWailsWindow(window.ID())
})
window.Show()
}
type waveAssetHandler struct {
@@ -104,6 +119,16 @@ func main() {
log.Printf("error initializing blockstore: %v\n", err)
return
}
err = wstore.InitWStore()
if err != nil {
log.Printf("error initializing wstore: %v\n", err)
return
}
err = wstore.EnsureInitialData()
if err != nil {
log.Printf("error ensuring initial data: %v\n", err)
return
}
app := application.New(application.Options{
Name: "NextWave",
@@ -111,6 +136,8 @@ func main() {
Services: []application.Service{
application.NewService(&fileservice.FileService{}),
application.NewService(&blockservice.BlockService{}),
application.NewService(&clientservice.ClientService{}),
application.NewService(&objectservice.ObjectService{}),
},
Icon: appIcon,
Assets: application.AssetOptions{
@@ -124,7 +151,23 @@ func main() {
app.SetMenu(menu)
eventbus.RegisterWailsApp(app)
createWindow(app)
setupCtx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second)
defer cancelFn()
client, err := wstore.DBGetSingleton[*wstore.Client](setupCtx)
if err != nil {
log.Printf("error getting client data: %v\n", err)
return
}
mainWindow, err := wstore.DBGet[*wstore.Window](setupCtx, client.MainWindowId)
if err != nil {
log.Printf("error getting main window: %v\n", err)
return
}
if mainWindow == nil {
log.Printf("no main window data\n")
return
}
createWindow(mainWindow, app)
eventbus.Start()
defer eventbus.Shutdown()
+74 -64
View File
@@ -4,15 +4,16 @@
package blockcontroller
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
"sync"
"time"
"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"
@@ -24,6 +25,8 @@ const (
BlockController_Cmd = "cmd"
)
const DefaultTimeout = 2 * time.Second
var globalLock = &sync.Mutex{}
var blockControllerMap = make(map[string]*BlockController)
@@ -32,11 +35,18 @@ type BlockController struct {
BlockId string
BlockDef *wstore.BlockDef
InputCh chan BlockCommand
Status string
ShellProc *shellexec.ShellProc
ShellInputCh chan *InputCommand
}
func (bc *BlockController) WithLock(f func()) {
bc.Lock.Lock()
defer bc.Lock.Unlock()
f()
}
func jsonDeepCopy(val map[string]any) (map[string]any, error) {
barr, err := json.Marshal(val)
if err != nil {
@@ -50,38 +60,6 @@ func jsonDeepCopy(val map[string]any) (map[string]any, error) {
return rtn, nil
}
func CreateBlock(bdef *wstore.BlockDef, rtOpts *wstore.RuntimeOpts) (*wstore.Block, error) {
blockId := uuid.New().String()
blockData := &wstore.Block{
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)
}
wstore.BlockMap.Set(blockId, blockData)
if blockData.Controller != "" {
StartBlockController(blockId, blockData)
}
return blockData, nil
}
func CloseBlock(blockId string) {
bc := GetBlockController(blockId)
if bc == nil {
return
}
bc.Close()
close(bc.InputCh)
wstore.BlockMap.Delete(blockId)
}
func (bc *BlockController) setShellProc(shellProc *shellexec.ShellProc) error {
bc.Lock.Lock()
defer bc.Lock.Unlock()
@@ -179,10 +157,10 @@ func (bc *BlockController) DoRunShellCommand(rc *RunShellOpts) error {
func (bc *BlockController) Run(bdata *wstore.Block) {
defer func() {
bdata.WithLock(func() {
bc.WithLock(func() {
// if the controller had an error status, don't change it
if bdata.ControllerStatus == "running" {
bdata.ControllerStatus = "done"
if bc.Status == "running" {
bc.Status = "done"
}
})
eventbus.SendEvent(application.WailsEvent{
@@ -193,8 +171,8 @@ func (bc *BlockController) Run(bdata *wstore.Block) {
defer globalLock.Unlock()
delete(blockControllerMap, bc.BlockId)
}()
bdata.WithLock(func() {
bdata.ControllerStatus = "running"
bc.WithLock(func() {
bc.Status = "running"
})
// only controller is "shell" for now
@@ -218,26 +196,42 @@ func (bc *BlockController) Run(bdata *wstore.Block) {
}
}
func StartBlockController(blockId string, bdata *wstore.Block) {
if bdata.Controller != BlockController_Shell {
log.Printf("unknown controller %q\n", bdata.Controller)
bdata.WithLock(func() {
bdata.ControllerStatus = "error"
})
return
func StartBlockController(ctx context.Context, blockId string) error {
blockData, err := wstore.DBMustGet[*wstore.Block](ctx, blockId)
if err != nil {
return fmt.Errorf("error getting block: %w", err)
}
if blockData.Controller == "" {
// nothing to start
return nil
}
if blockData.Controller != BlockController_Shell {
return fmt.Errorf("unknown controller %q", blockData.Controller)
}
globalLock.Lock()
defer globalLock.Unlock()
if _, ok := blockControllerMap[blockId]; ok {
return
// already running
return nil
}
bc := &BlockController{
Lock: &sync.Mutex{},
BlockId: blockId,
Status: "init",
InputCh: make(chan BlockCommand),
}
blockControllerMap[blockId] = bc
go bc.Run(bdata)
go bc.Run(blockData)
return nil
}
func StopBlockController(blockId string) {
bc := GetBlockController(blockId)
if bc == nil {
return
}
bc.Close()
close(bc.InputCh)
}
func GetBlockController(blockId string) *BlockController {
@@ -246,31 +240,47 @@ func GetBlockController(blockId string) *BlockController {
return blockControllerMap[blockId]
}
func ProcessStaticCommand(blockId string, cmdGen BlockCommand) {
func ProcessStaticCommand(blockId string, cmdGen BlockCommand) error {
ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout)
defer cancelFn()
switch cmd := cmdGen.(type) {
case *MessageCommand:
log.Printf("MESSAGE: %s | %q\n", blockId, cmd.Message)
return nil
case *SetViewCommand:
log.Printf("SETVIEW: %s | %q\n", blockId, cmd.View)
block := wstore.BlockMap.Get(blockId)
if block != nil {
block.WithLock(func() {
block.View = cmd.View
})
block, err := wstore.DBGet[*wstore.Block](ctx, blockId)
if err != nil {
return fmt.Errorf("error getting block: %w", err)
}
block.View = cmd.View
err = wstore.DBUpdate(ctx, block)
if err != nil {
return fmt.Errorf("error updating block: %w", err)
}
return nil
case *SetMetaCommand:
log.Printf("SETMETA: %s | %v\n", blockId, cmd.Meta)
block := wstore.BlockMap.Get(blockId)
if block != nil {
block.WithLock(func() {
for k, v := range cmd.Meta {
if v == nil {
delete(block.Meta, k)
continue
}
block.Meta[k] = v
}
})
block, err := wstore.DBGet[*wstore.Block](ctx, blockId)
if err != nil {
return fmt.Errorf("error getting block: %w", err)
}
if block == nil {
return nil
}
for k, v := range cmd.Meta {
if v == nil {
delete(block.Meta, k)
continue
}
block.Meta[k] = v
}
err = wstore.DBUpdate(ctx, block)
if err != nil {
return fmt.Errorf("error updating block: %w", err)
}
return nil
default:
return fmt.Errorf("unknown command type %T", cmdGen)
}
}

Some files were not shown because too many files have changed in this diff Show More