feat: integrate plots into new block setup

This commit is contained in:
Sylvia Crowe
2024-05-16 13:54:15 -07:00
22 changed files with 724 additions and 263 deletions
+2 -33
View File
@@ -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 (
<div className={clsx("tab", { active: activeTab === tab.tabid })} onClick={() => setActiveTab(tab.tabid)}>
{tab.name}
</div>
);
};
const TabBar = () => {
const [activeTab, setActiveTab] = jotai.useAtom(atoms.activeTabId);
const tabs = jotai.useAtomValue(atoms.tabsAtom);
return (
<div className="tab-bar">
{tabs.map((tab, idx) => {
return <Tab key={idx} tab={tab} />;
})}
</div>
);
};
const Workspace = () => {
const activeTabId = jotai.useAtomValue(atoms.activeTabId);
return (
<div className="workspace">
<TabBar />
<TabContent tabId={activeTabId} />
</div>
);
};
const AppInner = () => {
return (
<div className="mainapp">
+13
View File
@@ -13,12 +13,25 @@
.block-header {
display: flex;
flex-direction: row;
flex-shrink: 0;
height: 30px;
width: 100%;
align-items: center;
justify-content: center;
background-color: var(--panel-bg-color);
.block-header-text {
padding-left: 5px;
}
.close-button {
font-size: 12px;
padding-right: 5px;
&:hover {
cursor: pointer;
}
}
}
.block-content {
+13 -4
View File
@@ -3,7 +3,7 @@
import * as React from "react";
import * as jotai from "jotai";
import { atoms } from "@/store/global";
import { atoms, blockDataMap, removeBlockFromTab } from "@/store/global";
import { TerminalView } from "@/app/view/term";
import { PreviewView } from "@/app/view/preview";
@@ -12,9 +12,14 @@ import { CenteredLoadingDiv } from "@/element/quickelems";
import "./block.less";
const Block = ({ blockId }: { blockId: string }) => {
const Block = ({ tabId, blockId }: { tabId: string; blockId: string }) => {
const blockRef = React.useRef<HTMLDivElement>(null);
const [dims, setDims] = React.useState({ width: 0, height: 0 });
function handleClose() {
removeBlockFromTab(tabId, blockId);
}
React.useEffect(() => {
if (!blockRef.current) {
return;
@@ -27,7 +32,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 = <TerminalView blockId={blockId} />;
@@ -39,9 +44,13 @@ const Block = ({ blockId }: { blockId: string }) => {
return (
<div className="block" ref={blockRef}>
<div key="header" className="block-header">
<div className="text-fixed">
<div className="block-header-text text-fixed">
Block [{blockId.substring(0, 8)}] {dims.width}x{dims.height}
</div>
<div className="flex-spacer" />
<div className="close-button" onClick={() => handleClose()}>
<i className="fa fa-solid fa-xmark-large" />
</div>
</div>
<div key="content" className="block-content">
<React.Suspense fallback={<CenteredLoadingDiv />}>{blockElem}</React.Suspense>
+46 -28
View File
@@ -7,42 +7,21 @@ 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 * as BlockService from "@/bindings/pkg/service/blockservice/BlockService";
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, blockId3] },
{ name: "Tab 2", tabid: tabId2, blockIds: [blockId3] },
];
const blockAtomFamily = atomFamily<string, jotai.Atom<BlockData>>((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: "plot" });
}
return jotai.atom(null);
});
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 atoms = {
activeTabId: jotai.atom<string>(tabId1),
tabsAtom: jotai.atom<TabData[]>(tabArr),
blockAtomFamily,
blockDataMap: blockDataMap,
};
type SubjectWithRef<T> = rxjs.Subject<T> & { refCount: number; release: () => void };
@@ -81,4 +60,43 @@ 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<T>(blockId: string, name: string, makeFn: () => jotai.Atom<T>): jotai.Atom<T> {
let blockCache = blockAtomCache.get(blockId);
if (blockCache == null) {
blockCache = new Map<string, jotai.Atom<any>>();
blockAtomCache.set(blockId, blockCache);
}
let atom = blockCache.get(name);
if (atom == null) {
atom = makeFn();
blockCache.set(name, atom);
}
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 };
+1 -1
View File
@@ -19,7 +19,7 @@ const TabContent = ({ tabId }: { tabId: string }) => {
{tabData.blockIds.map((blockId: string) => {
return (
<div key={blockId} className="block-container">
<Block blockId={blockId} />
<Block tabId={tabId} blockId={blockId} />
</div>
);
})}
+48 -25
View File
@@ -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<Promise<string>> }) => {
const readmeText = jotai.useAtomValue(contentAtom);
return (
<div className="view-preview view-preview-markdown">
<Markdown text={readmeText} />
@@ -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 <MarkdownPreview blockData={blockData} />;
const blockDataAtom: jotai.Atom<BlockData> = blockDataMap.get(blockId);
const fileNameAtom = useBlockAtom(blockId, "preview:filename", () =>
jotai.atom<string>((get) => {
return get(blockDataAtom)?.meta?.file;
})
);
const fullFileAtom = useBlockAtom(blockId, "preview:fullfile", () =>
jotai.atom<Promise<FullFile>>(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<Promise<string>>(async (get) => {
const fullFile = await get(fullFileAtom);
return fullFile?.info?.mimetype;
})
);
const fileContentAtom = useBlockAtom(blockId, "preview:filecontent", () =>
jotai.atom<Promise<string>>(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 <MarkdownPreview contentAtom={fileContentAtom} />;
}
if (mimeType.startsWith("text/")) {
return (
<div className="view-preview view-preview-text">
<pre>{jotai.useAtomValue(fileContentAtom)}</pre>
</div>
);
}
return (
<div className="view-preview">
<div>Preview</div>
<div>Preview ({mimeType})</div>
</div>
);
};
+11 -43
View File
@@ -44,8 +44,7 @@ function getThemeFromCSSVars(el: Element): ITheme {
const TerminalView = ({ blockId }: { blockId: string }) => {
const connectElemRef = React.useRef<HTMLDivElement>(null);
const [term, setTerm] = React.useState<Terminal | null>(null);
const [blockStarted, setBlockStarted] = React.useState<boolean>(false);
const termRef = React.useRef<Terminal>(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 (
<div className="view-term">
<div className="term-header">
<div>Terminal</div>
<Button className="term-inline" onClick={() => handleRunClick()}>
Run `ls`
</Button>
<Button className="term-inline" onClick={() => handleStartTerminalClick()}>
Start Terminal
</Button>
</div>
<div key="conntectElem" className="term-connectelem" ref={connectElemRef}></div>
</div>
);
+10
View File
@@ -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);
}
}
}
+76
View File
@@ -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);
}
}
}
+115
View File
@@ -0,0 +1,115 @@
// 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 (
<div className={clsx("tab", { active: activeTab === tab.tabid })} onClick={() => setActiveTab(tab.tabid)}>
{tab.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 handleAddTab() {
const newTabId = uuidv4();
const newTabName = "Tab " + (tabData.length + 1);
setTabData([...tabData, { name: newTabName, tabid: newTabId, blockIds: [] }]);
setActiveTab(newTabId);
}
return (
<div className="tab-bar">
{tabs.map((tab, idx) => {
return <Tab key={idx} tab={tab} />;
})}
<div className="tab-add" onClick={() => handleAddTab()}>
<i className="fa fa-solid fa-plus fa-fw" />
</div>
</div>
);
}
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() {
const plotDef = {
view: "plot",
};
createBlock(plotDef);
}
return (
<div className="workspace-widgets">
<div className="widget" onClick={() => clickTerminal()}>
<i className="fa fa-solid fa-square-terminal fa-fw" />
</div>
<div className="widget" onClick={() => clickPreview("README.md")}>
<i className="fa fa-solid fa-files fa-fw" />
</div>
<div className="widget" onClick={() => clickPreview("go.mod")}>
<i className="fa fa-solid fa-files fa-fw" />
</div>
<div className="widget" onClick={() => clickPlot()}>
<i className="fa fa-solid fa-chart-simple fa-fw" />
</div>
<div className="widget no-hover">
<i className="fa fa-solid fa-plus fa-fw" />
</div>
</div>
);
}
function Workspace() {
const activeTabId = jotai.useAtomValue(atoms.activeTabId);
return (
<div className="workspace">
<TabBar />
<div className="workspace-tabcontent">
<TabContent key={activeTabId} tabId={activeTabId} />
<Widgets />
</div>
</div>
);
}
export { Workspace };
+36 -1
View File
@@ -2,6 +2,8 @@
// SPDX-License-Identifier: Apache-2.0
declare global {
type MetaDataType = Record<string, any>;
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<string, any>;
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;
};
}