mirror of
https://github.com/wavetermdev/waveterm.git
synced 2024-12-21 16:38:23 +01:00
feat: integrate plots into new block setup
This commit is contained in:
commit
afd125a77e
@ -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">
|
||||||
|
@ -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 {
|
||||||
|
@ -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>
|
||||||
|
@ -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 };
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
76
frontend/app/workspace/workspace.less
Normal file
76
frontend/app/workspace/workspace.less
Normal 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
frontend/app/workspace/workspace.tsx
Normal file
115
frontend/app/workspace/workspace.tsx
Normal 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 };
|
37
frontend/types/custom.d.ts
vendored
37
frontend/types/custom.d.ts
vendored
@ -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;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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",
|
||||||
|
@ -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
|
|
||||||
}
|
|
||||||
|
@ -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 {
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
@ -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 {
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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)
|
||||||
|
@ -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
|
||||||
|
}
|
||||||
|
@ -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
|
||||||
|
}
|
||||||
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -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"
|
||||||
|
Loading…
Reference in New Issue
Block a user