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 { Provider } from "jotai";
|
||||
import { clsx } from "clsx";
|
||||
import { TabContent } from "@/app/tab/tab";
|
||||
import { Workspace } from "@/app/workspace/workspace";
|
||||
import { globalStore, atoms } from "@/store/global";
|
||||
|
||||
import "/public/style.less";
|
||||
import "../../public/style.less";
|
||||
|
||||
const App = () => {
|
||||
return (
|
||||
@ -18,37 +18,6 @@ const App = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const Tab = ({ tab }: { tab: TabData }) => {
|
||||
const [activeTab, setActiveTab] = jotai.useAtom(atoms.activeTabId);
|
||||
return (
|
||||
<div className={clsx("tab", { active: activeTab === tab.tabid })} onClick={() => setActiveTab(tab.tabid)}>
|
||||
{tab.name}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TabBar = () => {
|
||||
const [activeTab, setActiveTab] = jotai.useAtom(atoms.activeTabId);
|
||||
const tabs = jotai.useAtomValue(atoms.tabsAtom);
|
||||
return (
|
||||
<div className="tab-bar">
|
||||
{tabs.map((tab, idx) => {
|
||||
return <Tab key={idx} tab={tab} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Workspace = () => {
|
||||
const activeTabId = jotai.useAtomValue(atoms.activeTabId);
|
||||
return (
|
||||
<div className="workspace">
|
||||
<TabBar />
|
||||
<TabContent tabId={activeTabId} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AppInner = () => {
|
||||
return (
|
||||
<div className="mainapp">
|
||||
|
@ -13,12 +13,25 @@
|
||||
|
||||
.block-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-shrink: 0;
|
||||
height: 30px;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--panel-bg-color);
|
||||
|
||||
.block-header-text {
|
||||
padding-left: 5px;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
font-size: 12px;
|
||||
padding-right: 5px;
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.block-content {
|
||||
|
@ -3,7 +3,7 @@
|
||||
|
||||
import * as React from "react";
|
||||
import * as jotai from "jotai";
|
||||
import { atoms } from "@/store/global";
|
||||
import { atoms, blockDataMap, removeBlockFromTab } from "@/store/global";
|
||||
|
||||
import { TerminalView } from "@/app/view/term";
|
||||
import { PreviewView } from "@/app/view/preview";
|
||||
@ -12,9 +12,14 @@ import { CenteredLoadingDiv } from "@/element/quickelems";
|
||||
|
||||
import "./block.less";
|
||||
|
||||
const Block = ({ blockId }: { blockId: string }) => {
|
||||
const Block = ({ tabId, blockId }: { tabId: string; blockId: string }) => {
|
||||
const blockRef = React.useRef<HTMLDivElement>(null);
|
||||
const [dims, setDims] = React.useState({ width: 0, height: 0 });
|
||||
|
||||
function handleClose() {
|
||||
removeBlockFromTab(tabId, blockId);
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!blockRef.current) {
|
||||
return;
|
||||
@ -27,7 +32,7 @@ const Block = ({ blockId }: { blockId: string }) => {
|
||||
}
|
||||
}, [blockRef.current]);
|
||||
let blockElem: JSX.Element = null;
|
||||
const blockAtom = atoms.blockAtomFamily(blockId);
|
||||
const blockAtom = blockDataMap.get(blockId);
|
||||
const blockData = jotai.useAtomValue(blockAtom);
|
||||
if (blockData.view === "term") {
|
||||
blockElem = <TerminalView blockId={blockId} />;
|
||||
@ -39,9 +44,13 @@ const Block = ({ blockId }: { blockId: string }) => {
|
||||
return (
|
||||
<div className="block" ref={blockRef}>
|
||||
<div key="header" className="block-header">
|
||||
<div className="text-fixed">
|
||||
<div className="block-header-text text-fixed">
|
||||
Block [{blockId.substring(0, 8)}] {dims.width}x{dims.height}
|
||||
</div>
|
||||
<div className="flex-spacer" />
|
||||
<div className="close-button" onClick={() => handleClose()}>
|
||||
<i className="fa fa-solid fa-xmark-large" />
|
||||
</div>
|
||||
</div>
|
||||
<div key="content" className="block-content">
|
||||
<React.Suspense fallback={<CenteredLoadingDiv />}>{blockElem}</React.Suspense>
|
||||
|
@ -7,42 +7,21 @@ import { v4 as uuidv4 } from "uuid";
|
||||
import * as rxjs from "rxjs";
|
||||
import type { WailsEvent } from "@wailsio/runtime/types/events";
|
||||
import { Events } from "@wailsio/runtime";
|
||||
import { produce } from "immer";
|
||||
import * as BlockService from "@/bindings/pkg/service/blockservice/BlockService";
|
||||
|
||||
const globalStore = jotai.createStore();
|
||||
|
||||
const tabId1 = uuidv4();
|
||||
const tabId2 = uuidv4();
|
||||
|
||||
const blockId1 = uuidv4();
|
||||
const blockId2 = uuidv4();
|
||||
const blockId3 = uuidv4();
|
||||
|
||||
const tabArr: TabData[] = [
|
||||
{ name: "Tab 1", tabid: tabId1, blockIds: [blockId1, blockId2, blockId3] },
|
||||
{ name: "Tab 2", tabid: tabId2, blockIds: [blockId3] },
|
||||
];
|
||||
|
||||
const blockAtomFamily = atomFamily<string, jotai.Atom<BlockData>>((blockId: string) => {
|
||||
if (blockId === blockId1) {
|
||||
return jotai.atom({ blockid: blockId1, view: "term" });
|
||||
}
|
||||
if (blockId === blockId2) {
|
||||
return jotai.atom({
|
||||
blockid: blockId2,
|
||||
view: "preview",
|
||||
meta: { mimetype: "text/markdown", file: "README.md" },
|
||||
});
|
||||
}
|
||||
if (blockId === blockId3) {
|
||||
return jotai.atom({ blockid: blockId3, view: "plot" });
|
||||
}
|
||||
return jotai.atom(null);
|
||||
});
|
||||
const tabArr: TabData[] = [{ name: "Tab 1", tabid: tabId1, blockIds: [] }];
|
||||
const blockDataMap = new Map<string, jotai.Atom<BlockData>>();
|
||||
const blockAtomCache = new Map<string, Map<string, jotai.Atom<any>>>();
|
||||
|
||||
const atoms = {
|
||||
activeTabId: jotai.atom<string>(tabId1),
|
||||
tabsAtom: jotai.atom<TabData[]>(tabArr),
|
||||
blockAtomFamily,
|
||||
blockDataMap: blockDataMap,
|
||||
};
|
||||
|
||||
type SubjectWithRef<T> = rxjs.Subject<T> & { refCount: number; release: () => void };
|
||||
@ -81,4 +60,43 @@ Events.On("block:ptydata", (event: any) => {
|
||||
subject.next(data);
|
||||
});
|
||||
|
||||
export { globalStore, atoms, getBlockSubject };
|
||||
function addBlockIdToTab(tabId: string, blockId: string) {
|
||||
let tabArr = globalStore.get(atoms.tabsAtom);
|
||||
const newTabArr = produce(tabArr, (draft) => {
|
||||
const tab = draft.find((tab) => tab.tabid == tabId);
|
||||
tab.blockIds.push(blockId);
|
||||
});
|
||||
globalStore.set(atoms.tabsAtom, newTabArr);
|
||||
}
|
||||
|
||||
function removeBlock(blockId: string) {
|
||||
blockDataMap.delete(blockId);
|
||||
blockAtomCache.delete(blockId);
|
||||
}
|
||||
|
||||
function useBlockAtom<T>(blockId: string, name: string, makeFn: () => jotai.Atom<T>): jotai.Atom<T> {
|
||||
let blockCache = blockAtomCache.get(blockId);
|
||||
if (blockCache == null) {
|
||||
blockCache = new Map<string, jotai.Atom<any>>();
|
||||
blockAtomCache.set(blockId, blockCache);
|
||||
}
|
||||
let atom = blockCache.get(name);
|
||||
if (atom == null) {
|
||||
atom = makeFn();
|
||||
blockCache.set(name, atom);
|
||||
}
|
||||
return atom as jotai.Atom<T>;
|
||||
}
|
||||
|
||||
function removeBlockFromTab(tabId: string, blockId: string) {
|
||||
let tabArr = globalStore.get(atoms.tabsAtom);
|
||||
const newTabArr = produce(tabArr, (draft) => {
|
||||
const tab = draft.find((tab) => tab.tabid == tabId);
|
||||
tab.blockIds = tab.blockIds.filter((id) => id !== blockId);
|
||||
});
|
||||
globalStore.set(atoms.tabsAtom, newTabArr);
|
||||
removeBlock(blockId);
|
||||
BlockService.CloseBlock(blockId);
|
||||
}
|
||||
|
||||
export { globalStore, atoms, getBlockSubject, addBlockIdToTab, blockDataMap, useBlockAtom, removeBlockFromTab };
|
||||
|
@ -19,7 +19,7 @@ const TabContent = ({ tabId }: { tabId: string }) => {
|
||||
{tabData.blockIds.map((blockId: string) => {
|
||||
return (
|
||||
<div key={blockId} className="block-container">
|
||||
<Block blockId={blockId} />
|
||||
<Block tabId={tabId} blockId={blockId} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
@ -3,33 +3,16 @@
|
||||
|
||||
import * as React from "react";
|
||||
import * as jotai from "jotai";
|
||||
import { atoms } from "@/store/global";
|
||||
import { atoms, blockDataMap, useBlockAtom } from "@/store/global";
|
||||
import { Markdown } from "@/element/markdown";
|
||||
import * as FileService from "@/bindings/pkg/service/fileservice/FileService";
|
||||
import * as util from "@/util/util";
|
||||
import { loadable } from "jotai/utils";
|
||||
|
||||
import "./view.less";
|
||||
|
||||
const markdownText = `
|
||||
# Markdown Preview
|
||||
|
||||
* list item 1
|
||||
* list item 2
|
||||
* item 3
|
||||
|
||||
\`\`\`
|
||||
let foo = "bar";
|
||||
console.log(foo);
|
||||
\`\`\`
|
||||
`;
|
||||
|
||||
const readmeAtom = jotai.atom(async () => {
|
||||
const readme = await FileService.ReadFile("README.md");
|
||||
return util.base64ToString(readme);
|
||||
});
|
||||
|
||||
const MarkdownPreview = ({ blockData }: { blockData: BlockData }) => {
|
||||
const readmeText = jotai.useAtomValue(readmeAtom);
|
||||
const MarkdownPreview = ({ contentAtom }: { contentAtom: jotai.Atom<Promise<string>> }) => {
|
||||
const readmeText = jotai.useAtomValue(contentAtom);
|
||||
return (
|
||||
<div className="view-preview view-preview-markdown">
|
||||
<Markdown text={readmeText} />
|
||||
@ -37,14 +20,54 @@ const MarkdownPreview = ({ blockData }: { blockData: BlockData }) => {
|
||||
);
|
||||
};
|
||||
|
||||
let counter = 0;
|
||||
|
||||
const PreviewView = ({ blockId }: { blockId: string }) => {
|
||||
const blockData: BlockData = jotai.useAtomValue(atoms.blockAtomFamily(blockId));
|
||||
if (blockData.meta?.mimetype === "text/markdown") {
|
||||
return <MarkdownPreview blockData={blockData} />;
|
||||
const blockDataAtom: jotai.Atom<BlockData> = blockDataMap.get(blockId);
|
||||
const fileNameAtom = useBlockAtom(blockId, "preview:filename", () =>
|
||||
jotai.atom<string>((get) => {
|
||||
return get(blockDataAtom)?.meta?.file;
|
||||
})
|
||||
);
|
||||
const fullFileAtom = useBlockAtom(blockId, "preview:fullfile", () =>
|
||||
jotai.atom<Promise<FullFile>>(async (get) => {
|
||||
const fileName = get(fileNameAtom);
|
||||
if (fileName == null) {
|
||||
return null;
|
||||
}
|
||||
const file = await FileService.ReadFile(fileName);
|
||||
return file;
|
||||
})
|
||||
);
|
||||
const fileMimeTypeAtom = useBlockAtom(blockId, "preview:mimetype", () =>
|
||||
jotai.atom<Promise<string>>(async (get) => {
|
||||
const fullFile = await get(fullFileAtom);
|
||||
return fullFile?.info?.mimetype;
|
||||
})
|
||||
);
|
||||
const fileContentAtom = useBlockAtom(blockId, "preview:filecontent", () =>
|
||||
jotai.atom<Promise<string>>(async (get) => {
|
||||
const fullFile = await get(fullFileAtom);
|
||||
return util.base64ToString(fullFile?.data64);
|
||||
})
|
||||
);
|
||||
let mimeType = jotai.useAtomValue(fileMimeTypeAtom);
|
||||
if (mimeType == null) {
|
||||
mimeType = "";
|
||||
}
|
||||
if (mimeType === "text/markdown") {
|
||||
return <MarkdownPreview contentAtom={fileContentAtom} />;
|
||||
}
|
||||
if (mimeType.startsWith("text/")) {
|
||||
return (
|
||||
<div className="view-preview view-preview-text">
|
||||
<pre>{jotai.useAtomValue(fileContentAtom)}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="view-preview">
|
||||
<div>Preview</div>
|
||||
<div>Preview ({mimeType})</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -44,8 +44,7 @@ function getThemeFromCSSVars(el: Element): ITheme {
|
||||
|
||||
const TerminalView = ({ blockId }: { blockId: string }) => {
|
||||
const connectElemRef = React.useRef<HTMLDivElement>(null);
|
||||
const [term, setTerm] = React.useState<Terminal | null>(null);
|
||||
const [blockStarted, setBlockStarted] = React.useState<boolean>(false);
|
||||
const termRef = React.useRef<Terminal>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!connectElemRef.current) {
|
||||
@ -59,26 +58,30 @@ const TerminalView = ({ blockId }: { blockId: string }) => {
|
||||
fontWeight: "normal",
|
||||
fontWeightBold: "bold",
|
||||
});
|
||||
setTerm(term);
|
||||
termRef.current = term;
|
||||
const fitAddon = new FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
term.open(connectElemRef.current);
|
||||
fitAddon.fit();
|
||||
term.write("Hello, world!\r\n");
|
||||
console.log(term);
|
||||
BlockService.SendCommand(blockId, {
|
||||
command: "controller:input",
|
||||
termsize: { rows: term.rows, cols: term.cols },
|
||||
});
|
||||
term.onData((data) => {
|
||||
const b64data = btoa(data);
|
||||
const inputCmd = { command: "input", blockid: blockId, inputdata64: b64data };
|
||||
const inputCmd = { command: "controller:input", blockid: blockId, inputdata64: b64data };
|
||||
BlockService.SendCommand(blockId, inputCmd);
|
||||
});
|
||||
|
||||
// resize observer
|
||||
const rszObs = new ResizeObserver(() => {
|
||||
const oldRows = term.rows;
|
||||
const oldCols = term.cols;
|
||||
fitAddon.fit();
|
||||
if (oldRows !== term.rows || oldCols !== term.cols) {
|
||||
BlockService.SendCommand(blockId, { command: "input", termsize: { rows: term.rows, cols: term.cols } });
|
||||
BlockService.SendCommand(blockId, {
|
||||
command: "controller:input",
|
||||
termsize: { rows: term.rows, cols: term.cols },
|
||||
});
|
||||
}
|
||||
});
|
||||
rszObs.observe(connectElemRef.current);
|
||||
@ -98,43 +101,8 @@ const TerminalView = ({ blockId }: { blockId: string }) => {
|
||||
};
|
||||
}, [connectElemRef.current]);
|
||||
|
||||
async function handleRunClick() {
|
||||
try {
|
||||
if (!blockStarted) {
|
||||
await BlockService.StartBlock(blockId);
|
||||
setBlockStarted(true);
|
||||
}
|
||||
let termSize = { rows: term.rows, cols: term.cols };
|
||||
await BlockService.SendCommand(blockId, { command: "run", cmdstr: "ls -l", termsize: termSize });
|
||||
} catch (e) {
|
||||
console.log("run click error: ", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStartTerminalClick() {
|
||||
try {
|
||||
if (!blockStarted) {
|
||||
await BlockService.StartBlock(blockId);
|
||||
setBlockStarted(true);
|
||||
}
|
||||
let termSize = { rows: term.rows, cols: term.cols };
|
||||
await BlockService.SendCommand(blockId, { command: "runshell", termsize: termSize });
|
||||
} catch (e) {
|
||||
console.log("start terminal click error: ", e);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="view-term">
|
||||
<div className="term-header">
|
||||
<div>Terminal</div>
|
||||
<Button className="term-inline" onClick={() => handleRunClick()}>
|
||||
Run `ls`
|
||||
</Button>
|
||||
<Button className="term-inline" onClick={() => handleStartTerminalClick()}>
|
||||
Start Terminal
|
||||
</Button>
|
||||
</div>
|
||||
<div key="conntectElem" className="term-connectelem" ref={connectElemRef}></div>
|
||||
</div>
|
||||
);
|
||||
|
@ -40,4 +40,14 @@
|
||||
align-items: start;
|
||||
justify-content: start;
|
||||
}
|
||||
|
||||
&.view-preview-text {
|
||||
align-items: start;
|
||||
justify-content: start;
|
||||
overflow: auto;
|
||||
|
||||
pre {
|
||||
font: var(--fixed-font);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
76
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
|
||||
|
||||
declare global {
|
||||
type MetaDataType = Record<string, any>;
|
||||
|
||||
type TabData = {
|
||||
name: string;
|
||||
tabid: string;
|
||||
@ -10,8 +12,41 @@ declare global {
|
||||
|
||||
type BlockData = {
|
||||
blockid: string;
|
||||
blockdef: BlockDef;
|
||||
controller: string;
|
||||
controllerstatus: string;
|
||||
view: string;
|
||||
meta?: Record<string, any>;
|
||||
meta?: MetaDataType;
|
||||
};
|
||||
|
||||
type FileDef = {
|
||||
filetype?: string;
|
||||
path?: string;
|
||||
url?: string;
|
||||
content?: string;
|
||||
meta?: MetaDataType;
|
||||
};
|
||||
|
||||
type BlockDef = {
|
||||
controller?: string;
|
||||
view: string;
|
||||
files?: FileDef[];
|
||||
meta?: MetaDataType;
|
||||
};
|
||||
|
||||
type FileInfo = {
|
||||
path: string;
|
||||
notfound: boolean;
|
||||
size: number;
|
||||
mode: number;
|
||||
modtime: number;
|
||||
isdir: boolean;
|
||||
mimetype: string;
|
||||
};
|
||||
|
||||
type FullFile = {
|
||||
info: FileInfo;
|
||||
data64: string;
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -24,6 +24,7 @@
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"base64-js": "^1.5.1",
|
||||
"clsx": "^2.1.1",
|
||||
"immer": "^10.1.1",
|
||||
"jotai": "^2.8.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
|
@ -15,16 +15,12 @@ const CommandKey = "command"
|
||||
|
||||
const (
|
||||
BlockCommand_Message = "message"
|
||||
BlockCommand_Run = "run"
|
||||
BlockCommand_Input = "input"
|
||||
BlockCommand_RunShell = "runshell"
|
||||
BlockCommand_Input = "controller:input"
|
||||
)
|
||||
|
||||
var CommandToTypeMap = map[string]reflect.Type{
|
||||
BlockCommand_Message: reflect.TypeOf(MessageCommand{}),
|
||||
BlockCommand_Run: reflect.TypeOf(RunCommand{}),
|
||||
BlockCommand_Input: reflect.TypeOf(InputCommand{}),
|
||||
BlockCommand_RunShell: reflect.TypeOf(RunShellCommand{}),
|
||||
}
|
||||
|
||||
type BlockCommand interface {
|
||||
@ -61,16 +57,6 @@ func (mc *MessageCommand) GetCommand() string {
|
||||
return BlockCommand_Message
|
||||
}
|
||||
|
||||
type RunCommand struct {
|
||||
Command string `json:"command"`
|
||||
CmdStr string `json:"cmdstr"`
|
||||
TermSize shellexec.TermSize `json:"termsize"`
|
||||
}
|
||||
|
||||
func (rc *RunCommand) GetCommand() string {
|
||||
return BlockCommand_Run
|
||||
}
|
||||
|
||||
type InputCommand struct {
|
||||
Command string `json:"command"`
|
||||
InputData64 string `json:"inputdata64"`
|
||||
@ -81,12 +67,3 @@ type InputCommand struct {
|
||||
func (ic *InputCommand) GetCommand() string {
|
||||
return BlockCommand_Input
|
||||
}
|
||||
|
||||
type RunShellCommand struct {
|
||||
Command string `json:"command"`
|
||||
TermSize shellexec.TermSize `json:"termsize"`
|
||||
}
|
||||
|
||||
func (rsc *RunShellCommand) GetCommand() string {
|
||||
return BlockCommand_RunShell
|
||||
}
|
||||
|
@ -5,30 +5,130 @@ package blockcontroller
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os/exec"
|
||||
"sync"
|
||||
|
||||
"github.com/creack/pty"
|
||||
"github.com/google/uuid"
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
"github.com/wavetermdev/thenextwave/pkg/eventbus"
|
||||
"github.com/wavetermdev/thenextwave/pkg/shellexec"
|
||||
"github.com/wavetermdev/thenextwave/pkg/util/shellutil"
|
||||
)
|
||||
|
||||
const (
|
||||
BlockController_Shell = "shell"
|
||||
BlockController_Cmd = "cmd"
|
||||
)
|
||||
|
||||
var globalLock = &sync.Mutex{}
|
||||
var blockControllerMap = make(map[string]*BlockController)
|
||||
var blockDataMap = make(map[string]*BlockData)
|
||||
|
||||
type BlockData struct {
|
||||
Lock *sync.Mutex `json:"-"`
|
||||
BlockId string `json:"blockid"`
|
||||
BlockDef *BlockDef `json:"blockdef"`
|
||||
Controller string `json:"controller"`
|
||||
ControllerStatus string `json:"controllerstatus"`
|
||||
View string `json:"view"`
|
||||
Meta map[string]any `json:"meta,omitempty"`
|
||||
RuntimeOpts *RuntimeOpts `json:"runtimeopts,omitempty"`
|
||||
}
|
||||
|
||||
type FileDef struct {
|
||||
FileType string `json:"filetype,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Url string `json:"url,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Meta map[string]any `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
type BlockDef struct {
|
||||
Controller string `json:"controller"`
|
||||
View string `json:"view,omitempty"`
|
||||
Files map[string]*FileDef `json:"files,omitempty"`
|
||||
Meta map[string]any `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
type WinSize struct {
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
}
|
||||
|
||||
type RuntimeOpts struct {
|
||||
TermSize shellexec.TermSize `json:"termsize,omitempty"`
|
||||
WinSize WinSize `json:"winsize,omitempty"`
|
||||
}
|
||||
|
||||
type BlockController struct {
|
||||
Lock *sync.Mutex
|
||||
BlockId string
|
||||
BlockDef *BlockDef
|
||||
InputCh chan BlockCommand
|
||||
|
||||
ShellProc *shellexec.ShellProc
|
||||
ShellInputCh chan *InputCommand
|
||||
}
|
||||
|
||||
func jsonDeepCopy(val map[string]any) (map[string]any, error) {
|
||||
barr, err := json.Marshal(val)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var rtn map[string]any
|
||||
err = json.Unmarshal(barr, &rtn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rtn, nil
|
||||
}
|
||||
|
||||
func CreateBlock(bdef *BlockDef, rtOpts *RuntimeOpts) (*BlockData, error) {
|
||||
blockId := uuid.New().String()
|
||||
blockData := &BlockData{
|
||||
Lock: &sync.Mutex{},
|
||||
BlockId: blockId,
|
||||
BlockDef: bdef,
|
||||
Controller: bdef.Controller,
|
||||
View: bdef.View,
|
||||
RuntimeOpts: rtOpts,
|
||||
}
|
||||
var err error
|
||||
blockData.Meta, err = jsonDeepCopy(bdef.Meta)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error copying meta: %w", err)
|
||||
}
|
||||
setBlockData(blockData)
|
||||
if blockData.Controller != "" {
|
||||
StartBlockController(blockId, blockData)
|
||||
}
|
||||
return blockData, nil
|
||||
}
|
||||
|
||||
func 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 {
|
||||
bc.Lock.Lock()
|
||||
defer bc.Lock.Unlock()
|
||||
@ -45,34 +145,17 @@ func (bc *BlockController) getShellProc() *shellexec.ShellProc {
|
||||
return bc.ShellProc
|
||||
}
|
||||
|
||||
func (bc *BlockController) DoRunCommand(rc *RunCommand) error {
|
||||
cmdStr := rc.CmdStr
|
||||
shellPath := shellutil.DetectLocalShellPath()
|
||||
ecmd := exec.Command(shellPath, "-c", cmdStr)
|
||||
log.Printf("running shell command: %q %q\n", shellPath, cmdStr)
|
||||
barr, err := shellexec.RunSimpleCmdInPty(ecmd, rc.TermSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for len(barr) > 0 {
|
||||
part := barr
|
||||
if len(part) > 4096 {
|
||||
part = part[:4096]
|
||||
}
|
||||
eventbus.SendEvent(application.WailsEvent{
|
||||
Name: "block:ptydata",
|
||||
Data: map[string]any{
|
||||
"blockid": bc.BlockId,
|
||||
"blockfile": "main",
|
||||
"ptydata": base64.StdEncoding.EncodeToString(part),
|
||||
},
|
||||
})
|
||||
barr = barr[len(part):]
|
||||
}
|
||||
return nil
|
||||
type RunShellOpts struct {
|
||||
TermSize shellexec.TermSize `json:"termsize,omitempty"`
|
||||
}
|
||||
|
||||
func (bc *BlockController) DoRunShellCommand(rc *RunShellCommand) error {
|
||||
func (bc *BlockController) Close() {
|
||||
if bc.getShellProc() != nil {
|
||||
bc.ShellProc.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (bc *BlockController) DoRunShellCommand(rc *RunShellOpts) error {
|
||||
if bc.getShellProc() != nil {
|
||||
return nil
|
||||
}
|
||||
@ -95,15 +178,18 @@ func (bc *BlockController) DoRunShellCommand(rc *RunShellCommand) error {
|
||||
bc.ShellProc = nil
|
||||
bc.ShellInputCh = nil
|
||||
}()
|
||||
seqNum := 0
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
nr, err := bc.ShellProc.Pty.Read(buf)
|
||||
seqNum++
|
||||
eventbus.SendEvent(application.WailsEvent{
|
||||
Name: "block:ptydata",
|
||||
Data: map[string]any{
|
||||
"blockid": bc.BlockId,
|
||||
"blockfile": "main",
|
||||
"ptydata": base64.StdEncoding.EncodeToString(buf[:nr]),
|
||||
"seqnum": seqNum,
|
||||
},
|
||||
})
|
||||
if err == io.EOF {
|
||||
@ -127,6 +213,7 @@ func (bc *BlockController) DoRunShellCommand(rc *RunShellCommand) error {
|
||||
bc.ShellProc.Pty.Write(inputBuf[:nw])
|
||||
}
|
||||
if ic.TermSize != nil {
|
||||
log.Printf("SETTERMSIZE: %dx%d\n", ic.TermSize.Rows, ic.TermSize.Cols)
|
||||
err := pty.Setsize(bc.ShellProc.Pty, &pty.Winsize{Rows: uint16(ic.TermSize.Rows), Cols: uint16(ic.TermSize.Cols)})
|
||||
if err != nil {
|
||||
log.Printf("error setting term size: %v\n", err)
|
||||
@ -137,8 +224,14 @@ func (bc *BlockController) DoRunShellCommand(rc *RunShellCommand) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (bc *BlockController) Run() {
|
||||
func (bc *BlockController) Run(bdata *BlockData) {
|
||||
defer func() {
|
||||
bdata.WithLock(func() {
|
||||
// if the controller had an error status, don't change it
|
||||
if bdata.ControllerStatus == "running" {
|
||||
bdata.ControllerStatus = "done"
|
||||
}
|
||||
})
|
||||
eventbus.SendEvent(application.WailsEvent{
|
||||
Name: "block:done",
|
||||
Data: nil,
|
||||
@ -147,6 +240,17 @@ func (bc *BlockController) Run() {
|
||||
defer globalLock.Unlock()
|
||||
delete(blockControllerMap, bc.BlockId)
|
||||
}()
|
||||
bdata.WithLock(func() {
|
||||
bdata.ControllerStatus = "running"
|
||||
})
|
||||
|
||||
// only controller is "shell" for now
|
||||
go func() {
|
||||
err := bc.DoRunShellCommand(&RunShellOpts{TermSize: bdata.RuntimeOpts.TermSize})
|
||||
if err != nil {
|
||||
log.Printf("error running shell: %v\n", err)
|
||||
}
|
||||
}()
|
||||
|
||||
messageCount := 0
|
||||
for genCmd := range bc.InputCh {
|
||||
@ -162,42 +266,35 @@ func (bc *BlockController) Run() {
|
||||
"ptydata": base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("message %d\r\n", messageCount))),
|
||||
},
|
||||
})
|
||||
case *RunCommand:
|
||||
fmt.Printf("RUN: %s | %q\n", bc.BlockId, cmd.CmdStr)
|
||||
go func() {
|
||||
err := bc.DoRunCommand(cmd)
|
||||
if err != nil {
|
||||
log.Printf("error running shell command: %v\n", err)
|
||||
}
|
||||
}()
|
||||
case *InputCommand:
|
||||
fmt.Printf("INPUT: %s | %q\n", bc.BlockId, cmd.InputData64)
|
||||
if bc.ShellInputCh != nil {
|
||||
bc.ShellInputCh <- cmd
|
||||
}
|
||||
|
||||
case *RunShellCommand:
|
||||
fmt.Printf("RUNSHELL: %s\n", bc.BlockId)
|
||||
if bc.ShellProc != nil {
|
||||
continue
|
||||
}
|
||||
go func() {
|
||||
err := bc.DoRunShellCommand(cmd)
|
||||
if err != nil {
|
||||
log.Printf("error running shell: %v\n", err)
|
||||
}
|
||||
}()
|
||||
default:
|
||||
fmt.Printf("unknown command type %T\n", cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func StartBlockController(blockId string) *BlockController {
|
||||
func (b *BlockData) WithLock(f func()) {
|
||||
b.Lock.Lock()
|
||||
defer b.Lock.Unlock()
|
||||
f()
|
||||
}
|
||||
|
||||
func StartBlockController(blockId string, bdata *BlockData) {
|
||||
if bdata.Controller != BlockController_Shell {
|
||||
log.Printf("unknown controller %q\n", bdata.Controller)
|
||||
bdata.WithLock(func() {
|
||||
bdata.ControllerStatus = "error"
|
||||
})
|
||||
return
|
||||
}
|
||||
globalLock.Lock()
|
||||
defer globalLock.Unlock()
|
||||
if existingBC, ok := blockControllerMap[blockId]; ok {
|
||||
return existingBC
|
||||
if _, ok := blockControllerMap[blockId]; ok {
|
||||
return
|
||||
}
|
||||
bc := &BlockController{
|
||||
Lock: &sync.Mutex{},
|
||||
@ -205,8 +302,7 @@ func StartBlockController(blockId string) *BlockController {
|
||||
InputCh: make(chan BlockCommand),
|
||||
}
|
||||
blockControllerMap[blockId] = bc
|
||||
go bc.Run()
|
||||
return bc
|
||||
go bc.Run(bdata)
|
||||
}
|
||||
|
||||
func GetBlockController(blockId string) *BlockController {
|
||||
|
@ -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) {
|
||||
EventCh <- event
|
||||
}
|
||||
@ -107,10 +119,7 @@ func processEvents() {
|
||||
for {
|
||||
select {
|
||||
case event := <-EventCh:
|
||||
// no lock needed for wailsApp since it is never updated
|
||||
if wailsApp != nil {
|
||||
wailsApp.Events.Emit(&event)
|
||||
}
|
||||
emitEventToAllWindows(&event)
|
||||
case windowEvent := <-WindowEventCh:
|
||||
emitEventToWindow(windowEvent)
|
||||
|
||||
|
@ -7,13 +7,48 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/wavetermdev/thenextwave/pkg/blockcontroller"
|
||||
"github.com/wavetermdev/thenextwave/pkg/util/utilfn"
|
||||
)
|
||||
|
||||
type BlockService struct{}
|
||||
|
||||
func (bs *BlockService) StartBlock(blockId string) error {
|
||||
blockcontroller.StartBlockController(blockId)
|
||||
return nil
|
||||
func (bs *BlockService) CreateBlock(bdefMap map[string]any, rtOptsMap map[string]any) (map[string]any, error) {
|
||||
var bdef blockcontroller.BlockDef
|
||||
err := utilfn.JsonMapToStruct(bdefMap, &bdef)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error unmarshalling BlockDef: %w", err)
|
||||
}
|
||||
var rtOpts blockcontroller.RuntimeOpts
|
||||
err = utilfn.JsonMapToStruct(rtOptsMap, &rtOpts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error unmarshalling RuntimeOpts: %w", err)
|
||||
}
|
||||
blockData, err := blockcontroller.CreateBlock(&bdef, &rtOpts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating block: %w", err)
|
||||
}
|
||||
rtnMap, err := utilfn.StructToJsonMap(blockData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error marshalling BlockData: %w", err)
|
||||
}
|
||||
return rtnMap, nil
|
||||
}
|
||||
|
||||
func (bs *BlockService) 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 {
|
||||
|
@ -7,19 +7,64 @@ import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/wavetermdev/thenextwave/pkg/util/utilfn"
|
||||
"github.com/wavetermdev/thenextwave/pkg/wavebase"
|
||||
)
|
||||
|
||||
type FileService struct{}
|
||||
|
||||
func (fs *FileService) ReadFile(path string) (string, error) {
|
||||
path = wavebase.ExpandHomeDir(path)
|
||||
barr, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot read file %q: %w", path, err)
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
return base64.StdEncoding.EncodeToString(barr), nil
|
||||
type FileInfo struct {
|
||||
Path string `json:"path"` // cleaned path
|
||||
NotFound bool `json:"notfound,omitempty"`
|
||||
Size int64 `json:"size"`
|
||||
Mode os.FileMode `json:"mode"`
|
||||
ModTime int64 `json:"modtime"`
|
||||
IsDir bool `json:"isdir,omitempty"`
|
||||
MimeType string `json:"mimetype,omitempty"`
|
||||
}
|
||||
|
||||
type FullFile struct {
|
||||
Info *FileInfo `json:"info"`
|
||||
Data64 string `json:"data64,omitempty"` // base64 encoded
|
||||
}
|
||||
|
||||
func (fs *FileService) StatFile(path string) (*FileInfo, error) {
|
||||
cleanedPath := filepath.Clean(wavebase.ExpandHomeDir(path))
|
||||
finfo, err := os.Stat(cleanedPath)
|
||||
if os.IsNotExist(err) {
|
||||
return &FileInfo{Path: wavebase.ReplaceHomeDir(path), NotFound: true}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot stat file %q: %w", path, err)
|
||||
}
|
||||
mimeType := utilfn.DetectMimeType(path)
|
||||
return &FileInfo{
|
||||
Path: wavebase.ReplaceHomeDir(path),
|
||||
Size: finfo.Size(),
|
||||
Mode: finfo.Mode(),
|
||||
ModTime: finfo.ModTime().UnixMilli(),
|
||||
IsDir: finfo.IsDir(),
|
||||
MimeType: mimeType,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (fs *FileService) ReadFile(path string) (*FullFile, error) {
|
||||
finfo, err := fs.StatFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot stat file %q: %w", path, err)
|
||||
}
|
||||
if finfo.NotFound {
|
||||
return &FullFile{Info: finfo}, nil
|
||||
}
|
||||
cleanedPath := filepath.Clean(wavebase.ExpandHomeDir(path))
|
||||
barr, err := os.ReadFile(cleanedPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read file %q: %w", path, err)
|
||||
}
|
||||
return &FullFile{
|
||||
Info: finfo,
|
||||
Data64: base64.StdEncoding.EncodeToString(barr),
|
||||
}, nil
|
||||
}
|
||||
|
@ -13,6 +13,7 @@ import (
|
||||
|
||||
"github.com/creack/pty"
|
||||
"github.com/wavetermdev/thenextwave/pkg/util/shellutil"
|
||||
"github.com/wavetermdev/thenextwave/pkg/wavebase"
|
||||
)
|
||||
|
||||
type TermSize struct {
|
||||
@ -37,7 +38,11 @@ func StartShellProc(termSize TermSize) (*ShellProc, error) {
|
||||
shellPath := shellutil.DetectLocalShellPath()
|
||||
ecmd := exec.Command(shellPath, "-i", "-l")
|
||||
ecmd.Env = os.Environ()
|
||||
shellutil.UpdateCmdEnv(ecmd, shellutil.WaveshellEnvVars(shellutil.DefaultTermType))
|
||||
envToAdd := shellutil.WaveshellEnvVars(shellutil.DefaultTermType)
|
||||
if os.Getenv("LANG") == "" {
|
||||
envToAdd["LANG"] = wavebase.DetermineLang()
|
||||
}
|
||||
shellutil.UpdateCmdEnv(ecmd, envToAdd)
|
||||
cmdPty, cmdTty, err := pty.Open()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opening new pty: %w", err)
|
||||
|
@ -13,9 +13,11 @@ import (
|
||||
"io"
|
||||
"math"
|
||||
mathrand "math/rand"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
@ -616,9 +618,22 @@ func CopyToChannel(outputCh chan<- []byte, reader io.Reader) error {
|
||||
}
|
||||
}
|
||||
|
||||
// TODO more
|
||||
var StaticMimeTypeMap = map[string]string{
|
||||
".md": "text/markdown",
|
||||
".json": "application/json",
|
||||
}
|
||||
|
||||
// on error just returns ""
|
||||
// does not return "application/octet-stream" as this is considered a detection failure
|
||||
func DetectMimeType(path string) string {
|
||||
ext := filepath.Ext(path)
|
||||
if mimeType, ok := StaticMimeTypeMap[ext]; ok {
|
||||
return mimeType
|
||||
}
|
||||
if mimeType := mime.TypeByExtension(ext); mimeType != "" {
|
||||
return mimeType
|
||||
}
|
||||
fd, err := os.Open(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
@ -673,3 +688,24 @@ func GetFirstLine(s string) string {
|
||||
}
|
||||
return s[0:idx]
|
||||
}
|
||||
|
||||
func JsonMapToStruct(m map[string]any, v interface{}) error {
|
||||
barr, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(barr, v)
|
||||
}
|
||||
|
||||
func StructToJsonMap(v interface{}) (map[string]any, error) {
|
||||
barr, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var m map[string]any
|
||||
err = json.Unmarshal(barr, &m)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
@ -4,13 +4,18 @@
|
||||
package wavebase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const WaveVersion = "v0.1.0"
|
||||
@ -46,6 +51,17 @@ func ExpandHomeDir(pathStr string) string {
|
||||
return path.Join(homeDir, pathStr[2:])
|
||||
}
|
||||
|
||||
func ReplaceHomeDir(pathStr string) string {
|
||||
homeDir := GetHomeDir()
|
||||
if pathStr == homeDir {
|
||||
return "~"
|
||||
}
|
||||
if strings.HasPrefix(pathStr, homeDir+"/") {
|
||||
return "~" + pathStr[len(homeDir):]
|
||||
}
|
||||
return pathStr
|
||||
}
|
||||
|
||||
func GetWaveHomeDir() string {
|
||||
homeVar := os.Getenv(WaveHomeVarName)
|
||||
if homeVar != "" {
|
||||
@ -92,3 +108,32 @@ func TryMkdirs(dirName string, perm os.FileMode, dirDesc string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var osLangOnce = &sync.Once{}
|
||||
var osLang string
|
||||
|
||||
func determineLang() string {
|
||||
ctx, cancelFn := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancelFn()
|
||||
if runtime.GOOS == "darwin" {
|
||||
out, err := exec.CommandContext(ctx, "defaults", "read", "-g", "AppleLocale").CombinedOutput()
|
||||
if err != nil {
|
||||
log.Printf("error executing 'defaults read -g AppleLocale': %v\n", err)
|
||||
return ""
|
||||
}
|
||||
strOut := string(out)
|
||||
truncOut := strings.Split(strOut, "@")[0]
|
||||
return strings.TrimSpace(truncOut) + ".UTF-8"
|
||||
} else {
|
||||
// this is specifically to get the wavesrv LANG so waveshell
|
||||
// on a remote uses the same LANG
|
||||
return os.Getenv("LANG")
|
||||
}
|
||||
}
|
||||
|
||||
func DetermineLang() string {
|
||||
osLangOnce.Do(func() {
|
||||
osLang = determineLang()
|
||||
})
|
||||
return osLang
|
||||
}
|
||||
|
@ -36,32 +36,3 @@ body {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: 35px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
|
||||
.tab {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100px;
|
||||
height: 100%;
|
||||
border-right: 1px solid var(--border-color);
|
||||
cursor: pointer;
|
||||
&.active {
|
||||
background-color: var(--highlight-bg-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1110,6 +1110,11 @@ image-size@~0.5.0:
|
||||
resolved "https://registry.yarnpkg.com/image-size/-/image-size-0.5.5.tgz#09dfd4ab9d20e29eb1c3e80b8990378df9e3cb9c"
|
||||
integrity sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ==
|
||||
|
||||
immer@^10.1.1:
|
||||
version "10.1.1"
|
||||
resolved "https://registry.yarnpkg.com/immer/-/immer-10.1.1.tgz#206f344ea372d8ea176891545ee53ccc062db7bc"
|
||||
integrity sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==
|
||||
|
||||
inline-style-parser@0.2.3:
|
||||
version "0.2.3"
|
||||
resolved "https://registry.yarnpkg.com/inline-style-parser/-/inline-style-parser-0.2.3.tgz#e35c5fb45f3a83ed7849fe487336eb7efa25971c"
|
||||
|
Loading…
Reference in New Issue
Block a user