feat: integrate plots into new block setup

This commit is contained in:
Sylvia Crowe 2024-05-16 13:54:15 -07:00
commit afd125a77e
22 changed files with 726 additions and 265 deletions

View File

@ -5,10 +5,10 @@ import * as React from "react";
import * as jotai from "jotai"; import * as jotai from "jotai";
import { Provider } from "jotai"; import { Provider } from "jotai";
import { clsx } from "clsx"; import { clsx } from "clsx";
import { TabContent } from "@/app/tab/tab"; import { Workspace } from "@/app/workspace/workspace";
import { globalStore, atoms } from "@/store/global"; import { globalStore, atoms } from "@/store/global";
import "/public/style.less"; import "../../public/style.less";
const App = () => { const App = () => {
return ( 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 = () => { const AppInner = () => {
return ( return (
<div className="mainapp"> <div className="mainapp">

View File

@ -13,12 +13,25 @@
.block-header { .block-header {
display: flex; display: flex;
flex-direction: row;
flex-shrink: 0; flex-shrink: 0;
height: 30px; height: 30px;
width: 100%; width: 100%;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background-color: var(--panel-bg-color); 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 { .block-content {

View File

@ -3,7 +3,7 @@
import * as React from "react"; import * as React from "react";
import * as jotai from "jotai"; import * as jotai from "jotai";
import { atoms } from "@/store/global"; import { atoms, blockDataMap, removeBlockFromTab } from "@/store/global";
import { TerminalView } from "@/app/view/term"; import { TerminalView } from "@/app/view/term";
import { PreviewView } from "@/app/view/preview"; import { PreviewView } from "@/app/view/preview";
@ -12,9 +12,14 @@ import { CenteredLoadingDiv } from "@/element/quickelems";
import "./block.less"; import "./block.less";
const Block = ({ blockId }: { blockId: string }) => { const Block = ({ tabId, blockId }: { tabId: string; blockId: string }) => {
const blockRef = React.useRef<HTMLDivElement>(null); const blockRef = React.useRef<HTMLDivElement>(null);
const [dims, setDims] = React.useState({ width: 0, height: 0 }); const [dims, setDims] = React.useState({ width: 0, height: 0 });
function handleClose() {
removeBlockFromTab(tabId, blockId);
}
React.useEffect(() => { React.useEffect(() => {
if (!blockRef.current) { if (!blockRef.current) {
return; return;
@ -27,7 +32,7 @@ const Block = ({ blockId }: { blockId: string }) => {
} }
}, [blockRef.current]); }, [blockRef.current]);
let blockElem: JSX.Element = null; let blockElem: JSX.Element = null;
const blockAtom = atoms.blockAtomFamily(blockId); const blockAtom = blockDataMap.get(blockId);
const blockData = jotai.useAtomValue(blockAtom); const blockData = jotai.useAtomValue(blockAtom);
if (blockData.view === "term") { if (blockData.view === "term") {
blockElem = <TerminalView blockId={blockId} />; blockElem = <TerminalView blockId={blockId} />;
@ -39,9 +44,13 @@ const Block = ({ blockId }: { blockId: string }) => {
return ( return (
<div className="block" ref={blockRef}> <div className="block" ref={blockRef}>
<div key="header" className="block-header"> <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} Block [{blockId.substring(0, 8)}] {dims.width}x{dims.height}
</div> </div>
<div className="flex-spacer" />
<div className="close-button" onClick={() => handleClose()}>
<i className="fa fa-solid fa-xmark-large" />
</div>
</div> </div>
<div key="content" className="block-content"> <div key="content" className="block-content">
<React.Suspense fallback={<CenteredLoadingDiv />}>{blockElem}</React.Suspense> <React.Suspense fallback={<CenteredLoadingDiv />}>{blockElem}</React.Suspense>

View File

@ -7,42 +7,21 @@ import { v4 as uuidv4 } from "uuid";
import * as rxjs from "rxjs"; import * as rxjs from "rxjs";
import type { WailsEvent } from "@wailsio/runtime/types/events"; import type { WailsEvent } from "@wailsio/runtime/types/events";
import { Events } from "@wailsio/runtime"; import { Events } from "@wailsio/runtime";
import { produce } from "immer";
import * as BlockService from "@/bindings/pkg/service/blockservice/BlockService";
const globalStore = jotai.createStore(); const globalStore = jotai.createStore();
const tabId1 = uuidv4(); const tabId1 = uuidv4();
const tabId2 = uuidv4();
const blockId1 = uuidv4(); const tabArr: TabData[] = [{ name: "Tab 1", tabid: tabId1, blockIds: [] }];
const blockId2 = uuidv4(); const blockDataMap = new Map<string, jotai.Atom<BlockData>>();
const blockId3 = uuidv4(); const blockAtomCache = new Map<string, Map<string, jotai.Atom<any>>>();
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 atoms = { const atoms = {
activeTabId: jotai.atom<string>(tabId1), activeTabId: jotai.atom<string>(tabId1),
tabsAtom: jotai.atom<TabData[]>(tabArr), tabsAtom: jotai.atom<TabData[]>(tabArr),
blockAtomFamily, blockDataMap: blockDataMap,
}; };
type SubjectWithRef<T> = rxjs.Subject<T> & { refCount: number; release: () => void }; type SubjectWithRef<T> = rxjs.Subject<T> & { refCount: number; release: () => void };
@ -81,4 +60,43 @@ Events.On("block:ptydata", (event: any) => {
subject.next(data); 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 };

View File

@ -19,7 +19,7 @@ const TabContent = ({ tabId }: { tabId: string }) => {
{tabData.blockIds.map((blockId: string) => { {tabData.blockIds.map((blockId: string) => {
return ( return (
<div key={blockId} className="block-container"> <div key={blockId} className="block-container">
<Block blockId={blockId} /> <Block tabId={tabId} blockId={blockId} />
</div> </div>
); );
})} })}

View File

@ -3,33 +3,16 @@
import * as React from "react"; import * as React from "react";
import * as jotai from "jotai"; import * as jotai from "jotai";
import { atoms } from "@/store/global"; import { atoms, blockDataMap, useBlockAtom } from "@/store/global";
import { Markdown } from "@/element/markdown"; import { Markdown } from "@/element/markdown";
import * as FileService from "@/bindings/pkg/service/fileservice/FileService"; import * as FileService from "@/bindings/pkg/service/fileservice/FileService";
import * as util from "@/util/util"; import * as util from "@/util/util";
import { loadable } from "jotai/utils";
import "./view.less"; import "./view.less";
const markdownText = ` const MarkdownPreview = ({ contentAtom }: { contentAtom: jotai.Atom<Promise<string>> }) => {
# Markdown Preview const readmeText = jotai.useAtomValue(contentAtom);
* 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);
return ( return (
<div className="view-preview view-preview-markdown"> <div className="view-preview view-preview-markdown">
<Markdown text={readmeText} /> <Markdown text={readmeText} />
@ -37,14 +20,54 @@ const MarkdownPreview = ({ blockData }: { blockData: BlockData }) => {
); );
}; };
let counter = 0;
const PreviewView = ({ blockId }: { blockId: string }) => { const PreviewView = ({ blockId }: { blockId: string }) => {
const blockData: BlockData = jotai.useAtomValue(atoms.blockAtomFamily(blockId)); const blockDataAtom: jotai.Atom<BlockData> = blockDataMap.get(blockId);
if (blockData.meta?.mimetype === "text/markdown") { const fileNameAtom = useBlockAtom(blockId, "preview:filename", () =>
return <MarkdownPreview blockData={blockData} />; 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 ( return (
<div className="view-preview"> <div className="view-preview">
<div>Preview</div> <div>Preview ({mimeType})</div>
</div> </div>
); );
}; };

View File

@ -44,8 +44,7 @@ function getThemeFromCSSVars(el: Element): ITheme {
const TerminalView = ({ blockId }: { blockId: string }) => { const TerminalView = ({ blockId }: { blockId: string }) => {
const connectElemRef = React.useRef<HTMLDivElement>(null); const connectElemRef = React.useRef<HTMLDivElement>(null);
const [term, setTerm] = React.useState<Terminal | null>(null); const termRef = React.useRef<Terminal>(null);
const [blockStarted, setBlockStarted] = React.useState<boolean>(false);
React.useEffect(() => { React.useEffect(() => {
if (!connectElemRef.current) { if (!connectElemRef.current) {
@ -59,26 +58,30 @@ const TerminalView = ({ blockId }: { blockId: string }) => {
fontWeight: "normal", fontWeight: "normal",
fontWeightBold: "bold", fontWeightBold: "bold",
}); });
setTerm(term); termRef.current = term;
const fitAddon = new FitAddon(); const fitAddon = new FitAddon();
term.loadAddon(fitAddon); term.loadAddon(fitAddon);
term.open(connectElemRef.current); term.open(connectElemRef.current);
fitAddon.fit(); fitAddon.fit();
term.write("Hello, world!\r\n"); BlockService.SendCommand(blockId, {
console.log(term); command: "controller:input",
termsize: { rows: term.rows, cols: term.cols },
});
term.onData((data) => { term.onData((data) => {
const b64data = btoa(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); BlockService.SendCommand(blockId, inputCmd);
}); });
// resize observer // resize observer
const rszObs = new ResizeObserver(() => { const rszObs = new ResizeObserver(() => {
const oldRows = term.rows; const oldRows = term.rows;
const oldCols = term.cols; const oldCols = term.cols;
fitAddon.fit(); fitAddon.fit();
if (oldRows !== term.rows || oldCols !== term.cols) { 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); rszObs.observe(connectElemRef.current);
@ -98,43 +101,8 @@ const TerminalView = ({ blockId }: { blockId: string }) => {
}; };
}, [connectElemRef.current]); }, [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 ( return (
<div className="view-term"> <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 key="conntectElem" className="term-connectelem" ref={connectElemRef}></div>
</div> </div>
); );

View File

@ -40,4 +40,14 @@
align-items: start; align-items: start;
justify-content: start; justify-content: start;
} }
&.view-preview-text {
align-items: start;
justify-content: start;
overflow: auto;
pre {
font: var(--fixed-font);
}
}
} }

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

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

View File

@ -2,6 +2,8 @@
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
declare global { declare global {
type MetaDataType = Record<string, any>;
type TabData = { type TabData = {
name: string; name: string;
tabid: string; tabid: string;
@ -10,8 +12,41 @@ declare global {
type BlockData = { type BlockData = {
blockid: string; blockid: string;
blockdef: BlockDef;
controller: string;
controllerstatus: string;
view: 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;
}; };
} }

View File

@ -24,6 +24,7 @@
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^5.5.0",
"base64-js": "^1.5.1", "base64-js": "^1.5.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"immer": "^10.1.1",
"jotai": "^2.8.0", "jotai": "^2.8.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",

View File

@ -14,17 +14,13 @@ import (
const CommandKey = "command" const CommandKey = "command"
const ( const (
BlockCommand_Message = "message" BlockCommand_Message = "message"
BlockCommand_Run = "run" BlockCommand_Input = "controller:input"
BlockCommand_Input = "input"
BlockCommand_RunShell = "runshell"
) )
var CommandToTypeMap = map[string]reflect.Type{ var CommandToTypeMap = map[string]reflect.Type{
BlockCommand_Message: reflect.TypeOf(MessageCommand{}), BlockCommand_Message: reflect.TypeOf(MessageCommand{}),
BlockCommand_Run: reflect.TypeOf(RunCommand{}), BlockCommand_Input: reflect.TypeOf(InputCommand{}),
BlockCommand_Input: reflect.TypeOf(InputCommand{}),
BlockCommand_RunShell: reflect.TypeOf(RunShellCommand{}),
} }
type BlockCommand interface { type BlockCommand interface {
@ -61,16 +57,6 @@ func (mc *MessageCommand) GetCommand() string {
return BlockCommand_Message 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 { type InputCommand struct {
Command string `json:"command"` Command string `json:"command"`
InputData64 string `json:"inputdata64"` InputData64 string `json:"inputdata64"`
@ -81,12 +67,3 @@ type InputCommand struct {
func (ic *InputCommand) GetCommand() string { func (ic *InputCommand) GetCommand() string {
return BlockCommand_Input return BlockCommand_Input
} }
type RunShellCommand struct {
Command string `json:"command"`
TermSize shellexec.TermSize `json:"termsize"`
}
func (rsc *RunShellCommand) GetCommand() string {
return BlockCommand_RunShell
}

View File

@ -5,30 +5,130 @@ package blockcontroller
import ( import (
"encoding/base64" "encoding/base64"
"encoding/json"
"fmt" "fmt"
"io" "io"
"log" "log"
"os/exec"
"sync" "sync"
"github.com/creack/pty" "github.com/creack/pty"
"github.com/google/uuid"
"github.com/wailsapp/wails/v3/pkg/application" "github.com/wailsapp/wails/v3/pkg/application"
"github.com/wavetermdev/thenextwave/pkg/eventbus" "github.com/wavetermdev/thenextwave/pkg/eventbus"
"github.com/wavetermdev/thenextwave/pkg/shellexec" "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 globalLock = &sync.Mutex{}
var blockControllerMap = make(map[string]*BlockController) 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 { type BlockController struct {
Lock *sync.Mutex Lock *sync.Mutex
BlockId string BlockId string
InputCh chan BlockCommand BlockDef *BlockDef
InputCh chan BlockCommand
ShellProc *shellexec.ShellProc ShellProc *shellexec.ShellProc
ShellInputCh chan *InputCommand 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 CloseBlock(blockId string) {
bc := GetBlockController(blockId)
if bc == nil {
return
}
bc.Close()
close(bc.InputCh)
}
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 { func (bc *BlockController) setShellProc(shellProc *shellexec.ShellProc) error {
bc.Lock.Lock() bc.Lock.Lock()
defer bc.Lock.Unlock() defer bc.Lock.Unlock()
@ -45,34 +145,17 @@ func (bc *BlockController) getShellProc() *shellexec.ShellProc {
return bc.ShellProc return bc.ShellProc
} }
func (bc *BlockController) DoRunCommand(rc *RunCommand) error { type RunShellOpts struct {
cmdStr := rc.CmdStr TermSize shellexec.TermSize `json:"termsize,omitempty"`
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
} }
func (bc *BlockController) DoRunShellCommand(rc *RunShellCommand) error { func (bc *BlockController) Close() {
if bc.getShellProc() != nil {
bc.ShellProc.Close()
}
}
func (bc *BlockController) DoRunShellCommand(rc *RunShellOpts) error {
if bc.getShellProc() != nil { if bc.getShellProc() != nil {
return nil return nil
} }
@ -95,15 +178,18 @@ func (bc *BlockController) DoRunShellCommand(rc *RunShellCommand) error {
bc.ShellProc = nil bc.ShellProc = nil
bc.ShellInputCh = nil bc.ShellInputCh = nil
}() }()
seqNum := 0
buf := make([]byte, 4096) buf := make([]byte, 4096)
for { for {
nr, err := bc.ShellProc.Pty.Read(buf) nr, err := bc.ShellProc.Pty.Read(buf)
seqNum++
eventbus.SendEvent(application.WailsEvent{ eventbus.SendEvent(application.WailsEvent{
Name: "block:ptydata", Name: "block:ptydata",
Data: map[string]any{ Data: map[string]any{
"blockid": bc.BlockId, "blockid": bc.BlockId,
"blockfile": "main", "blockfile": "main",
"ptydata": base64.StdEncoding.EncodeToString(buf[:nr]), "ptydata": base64.StdEncoding.EncodeToString(buf[:nr]),
"seqnum": seqNum,
}, },
}) })
if err == io.EOF { if err == io.EOF {
@ -127,6 +213,7 @@ func (bc *BlockController) DoRunShellCommand(rc *RunShellCommand) error {
bc.ShellProc.Pty.Write(inputBuf[:nw]) bc.ShellProc.Pty.Write(inputBuf[:nw])
} }
if ic.TermSize != nil { 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)}) err := pty.Setsize(bc.ShellProc.Pty, &pty.Winsize{Rows: uint16(ic.TermSize.Rows), Cols: uint16(ic.TermSize.Cols)})
if err != nil { if err != nil {
log.Printf("error setting term size: %v\n", err) log.Printf("error setting term size: %v\n", err)
@ -137,8 +224,14 @@ func (bc *BlockController) DoRunShellCommand(rc *RunShellCommand) error {
return nil return nil
} }
func (bc *BlockController) Run() { func (bc *BlockController) Run(bdata *BlockData) {
defer func() { 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{ eventbus.SendEvent(application.WailsEvent{
Name: "block:done", Name: "block:done",
Data: nil, Data: nil,
@ -147,6 +240,17 @@ func (bc *BlockController) Run() {
defer globalLock.Unlock() defer globalLock.Unlock()
delete(blockControllerMap, bc.BlockId) 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 messageCount := 0
for genCmd := range bc.InputCh { for genCmd := range bc.InputCh {
@ -162,42 +266,35 @@ func (bc *BlockController) Run() {
"ptydata": base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("message %d\r\n", messageCount))), "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: case *InputCommand:
fmt.Printf("INPUT: %s | %q\n", bc.BlockId, cmd.InputData64) fmt.Printf("INPUT: %s | %q\n", bc.BlockId, cmd.InputData64)
if bc.ShellInputCh != nil { if bc.ShellInputCh != nil {
bc.ShellInputCh <- cmd 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: default:
fmt.Printf("unknown command type %T\n", cmd) 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() globalLock.Lock()
defer globalLock.Unlock() defer globalLock.Unlock()
if existingBC, ok := blockControllerMap[blockId]; ok { if _, ok := blockControllerMap[blockId]; ok {
return existingBC return
} }
bc := &BlockController{ bc := &BlockController{
Lock: &sync.Mutex{}, Lock: &sync.Mutex{},
@ -205,8 +302,7 @@ func StartBlockController(blockId string) *BlockController {
InputCh: make(chan BlockCommand), InputCh: make(chan BlockCommand),
} }
blockControllerMap[blockId] = bc blockControllerMap[blockId] = bc
go bc.Run() go bc.Run(bdata)
return bc
} }
func GetBlockController(blockId string) *BlockController { func GetBlockController(blockId string) *BlockController {

View File

@ -63,6 +63,18 @@ func emitEventToWindow(event WindowEvent) {
} }
} }
func emitEventToAllWindows(event *application.WailsEvent) {
globalLock.Lock()
wins := make([]*application.WebviewWindow, 0, len(wailsWindowMap))
for _, window := range wailsWindowMap {
wins = append(wins, window)
}
globalLock.Unlock()
for _, window := range wins {
window.DispatchWailsEvent(event)
}
}
func SendEvent(event application.WailsEvent) { func SendEvent(event application.WailsEvent) {
EventCh <- event EventCh <- event
} }
@ -107,10 +119,7 @@ func processEvents() {
for { for {
select { select {
case event := <-EventCh: case event := <-EventCh:
// no lock needed for wailsApp since it is never updated emitEventToAllWindows(&event)
if wailsApp != nil {
wailsApp.Events.Emit(&event)
}
case windowEvent := <-WindowEventCh: case windowEvent := <-WindowEventCh:
emitEventToWindow(windowEvent) emitEventToWindow(windowEvent)

View File

@ -7,13 +7,48 @@ import (
"fmt" "fmt"
"github.com/wavetermdev/thenextwave/pkg/blockcontroller" "github.com/wavetermdev/thenextwave/pkg/blockcontroller"
"github.com/wavetermdev/thenextwave/pkg/util/utilfn"
) )
type BlockService struct{} type BlockService struct{}
func (bs *BlockService) StartBlock(blockId string) error { func (bs *BlockService) CreateBlock(bdefMap map[string]any, rtOptsMap map[string]any) (map[string]any, error) {
blockcontroller.StartBlockController(blockId) var bdef blockcontroller.BlockDef
return nil 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) CloseBlock(blockId string) {
blockcontroller.CloseBlock(blockId)
}
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 { func (bs *BlockService) SendCommand(blockId string, cmdMap map[string]any) error {

View File

@ -7,19 +7,64 @@ import (
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"os" "os"
"time" "path/filepath"
"github.com/wavetermdev/thenextwave/pkg/util/utilfn"
"github.com/wavetermdev/thenextwave/pkg/wavebase" "github.com/wavetermdev/thenextwave/pkg/wavebase"
) )
type FileService struct{} type FileService struct{}
func (fs *FileService) ReadFile(path string) (string, error) { type FileInfo struct {
path = wavebase.ExpandHomeDir(path) Path string `json:"path"` // cleaned path
barr, err := os.ReadFile(path) NotFound bool `json:"notfound,omitempty"`
if err != nil { Size int64 `json:"size"`
return "", fmt.Errorf("cannot read file %q: %w", path, err) Mode os.FileMode `json:"mode"`
} ModTime int64 `json:"modtime"`
time.Sleep(2 * time.Second) IsDir bool `json:"isdir,omitempty"`
return base64.StdEncoding.EncodeToString(barr), nil 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
} }

View File

@ -13,6 +13,7 @@ import (
"github.com/creack/pty" "github.com/creack/pty"
"github.com/wavetermdev/thenextwave/pkg/util/shellutil" "github.com/wavetermdev/thenextwave/pkg/util/shellutil"
"github.com/wavetermdev/thenextwave/pkg/wavebase"
) )
type TermSize struct { type TermSize struct {
@ -37,7 +38,11 @@ func StartShellProc(termSize TermSize) (*ShellProc, error) {
shellPath := shellutil.DetectLocalShellPath() shellPath := shellutil.DetectLocalShellPath()
ecmd := exec.Command(shellPath, "-i", "-l") ecmd := exec.Command(shellPath, "-i", "-l")
ecmd.Env = os.Environ() 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() cmdPty, cmdTty, err := pty.Open()
if err != nil { if err != nil {
return nil, fmt.Errorf("opening new pty: %w", err) return nil, fmt.Errorf("opening new pty: %w", err)

View File

@ -13,9 +13,11 @@ import (
"io" "io"
"math" "math"
mathrand "math/rand" mathrand "math/rand"
"mime"
"net/http" "net/http"
"os" "os"
"os/exec" "os/exec"
"path/filepath"
"regexp" "regexp"
"sort" "sort"
"strings" "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 "" // on error just returns ""
// does not return "application/octet-stream" as this is considered a detection failure // does not return "application/octet-stream" as this is considered a detection failure
func DetectMimeType(path string) string { 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) fd, err := os.Open(path)
if err != nil { if err != nil {
return "" return ""
@ -673,3 +688,24 @@ func GetFirstLine(s string) string {
} }
return s[0:idx] 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
}

View File

@ -4,13 +4,18 @@
package wavebase package wavebase
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"io/fs" "io/fs"
"log"
"os" "os"
"os/exec"
"path" "path"
"runtime"
"strings" "strings"
"sync" "sync"
"time"
) )
const WaveVersion = "v0.1.0" const WaveVersion = "v0.1.0"
@ -46,6 +51,17 @@ func ExpandHomeDir(pathStr string) string {
return path.Join(homeDir, pathStr[2:]) 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 { func GetWaveHomeDir() string {
homeVar := os.Getenv(WaveHomeVarName) homeVar := os.Getenv(WaveHomeVarName)
if homeVar != "" { if homeVar != "" {
@ -92,3 +108,32 @@ func TryMkdirs(dirName string, perm os.FileMode, dirDesc string) error {
} }
return nil 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
}

View File

@ -36,32 +36,3 @@ body {
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
flex-shrink: 0; 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);
}
}
}

View File

@ -1110,6 +1110,11 @@ image-size@~0.5.0:
resolved "https://registry.yarnpkg.com/image-size/-/image-size-0.5.5.tgz#09dfd4ab9d20e29eb1c3e80b8990378df9e3cb9c" resolved "https://registry.yarnpkg.com/image-size/-/image-size-0.5.5.tgz#09dfd4ab9d20e29eb1c3e80b8990378df9e3cb9c"
integrity sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ== 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: inline-style-parser@0.2.3:
version "0.2.3" version "0.2.3"
resolved "https://registry.yarnpkg.com/inline-style-parser/-/inline-style-parser-0.2.3.tgz#e35c5fb45f3a83ed7849fe487336eb7efa25971c" resolved "https://registry.yarnpkg.com/inline-style-parser/-/inline-style-parser-0.2.3.tgz#e35c5fb45f3a83ed7849fe487336eb7efa25971c"