mirror of
https://github.com/wavetermdev/waveterm.git
synced 2025-01-02 18:39:05 +01:00
Merge branch 'main' of github.com:wavetermdev/waveterm into red/aichat-sidebar
This commit is contained in:
commit
268bfabec2
14
package.json
14
package.json
@ -6,7 +6,7 @@
|
||||
},
|
||||
"productName": "Wave",
|
||||
"description": "An Open-Source, AI-Native, Terminal Built for Seamless Workflows",
|
||||
"version": "0.7.2",
|
||||
"version": "0.7.3",
|
||||
"main": "dist/emain.js",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
@ -32,10 +32,10 @@
|
||||
"electron-squirrel-startup": "^1.0.0",
|
||||
"electron-updater": "^6.1.8",
|
||||
"framer-motion": "^10.16.16",
|
||||
"lexical": "^0.14.3",
|
||||
"lexical": "0.14.5",
|
||||
"mobx": "6.12",
|
||||
"mobx-react": "^7.5.0",
|
||||
"monaco-editor": "^0.44.0",
|
||||
"monaco-editor": "0.48.0",
|
||||
"mustache": "^4.2.0",
|
||||
"node-fetch": "^3.2.10",
|
||||
"overlayscrollbars": "^2.6.1",
|
||||
@ -83,8 +83,8 @@
|
||||
"babel-loader": "^9.1.3",
|
||||
"babel-plugin-jsx-control-statements": "^4.1.2",
|
||||
"copy-webpack-plugin": "^12.0.0",
|
||||
"css-loader": "^6.7.1",
|
||||
"electron": "^29.0.1",
|
||||
"css-loader": "^7.1.0",
|
||||
"electron": "^30.0.1",
|
||||
"electron-builder": "^24.13.3",
|
||||
"electron-builder-squirrel-windows": "^24.13.3",
|
||||
"file-loader": "^6.2.0",
|
||||
@ -96,7 +96,7 @@
|
||||
"prettier": "^2.8.8",
|
||||
"raw-loader": "^4.0.2",
|
||||
"react-split-it": "^2.0.0",
|
||||
"style-loader": "^3.3.1",
|
||||
"style-loader": "4.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"webpack": "^5.73.0",
|
||||
"webpack-bundle-analyzer": "^4.10.1",
|
||||
@ -108,4 +108,4 @@
|
||||
"scripts": {
|
||||
"postinstall": "electron-builder install-app-deps"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -44,7 +44,7 @@ rm -rf bin/
|
||||
rm -rf build/
|
||||
node_modules/.bin/webpack --env prod
|
||||
WAVESRV_VERSION=$(node -e 'console.log(require("./version.js"))')
|
||||
WAVESHELL_VERSION=v0.6
|
||||
WAVESHELL_VERSION=v0.7
|
||||
GO_LDFLAGS="-s -w -X main.BuildTime=$(date +'%Y%m%d%H%M')"
|
||||
function buildWaveShell {
|
||||
(cd waveshell; CGO_ENABLED=0 GOOS=$1 GOARCH=$2 go build -ldflags="$GO_LDFLAGS" -o ../bin/mshell/mshell-$WAVESHELL_VERSION-$1.$2 main-waveshell.go)
|
||||
@ -69,7 +69,7 @@ rm -rf bin/
|
||||
rm -rf build/
|
||||
node_modules/.bin/webpack --env prod
|
||||
WAVESRV_VERSION=$(node -e 'console.log(require("./version.js"))')
|
||||
WAVESHELL_VERSION=v0.6
|
||||
WAVESHELL_VERSION=v0.7
|
||||
GO_LDFLAGS="-s -w -X main.BuildTime=$(date +'%Y%m%d%H%M')"
|
||||
function buildWaveShell {
|
||||
(cd waveshell; CGO_ENABLED=0 GOOS=$1 GOARCH=$2 go build -ldflags="$GO_LDFLAGS" -o ../bin/mshell/mshell-$WAVESHELL_VERSION-$1.$2 main-waveshell.go)
|
||||
@ -96,7 +96,7 @@ CGO_ENABLED=1 go build -tags "osusergo,netgo,sqlite_omit_load_extension" -ldflag
|
||||
```bash
|
||||
# @scripthaus command fullbuild-waveshell
|
||||
set -e
|
||||
WAVESHELL_VERSION=v0.6
|
||||
WAVESHELL_VERSION=v0.7
|
||||
GO_LDFLAGS="-s -w -X main.BuildTime=$(date +'%Y%m%d%H%M')"
|
||||
function buildWaveShell {
|
||||
(cd waveshell; CGO_ENABLED=0 GOOS=$1 GOARCH=$2 go build -ldflags="$GO_LDFLAGS" -o ../bin/mshell/mshell-$WAVESHELL_VERSION-$1.$2 main-waveshell.go)
|
||||
|
107
src/app/app.tsx
107
src/app/app.tsx
@ -4,10 +4,10 @@
|
||||
import * as React from "react";
|
||||
import * as mobxReact from "mobx-react";
|
||||
import * as mobx from "mobx";
|
||||
import cn from "classnames";
|
||||
|
||||
import { boundMethod } from "autobind-decorator";
|
||||
import { If } from "tsx-control-statements/components";
|
||||
import dayjs from "dayjs";
|
||||
import localizedFormat from "dayjs/plugin/localizedFormat";
|
||||
import { GlobalModel } from "@/models";
|
||||
import { isBlank } from "@/util/util";
|
||||
import { WorkspaceView } from "./workspace/workspaceview";
|
||||
@ -22,15 +22,15 @@ import { DisconnectedModal, ClientStopModal } from "@/modals";
|
||||
import { ModalsProvider } from "@/modals/provider";
|
||||
import { Button } from "@/elements";
|
||||
import { ErrorBoundary } from "@/common/error/errorboundary";
|
||||
import cn from "classnames";
|
||||
import "./app.less";
|
||||
import { TermStyleList } from "@/elements";
|
||||
|
||||
dayjs.extend(localizedFormat);
|
||||
import "./app.less";
|
||||
|
||||
@mobxReact.observer
|
||||
class App extends React.Component<{}, {}> {
|
||||
dcWait: OV<boolean> = mobx.observable.box(false, { name: "dcWait" });
|
||||
mainContentRef: React.RefObject<HTMLDivElement> = React.createRef();
|
||||
termThemesLoaded: OV<boolean> = mobx.observable.box(false, { name: "termThemesLoaded" });
|
||||
|
||||
constructor(props: {}) {
|
||||
super(props);
|
||||
@ -80,6 +80,13 @@ class App extends React.Component<{}, {}> {
|
||||
rightSidebarModel.saveState(width, false);
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
handleTermThemesRendered() {
|
||||
mobx.action(() => {
|
||||
this.termThemesLoaded.set(true);
|
||||
})();
|
||||
}
|
||||
|
||||
render() {
|
||||
const remotesModel = GlobalModel.remotesModel;
|
||||
const disconnected = !GlobalModel.ws.open.get() || !GlobalModel.waveSrvRunning.get();
|
||||
@ -90,7 +97,8 @@ class App extends React.Component<{}, {}> {
|
||||
|
||||
// Previously, this is done in sidebar.tsx but it causes flicker when clientData is null cos screen-view shifts around.
|
||||
// Doing it here fixes the flicker cos app is not rendered until clientData is populated.
|
||||
if (clientData == null) {
|
||||
// wait for termThemes as well (this actually means that the "connect" packet has been received)
|
||||
if (clientData == null || GlobalModel.termThemes.get() == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -118,52 +126,65 @@ class App extends React.Component<{}, {}> {
|
||||
if (dcWait) {
|
||||
setTimeout(() => this.updateDcWait(false), 0);
|
||||
}
|
||||
|
||||
// used to force a full reload of the application
|
||||
const renderVersion = GlobalModel.renderVersion.get();
|
||||
const mainSidebarCollapsed = GlobalModel.mainSidebarModel.getCollapsed();
|
||||
const rightSidebarCollapsed = GlobalModel.rightSidebarModel.getCollapsed();
|
||||
const activeMainView = GlobalModel.activeMainView.get();
|
||||
const lightDarkClass = GlobalModel.isDarkTheme.get() ? "is-dark" : "is-light";
|
||||
const mainClassName = cn(
|
||||
"platform-" + platform,
|
||||
{
|
||||
"mainsidebar-collapsed": mainSidebarCollapsed,
|
||||
"rightsidebar-collapsed": rightSidebarCollapsed,
|
||||
},
|
||||
lightDarkClass
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={"version-" + renderVersion}
|
||||
id="main"
|
||||
className={cn(
|
||||
"platform-" + platform,
|
||||
{ "mainsidebar-collapsed": mainSidebarCollapsed, "rightsidebar-collapsed": rightSidebarCollapsed },
|
||||
lightDarkClass
|
||||
)}
|
||||
onContextMenu={this.handleContextMenu}
|
||||
>
|
||||
<If condition={mainSidebarCollapsed}>
|
||||
<div key="logo-button" className="logo-button-container">
|
||||
<div className="logo-button-spacer" />
|
||||
<div className="logo-button" onClick={this.openMainSidebar}>
|
||||
<img src="public/logos/wave-logo.png" alt="logo" />
|
||||
<>
|
||||
<TermStyleList onRendered={this.handleTermThemesRendered} />
|
||||
<div
|
||||
key={`version- + ${renderVersion}`}
|
||||
id="main"
|
||||
className={mainClassName}
|
||||
onContextMenu={this.handleContextMenu}
|
||||
>
|
||||
<If condition={this.termThemesLoaded.get()}>
|
||||
<If condition={mainSidebarCollapsed}>
|
||||
<div key="logo-button" className="logo-button-container">
|
||||
<div className="logo-button-spacer" />
|
||||
<div className="logo-button" onClick={this.openMainSidebar}>
|
||||
<img src="public/logos/wave-logo.png" alt="logo" />
|
||||
</div>
|
||||
</div>
|
||||
</If>
|
||||
<If condition={GlobalModel.isDev && rightSidebarCollapsed && activeMainView == "session"}>
|
||||
<div className="right-sidebar-triggers">
|
||||
<Button
|
||||
className="secondary ghost right-sidebar-trigger"
|
||||
onClick={this.openRightSidebar}
|
||||
>
|
||||
<i className="fa-sharp fa-solid fa-sidebar-flip"></i>
|
||||
</Button>
|
||||
</div>
|
||||
</If>
|
||||
<div ref={this.mainContentRef} className="main-content">
|
||||
<MainSideBar parentRef={this.mainContentRef} />
|
||||
<ErrorBoundary>
|
||||
<PluginsView />
|
||||
<WorkspaceView />
|
||||
<HistoryView />
|
||||
<BookmarksView />
|
||||
<ConnectionsView model={remotesModel} />
|
||||
<ClientSettingsView model={remotesModel} />
|
||||
</ErrorBoundary>
|
||||
<RightSideBar parentRef={this.mainContentRef} />
|
||||
</div>
|
||||
</div>
|
||||
</If>
|
||||
<If condition={GlobalModel.isDev && rightSidebarCollapsed && activeMainView == "session"}>
|
||||
<div className="right-sidebar-triggers">
|
||||
<Button className="secondary ghost right-sidebar-trigger" onClick={this.openRightSidebar}>
|
||||
<i className="fa-sharp fa-solid fa-sidebar-flip"></i>
|
||||
</Button>
|
||||
</div>
|
||||
</If>
|
||||
<div ref={this.mainContentRef} className="main-content">
|
||||
<MainSideBar parentRef={this.mainContentRef} />
|
||||
<ErrorBoundary>
|
||||
<PluginsView />
|
||||
<WorkspaceView />
|
||||
<HistoryView />
|
||||
<BookmarksView />
|
||||
<ConnectionsView model={remotesModel} />
|
||||
<ClientSettingsView model={remotesModel} />
|
||||
</ErrorBoundary>
|
||||
<RightSideBar parentRef={this.mainContentRef} />
|
||||
<ModalsProvider />
|
||||
</If>
|
||||
</div>
|
||||
<ModalsProvider />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -46,6 +46,10 @@ export const TabIcons = [
|
||||
"heart",
|
||||
"file",
|
||||
];
|
||||
export const DefaultSudoPwStore = "on";
|
||||
export const DefaultSudoPwTimeoutMs = 5 * 60 * 1000;
|
||||
|
||||
export const MaxWebSocketSendSize = 64 * 1024 - 100;
|
||||
|
||||
// @ts-ignore
|
||||
export const VERSION = __WAVETERM_VERSION__;
|
||||
|
@ -76,14 +76,13 @@ class ClientSettingsView extends React.Component<{ model: RemotesModel }, { hove
|
||||
|
||||
@boundMethod
|
||||
handleChangeTermTheme(theme: string): void {
|
||||
// For global terminal theme, the key is global, otherwise it's either
|
||||
// For root terminal theme, the key is root, otherwise it's either
|
||||
// sessionId or screenId.
|
||||
const currTheme = GlobalModel.getTermTheme()["global"];
|
||||
const currTheme = GlobalModel.getTermThemeSettings()["root"];
|
||||
if (currTheme == theme) {
|
||||
return;
|
||||
}
|
||||
|
||||
const prtn = GlobalCommandRunner.setGlobalTermTheme(theme, false);
|
||||
const prtn = GlobalCommandRunner.setRootTermTheme(theme, false);
|
||||
commandRtnHandler(prtn, this.errorMessage);
|
||||
}
|
||||
|
||||
@ -158,6 +157,12 @@ class ClientSettingsView extends React.Component<{ model: RemotesModel }, { hove
|
||||
commandRtnHandler(prtn, this.errorMessage);
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
inlineUpdateOpenAITimeout(newTimeout: string): void {
|
||||
const prtn = GlobalCommandRunner.setClientOpenAISettings({ timeout: newTimeout });
|
||||
commandRtnHandler(prtn, this.errorMessage);
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
setErrorMessage(msg: string): void {
|
||||
mobx.action(() => {
|
||||
@ -192,6 +197,35 @@ class ClientSettingsView extends React.Component<{ model: RemotesModel }, { hove
|
||||
GlobalModel.clientSettingsViewModel.closeView();
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
getSudoPwStoreOptions(): DropdownItem[] {
|
||||
const sudoCacheSources: DropdownItem[] = [];
|
||||
sudoCacheSources.push({ label: "On", value: "on" });
|
||||
sudoCacheSources.push({ label: "Off", value: "off" });
|
||||
sudoCacheSources.push({ label: "On Without Timeout", value: "notimeout" });
|
||||
return sudoCacheSources;
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
handleChangeSudoPwStoreConfig(store: string) {
|
||||
const prtn = GlobalCommandRunner.setSudoPwStore(store);
|
||||
commandRtnHandler(prtn, this.errorMessage);
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
handleChangeSudoPwTimeoutConfig(timeout: string) {
|
||||
if (Number(timeout) != 0) {
|
||||
const prtn = GlobalCommandRunner.setSudoPwTimeout(timeout);
|
||||
commandRtnHandler(prtn, this.errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
handleChangeSudoPwClearOnSleepConfig(clearOnSleep: boolean) {
|
||||
const prtn = GlobalCommandRunner.setSudoPwClearOnSleep(clearOnSleep);
|
||||
commandRtnHandler(prtn, this.errorMessage);
|
||||
}
|
||||
|
||||
render() {
|
||||
const isHidden = GlobalModel.activeMainView.get() != "clientsettings";
|
||||
if (isHidden) {
|
||||
@ -204,11 +238,17 @@ class ClientSettingsView extends React.Component<{ model: RemotesModel }, { hove
|
||||
const maxTokensStr = String(
|
||||
openAIOpts.maxtokens == null || openAIOpts.maxtokens == 0 ? 1000 : openAIOpts.maxtokens
|
||||
);
|
||||
const aiTimeoutStr = String(
|
||||
openAIOpts.timeout == null || openAIOpts.timeout == 0 ? 10 : openAIOpts.timeout / 1000
|
||||
);
|
||||
const curFontSize = GlobalModel.getTermFontSize();
|
||||
const curFontFamily = GlobalModel.getTermFontFamily();
|
||||
const curTheme = GlobalModel.getThemeSource();
|
||||
const termThemes = getTermThemes(GlobalModel.termThemes, "Wave Default");
|
||||
const currTermTheme = GlobalModel.getTermTheme()["global"] ?? termThemes[0].label;
|
||||
const termThemes = getTermThemes(GlobalModel.termThemes.get(), "Wave Default");
|
||||
const currTermTheme = GlobalModel.getTermThemeSettings()["root"] ?? termThemes[0].label;
|
||||
const curSudoPwStore = GlobalModel.getSudoPwStore();
|
||||
const curSudoPwTimeout = String(GlobalModel.getSudoPwTimeout());
|
||||
const curSudoPwClearOnSleep = GlobalModel.getSudoPwClearOnSleep();
|
||||
|
||||
return (
|
||||
<MainView className="clientsettings-view" title="Client Settings" onClose={this.handleClose}>
|
||||
@ -343,6 +383,19 @@ class ClientSettingsView extends React.Component<{ model: RemotesModel }, { hove
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-field">
|
||||
<div className="settings-label">AI Timeout (seconds)</div>
|
||||
<div className="settings-input">
|
||||
<InlineSettingsTextEdit
|
||||
placeholder=""
|
||||
text={aiTimeoutStr}
|
||||
value={aiTimeoutStr}
|
||||
onChange={this.inlineUpdateOpenAITimeout}
|
||||
maxLength={10}
|
||||
showIcon={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-field">
|
||||
<div className="settings-label">Global Hotkey</div>
|
||||
<div className="settings-input">
|
||||
@ -354,6 +407,40 @@ class ClientSettingsView extends React.Component<{ model: RemotesModel }, { hove
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-field">
|
||||
<div className="settings-label">Remember Sudo Password</div>
|
||||
<div className="settings-input">
|
||||
<Dropdown
|
||||
className="hotkey-dropdown"
|
||||
options={this.getSudoPwStoreOptions()}
|
||||
defaultValue={curSudoPwStore}
|
||||
onChange={this.handleChangeSudoPwStoreConfig}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-field">
|
||||
<div className="settings-label">Sudo Timeout (Minutes)</div>
|
||||
<div className="settings-input">
|
||||
<InlineSettingsTextEdit
|
||||
placeholder=""
|
||||
text={curSudoPwTimeout}
|
||||
value={curSudoPwTimeout}
|
||||
onChange={this.handleChangeSudoPwTimeoutConfig}
|
||||
maxLength={6}
|
||||
showIcon={true}
|
||||
isNumber={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-field">
|
||||
<div className="settings-label">Clear Sudo Password on Sleep</div>
|
||||
<div className="settings-input">
|
||||
<Toggle
|
||||
checked={curSudoPwClearOnSleep}
|
||||
onChange={this.handleChangeSudoPwClearOnSleepConfig}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<SettingsError errorMessage={this.errorMessage} />
|
||||
</div>
|
||||
</MainView>
|
||||
|
@ -258,7 +258,11 @@ class Dropdown extends React.Component<DropdownProps, DropdownState> {
|
||||
document.getElementById("app")!
|
||||
)
|
||||
: null;
|
||||
|
||||
let selectedOptionLabelStyle = {};
|
||||
const wrapperClientWidth = this.wrapperRef.current?.clientWidth;
|
||||
if ((wrapperClientWidth ?? 0) > 0) {
|
||||
selectedOptionLabelStyle["width"] = Math.max(wrapperClientWidth - 55, 0);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={cn("wave-dropdown", className, {
|
||||
@ -284,7 +288,10 @@ class Dropdown extends React.Component<DropdownProps, DropdownState> {
|
||||
</div>
|
||||
</If>
|
||||
<div
|
||||
className={cn("wave-dropdown-display unselectable", { "offset-left": decoration?.startDecoration })}
|
||||
className={cn("wave-dropdown-display unselectable truncate", {
|
||||
"offset-left": decoration?.startDecoration,
|
||||
})}
|
||||
style={selectedOptionLabelStyle}
|
||||
>
|
||||
{selectedOptionLabel}
|
||||
</div>
|
||||
|
@ -8,7 +8,6 @@ export { InputDecoration } from "./inputdecoration";
|
||||
export { LinkButton } from "./linkbutton";
|
||||
export { Markdown } from "./markdown";
|
||||
export { Modal } from "./modal";
|
||||
export { NumberField } from "./numberfield";
|
||||
export { PasswordField } from "./passwordfield";
|
||||
export { ResizableSidebar } from "./resizablesidebar";
|
||||
export { SettingsError } from "./settingserror";
|
||||
@ -18,4 +17,5 @@ export { Toggle } from "./toggle";
|
||||
export { Tooltip } from "./tooltip";
|
||||
export { TabIcon } from "./tabicon";
|
||||
export { DatePicker } from "./datepicker";
|
||||
export { TermStyleList } from "./termstyle";
|
||||
export { CopyButton } from "./copybutton";
|
||||
|
@ -22,6 +22,7 @@ class InlineSettingsTextEdit extends React.Component<
|
||||
maxLength: number;
|
||||
placeholder: string;
|
||||
showIcon?: boolean;
|
||||
isNumber?: boolean;
|
||||
},
|
||||
{}
|
||||
> {
|
||||
@ -46,6 +47,12 @@ class InlineSettingsTextEdit extends React.Component<
|
||||
|
||||
@boundMethod
|
||||
handleChangeText(e: any): void {
|
||||
const isNumber = this.props.isNumber ?? false;
|
||||
const value = e.target.value;
|
||||
if (isNumber && value !== "" && !/^\d*$/.test(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
mobx.action(() => {
|
||||
this.tempText.set(e.target.value);
|
||||
})();
|
||||
|
@ -1,39 +0,0 @@
|
||||
// Copyright 2023, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import * as React from "react";
|
||||
import { boundMethod } from "autobind-decorator";
|
||||
|
||||
import { TextField } from "./textfield";
|
||||
|
||||
class NumberField extends TextField {
|
||||
@boundMethod
|
||||
handleInputChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const { required, onChange } = this.props;
|
||||
const inputValue = e.target.value;
|
||||
|
||||
// Allow only numeric input
|
||||
if (inputValue === "" || /^\d*$/.test(inputValue)) {
|
||||
// Update the internal state only if the component is not controlled.
|
||||
if (this.props.value === undefined) {
|
||||
const isError = required ? inputValue.trim() === "" : false;
|
||||
|
||||
this.setState({
|
||||
internalValue: inputValue,
|
||||
error: isError,
|
||||
hasContent: Boolean(inputValue),
|
||||
});
|
||||
}
|
||||
|
||||
onChange && onChange(inputValue);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
// Use the render method from TextField but add the onKeyDown handler
|
||||
const renderedTextField = super.render();
|
||||
return React.cloneElement(renderedTextField);
|
||||
}
|
||||
}
|
||||
|
||||
export { NumberField };
|
133
src/app/common/elements/termstyle.tsx
Normal file
133
src/app/common/elements/termstyle.tsx
Normal file
@ -0,0 +1,133 @@
|
||||
// Copyright 2023, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import * as React from "react";
|
||||
import * as mobxReact from "mobx-react";
|
||||
import { GlobalModel } from "@/models";
|
||||
import ReactDOM from "react-dom";
|
||||
import { For } from "tsx-control-statements/components";
|
||||
import * as mobx from "mobx";
|
||||
|
||||
const VALID_CSS_VARIABLES = [
|
||||
"--term-black",
|
||||
"--term-red",
|
||||
"--term-green",
|
||||
"--term-yellow",
|
||||
"--term-blue",
|
||||
"--term-magenta",
|
||||
"--term-cyan",
|
||||
"--term-white",
|
||||
"--term-bright-black",
|
||||
"--term-bright-red",
|
||||
"--term-bright-green",
|
||||
"--term-bright-yellow",
|
||||
"--term-bright-blue",
|
||||
"--term-bright-magenta",
|
||||
"--term-bright-cyan",
|
||||
"--term-bright-white",
|
||||
"--term-gray",
|
||||
"--term-cmdtext",
|
||||
"--term-foreground",
|
||||
"--term-background",
|
||||
"--term-selection-background",
|
||||
"--term-cursor-accent",
|
||||
];
|
||||
|
||||
@mobxReact.observer
|
||||
class TermStyle extends React.Component<{
|
||||
themeName: string;
|
||||
selector: string;
|
||||
}> {
|
||||
componentDidMount() {
|
||||
GlobalModel.bumpTermRenderVersion();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
GlobalModel.bumpTermRenderVersion();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.themeName !== this.props.themeName || prevProps.selector !== this.props.selector) {
|
||||
GlobalModel.bumpTermRenderVersion();
|
||||
}
|
||||
}
|
||||
|
||||
isValidCSSColor(color) {
|
||||
const element = document.createElement("div");
|
||||
element.style.color = color;
|
||||
return element.style.color !== "";
|
||||
}
|
||||
|
||||
camelCaseToKebabCase(str) {
|
||||
return str.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
|
||||
}
|
||||
|
||||
getStyleRules() {
|
||||
const { selector, themeName } = this.props;
|
||||
const termThemeOptions = GlobalModel.getTermThemes();
|
||||
if (!(themeName in termThemeOptions)) {
|
||||
return null;
|
||||
}
|
||||
const theme = termThemeOptions[themeName];
|
||||
if (!theme) {
|
||||
return null;
|
||||
}
|
||||
const styleProperties = Object.entries(theme)
|
||||
.filter(([key, value]) => {
|
||||
const cssVarName = `--term-${this.camelCaseToKebabCase(key)}`;
|
||||
return VALID_CSS_VARIABLES.includes(cssVarName) && this.isValidCSSColor(value);
|
||||
})
|
||||
.map(([key, value]) => `--term-${key}: ${value};`)
|
||||
.join(" ");
|
||||
|
||||
if (!styleProperties) {
|
||||
return null;
|
||||
}
|
||||
return `${selector} { ${styleProperties} }`;
|
||||
}
|
||||
|
||||
render() {
|
||||
const styleRules = this.getStyleRules();
|
||||
if (!styleRules) {
|
||||
return null;
|
||||
}
|
||||
return ReactDOM.createPortal(<style>{styleRules}</style>, document.head);
|
||||
}
|
||||
}
|
||||
|
||||
@mobxReact.observer
|
||||
class TermStyleList extends React.Component<{ onRendered: () => void }, {}> {
|
||||
componentDidMount(): void {
|
||||
this.props.onRendered();
|
||||
}
|
||||
|
||||
getSelector(themeKey: string) {
|
||||
const sessions = GlobalModel.getSessionNames();
|
||||
const screens = GlobalModel.getScreenNames();
|
||||
|
||||
if (themeKey === "root") {
|
||||
return ":root";
|
||||
} else if (themeKey in screens) {
|
||||
return `.main-content [data-screenid="${themeKey}"]`;
|
||||
} else if (themeKey in sessions) {
|
||||
return `.main-content [data-sessionid="${themeKey}"]`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
render() {
|
||||
const termTheme = GlobalModel.getTermThemeSettings();
|
||||
const themeKey = null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<For index="idx" each="themeKey" of={Object.keys(termTheme)}>
|
||||
<TermStyle key={themeKey} themeName={termTheme[themeKey]} selector={this.getSelector(themeKey)} />
|
||||
</For>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export { TermStyleList };
|
@ -27,6 +27,7 @@ interface TextFieldProps {
|
||||
maxLength?: number;
|
||||
autoFocus?: boolean;
|
||||
disabled?: boolean;
|
||||
isNumber?: boolean;
|
||||
}
|
||||
|
||||
interface TextFieldState {
|
||||
@ -108,9 +109,13 @@ class TextField extends React.Component<TextFieldProps, TextFieldState> {
|
||||
|
||||
@boundMethod
|
||||
handleInputChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const { required, onChange } = this.props;
|
||||
const { required, onChange, isNumber } = this.props;
|
||||
const inputValue = e.target.value;
|
||||
|
||||
if (isNumber && inputValue !== "" && !/^\d*$/.test(inputValue)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if value is empty and the field is required
|
||||
if (required && !inputValue) {
|
||||
this.setState({ error: true, hasContent: false });
|
||||
|
@ -7,7 +7,7 @@ import * as mobx from "mobx";
|
||||
import { boundMethod } from "autobind-decorator";
|
||||
import { If } from "tsx-control-statements/components";
|
||||
import { GlobalModel, GlobalCommandRunner, RemotesModel } from "@/models";
|
||||
import { Modal, TextField, NumberField, InputDecoration, Dropdown, PasswordField, Tooltip } from "@/elements";
|
||||
import { Modal, TextField, InputDecoration, Dropdown, PasswordField, Tooltip } from "@/elements";
|
||||
import * as util from "@/util/util";
|
||||
|
||||
import "./createremoteconn.less";
|
||||
@ -236,11 +236,12 @@ class CreateRemoteConnModal extends React.Component<{}, {}> {
|
||||
/>
|
||||
</div>
|
||||
<div className="port-section">
|
||||
<NumberField
|
||||
<TextField
|
||||
label="Port"
|
||||
placeholder="22"
|
||||
value={this.tempPort.get()}
|
||||
onChange={this.handleChangePort}
|
||||
isNumber={true}
|
||||
decoration={{
|
||||
endDecoration: (
|
||||
<InputDecoration>
|
||||
|
@ -156,7 +156,7 @@ class ScreenSettingsModal extends React.Component<{}, {}> {
|
||||
|
||||
@boundMethod
|
||||
handleChangeTermTheme(theme: string): void {
|
||||
const currTheme = GlobalModel.getTermTheme()[this.screenId];
|
||||
const currTheme = GlobalModel.getTermThemeSettings()[this.screenId];
|
||||
if (currTheme == theme) {
|
||||
return;
|
||||
}
|
||||
@ -175,13 +175,8 @@ class ScreenSettingsModal extends React.Component<{}, {}> {
|
||||
if (screen == null) {
|
||||
return null;
|
||||
}
|
||||
let color: string = null;
|
||||
let icon: string = null;
|
||||
let index: number = 0;
|
||||
const curRemote = GlobalModel.getRemote(GlobalModel.getActiveScreen().getCurRemoteInstance().remoteid);
|
||||
const termThemes = getTermThemes(GlobalModel.termThemes);
|
||||
const currTermTheme = GlobalModel.getTermTheme()[this.screenId] ?? termThemes[0].label;
|
||||
|
||||
const termThemes = getTermThemes(GlobalModel.termThemes.get());
|
||||
const currTermTheme = GlobalModel.getTermThemeSettings()[this.screenId] ?? termThemes[0].label;
|
||||
return (
|
||||
<Modal className="screen-settings-modal">
|
||||
<Modal.Header onClose={this.closeModal} title={`Tab Settings (${screen.name.get()})`} />
|
||||
|
@ -80,7 +80,7 @@ class SessionSettingsModal extends React.Component<{}, {}> {
|
||||
|
||||
@boundMethod
|
||||
handleChangeTermTheme(theme: string): void {
|
||||
const currTheme = GlobalModel.getTermTheme()[this.sessionId];
|
||||
const currTheme = GlobalModel.getTermThemeSettings()[this.sessionId];
|
||||
if (currTheme == theme) {
|
||||
return;
|
||||
}
|
||||
@ -99,8 +99,8 @@ class SessionSettingsModal extends React.Component<{}, {}> {
|
||||
if (this.session == null) {
|
||||
return null;
|
||||
}
|
||||
const termThemes = getTermThemes(GlobalModel.termThemes);
|
||||
const currTermTheme = GlobalModel.getTermTheme()[this.sessionId] ?? termThemes[0].label;
|
||||
const termThemes = getTermThemes(GlobalModel.termThemes.get());
|
||||
const currTermTheme = GlobalModel.getTermThemeSettings()[this.sessionId] ?? termThemes[0].label;
|
||||
|
||||
return (
|
||||
<Modal className="session-settings-modal">
|
||||
|
@ -649,11 +649,6 @@ class LineContainer extends React.Component<{ historyId: string; width: number }
|
||||
this.line = hvm.getLineById(this.historyItem.lineid);
|
||||
}
|
||||
|
||||
componentDidMount(): void {
|
||||
GlobalModel.bumpTermRenderVersion();
|
||||
GlobalModel.termThemeSrcEl.set(null);
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
handleHeightChange(lineNum: number, newHeight: number, oldHeight: number): void {
|
||||
return;
|
||||
|
@ -31,6 +31,8 @@
|
||||
flex-direction: row;
|
||||
color: var(--app-warning-color);
|
||||
align-items: center;
|
||||
padding: var(--termpad) calc(var(--termpad) * 2) 0 calc(var(--termpad) * 2);
|
||||
margin-left: 2px;
|
||||
|
||||
.wave-button,
|
||||
.button {
|
||||
|
@ -13,6 +13,7 @@ import { getMonoFontSize } from "@/util/textmeasure";
|
||||
import * as appconst from "@/app/appconst";
|
||||
|
||||
type OV<T> = mobx.IObservableValue<T>;
|
||||
const MaxInputLength = 10 * 1024;
|
||||
|
||||
function pageSize(div: any): number {
|
||||
if (div == null) {
|
||||
@ -616,7 +617,7 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: ()
|
||||
<CmdInputKeybindings inputObject={this}></CmdInputKeybindings>
|
||||
</If>
|
||||
<If condition={renderHistoryKeybindings}>
|
||||
<HistoryKeybindings inputObject={this}></HistoryKeybindings>
|
||||
<HistoryKeybindings></HistoryKeybindings>
|
||||
</If>
|
||||
|
||||
<If condition={!util.isBlank(shellType)}>
|
||||
@ -637,6 +638,7 @@ class TextAreaInput extends React.Component<{ screen: Screen; onHeightChange: ()
|
||||
onChange={this.onChange}
|
||||
onSelect={this.onSelect}
|
||||
placeholder="Type here..."
|
||||
maxLength={MaxInputLength}
|
||||
className={cn("textarea", { "display-disabled": auxViewFocused })}
|
||||
></textarea>
|
||||
<input
|
||||
|
@ -184,10 +184,12 @@ class ScreenView extends React.Component<{ session: Session; screen: Screen }, {
|
||||
winWidth = screenWidth - realWidth + "px";
|
||||
sidebarWidth = realWidth - MagicLayout.ScreenSidebarWidthPadding + "px";
|
||||
}
|
||||
const termRenderVersion = GlobalModel.termRenderVersion.get();
|
||||
|
||||
return (
|
||||
<div className="screen-view" data-screenid={screen.screenId} ref={this.screenViewRef}>
|
||||
<div className="screen-view" id={screen.screenId} data-screenid={screen.screenId} ref={this.screenViewRef}>
|
||||
<ScreenWindowView
|
||||
key={screen.screenId + ":" + fontSize + ":" + dprStr}
|
||||
key={screen.screenId + ":" + fontSize + ":" + dprStr + ":" + termRenderVersion}
|
||||
session={session}
|
||||
screen={screen}
|
||||
width={winWidth}
|
||||
|
@ -144,7 +144,7 @@ class TabSettings extends React.Component<{ screen: Screen }, {}> {
|
||||
@boundMethod
|
||||
handleChangeTermTheme(theme: string): void {
|
||||
const { screenId } = this.props.screen;
|
||||
const currTheme = GlobalModel.getTermTheme()[screenId];
|
||||
const currTheme = GlobalModel.getTermThemeSettings()[screenId];
|
||||
if (currTheme == theme) {
|
||||
return;
|
||||
}
|
||||
@ -155,8 +155,8 @@ class TabSettings extends React.Component<{ screen: Screen }, {}> {
|
||||
render() {
|
||||
const { screen } = this.props;
|
||||
const rptr = screen.curRemote.get();
|
||||
const termThemes = getTermThemes(GlobalModel.termThemes);
|
||||
const currTermTheme = GlobalModel.getTermTheme()[screen.screenId] ?? termThemes[0].label;
|
||||
const termThemes = getTermThemes(GlobalModel.termThemes.get());
|
||||
const currTermTheme = GlobalModel.getTermThemeSettings()[screen.screenId] ?? termThemes[0].label;
|
||||
return (
|
||||
<div className="newtab-container">
|
||||
<div className="newtab-section name-section">
|
||||
@ -212,59 +212,6 @@ class TabSettings extends React.Component<{ screen: Screen }, {}> {
|
||||
@mobxReact.observer
|
||||
class WorkspaceView extends React.Component<{}, {}> {
|
||||
sessionRef = React.createRef<HTMLDivElement>();
|
||||
theme: string;
|
||||
themeReactionDisposer: mobx.IReactionDisposer;
|
||||
|
||||
componentDidMount() {
|
||||
this.setupThemeReaction();
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
this.setupThemeReaction();
|
||||
}
|
||||
|
||||
setupThemeReaction() {
|
||||
if (this.themeReactionDisposer) {
|
||||
this.themeReactionDisposer();
|
||||
}
|
||||
|
||||
// This handles session and screen-level terminal theming.
|
||||
// Ideally, screen-level theming should be handled in the inner-level component, but
|
||||
// the frequent mounting and unmounting of the screen view make it really difficult to work.
|
||||
this.themeReactionDisposer = mobx.reaction(
|
||||
() => {
|
||||
return {
|
||||
termTheme: GlobalModel.getTermTheme(),
|
||||
session: GlobalModel.getActiveSession(),
|
||||
screen: GlobalModel.getActiveScreen(),
|
||||
};
|
||||
},
|
||||
({ termTheme, session, screen }) => {
|
||||
let currTheme = termTheme[session.sessionId];
|
||||
if (termTheme[screen.screenId]) {
|
||||
currTheme = termTheme[screen.screenId];
|
||||
}
|
||||
if (session && currTheme !== this.theme && this.sessionRef.current) {
|
||||
const reset = currTheme == null;
|
||||
const theme = currTheme ?? this.theme;
|
||||
const themeSrcEl = reset ? null : this.sessionRef.current;
|
||||
const rtn = GlobalModel.updateTermTheme(this.sessionRef.current, theme, reset);
|
||||
rtn.then(() => {
|
||||
GlobalModel.termThemeSrcEl.set(themeSrcEl);
|
||||
}).then(() => {
|
||||
GlobalModel.bumpTermRenderVersion();
|
||||
});
|
||||
this.theme = currTheme;
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.themeReactionDisposer) {
|
||||
this.themeReactionDisposer();
|
||||
}
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
toggleTabSettings() {
|
||||
@ -281,15 +228,14 @@ class WorkspaceView extends React.Component<{}, {}> {
|
||||
sessionId = session.sessionId;
|
||||
activeScreen = session.getActiveScreen();
|
||||
}
|
||||
|
||||
const isHidden = GlobalModel.activeMainView.get() != "session";
|
||||
const mainSidebarModel = GlobalModel.mainSidebarModel;
|
||||
const termRenderVersion = GlobalModel.termRenderVersion.get();
|
||||
const showTabSettings = GlobalModel.tabSettingsOpen.get();
|
||||
return (
|
||||
<div
|
||||
ref={this.sessionRef}
|
||||
className={cn("mainview", "session-view", { "is-hidden": isHidden })}
|
||||
id={sessionId}
|
||||
data-sessionid={sessionId}
|
||||
style={{
|
||||
width: `${window.innerWidth - mainSidebarModel.getWidth()}px`,
|
||||
@ -311,11 +257,7 @@ class WorkspaceView extends React.Component<{}, {}> {
|
||||
</div>
|
||||
</If>
|
||||
<ErrorBoundary key="eb">
|
||||
<ScreenView
|
||||
key={`screenview-${sessionId}-${termRenderVersion}`}
|
||||
session={session}
|
||||
screen={activeScreen}
|
||||
/>
|
||||
<ScreenView key={`screenview-${sessionId}`} session={session} screen={activeScreen} />
|
||||
<If condition={activeScreen != null}>
|
||||
<CmdInput key={"cmdinput-" + sessionId} />
|
||||
</If>
|
||||
|
@ -419,6 +419,17 @@ function mainResizeHandler(_: any, win: Electron.BrowserWindow) {
|
||||
});
|
||||
}
|
||||
|
||||
function mainPowerHandler(status: string) {
|
||||
const url = new URL(getBaseHostPort() + "/api/power-monitor");
|
||||
const fetchHeaders = getFetchHeaders();
|
||||
const body = { status: status };
|
||||
fetch(url, { method: "post", body: JSON.stringify(body), headers: fetchHeaders })
|
||||
.then((resp) => handleJsonFetchResponse(url, resp))
|
||||
.catch((err) => {
|
||||
console.log("error setting power monitor state", err);
|
||||
});
|
||||
}
|
||||
|
||||
function calcBounds(clientData: ClientDataType): Electron.Rectangle {
|
||||
const primaryDisplay = electron.screen.getPrimaryDisplay();
|
||||
const pdBounds = primaryDisplay.bounds;
|
||||
@ -946,3 +957,5 @@ function configureAutoUpdater(enabled: boolean) {
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
electron.powerMonitor.on("suspend", () => mainPowerHandler("suspend"));
|
||||
|
@ -381,29 +381,40 @@ class CommandRunner {
|
||||
return GlobalModel.submitCommand("client", "set", null, kwargs, interactive);
|
||||
}
|
||||
|
||||
setGlobalTermTheme(theme: string, interactive: boolean): Promise<CommandRtnType> {
|
||||
setRootTermTheme(theme: string, interactive: boolean): Promise<CommandRtnType> {
|
||||
let ftheme = theme;
|
||||
if (ftheme == "inherit") {
|
||||
ftheme = "";
|
||||
}
|
||||
let kwargs = {
|
||||
nohist: "1",
|
||||
termtheme: theme,
|
||||
termtheme: ftheme,
|
||||
};
|
||||
return GlobalModel.submitCommand("client", "set", null, kwargs, interactive);
|
||||
}
|
||||
|
||||
setSessionTermTheme(sessionId: string, name: string, interactive: boolean): Promise<CommandRtnType> {
|
||||
console.log("setSessionTermTheme-------");
|
||||
let fname = name;
|
||||
if (name == "inherit") {
|
||||
fname = "";
|
||||
}
|
||||
let kwargs = {
|
||||
nohist: "1",
|
||||
id: sessionId,
|
||||
name: name,
|
||||
name: fname,
|
||||
};
|
||||
return GlobalModel.submitCommand("session", "termtheme", null, kwargs, interactive);
|
||||
}
|
||||
|
||||
setScreenTermTheme(screenId: string, name: string, interactive: boolean): Promise<CommandRtnType> {
|
||||
let fname = name;
|
||||
if (name == "inherit") {
|
||||
fname = "";
|
||||
}
|
||||
let kwargs = {
|
||||
nohist: "1",
|
||||
id: screenId,
|
||||
name: name,
|
||||
name: fname,
|
||||
};
|
||||
return GlobalModel.submitCommand("screen", "termtheme", null, kwargs, interactive);
|
||||
}
|
||||
@ -413,6 +424,7 @@ class CommandRunner {
|
||||
apitoken?: string;
|
||||
maxtokens?: string;
|
||||
baseurl?: string;
|
||||
timeout?: string;
|
||||
}): Promise<CommandRtnType> {
|
||||
let kwargs = {
|
||||
nohist: "1",
|
||||
@ -429,6 +441,9 @@ class CommandRunner {
|
||||
if (opts.baseurl != null) {
|
||||
kwargs["openaibaseurl"] = opts.baseurl;
|
||||
}
|
||||
if (opts.timeout != null) {
|
||||
kwargs["openaitimeout"] = opts.timeout;
|
||||
}
|
||||
return GlobalModel.submitCommand("client", "set", null, kwargs, false);
|
||||
}
|
||||
|
||||
@ -452,6 +467,31 @@ class CommandRunner {
|
||||
return GlobalModel.submitCommand("client", "setrightsidebar", null, kwargs, false);
|
||||
}
|
||||
|
||||
setSudoPwStore(store: string): Promise<CommandRtnType> {
|
||||
let kwargs = {
|
||||
nohist: "1",
|
||||
sudopwstore: store,
|
||||
};
|
||||
return GlobalModel.submitCommand("client", "set", null, kwargs, false);
|
||||
}
|
||||
|
||||
setSudoPwTimeout(timeout: string): Promise<CommandRtnType> {
|
||||
let kwargs = {
|
||||
nohist: "1",
|
||||
sudopwtimeout: timeout,
|
||||
};
|
||||
return GlobalModel.submitCommand("client", "set", null, kwargs, false);
|
||||
}
|
||||
|
||||
setSudoPwClearOnSleep(clear: boolean): Promise<CommandRtnType> {
|
||||
let kwargs = {
|
||||
nohist: "1",
|
||||
sudopwclearonsleep: String(clear),
|
||||
};
|
||||
console.log(kwargs);
|
||||
return GlobalModel.submitCommand("client", "set", null, kwargs, false);
|
||||
}
|
||||
|
||||
editBookmark(bookmarkId: string, desc: string, cmdstr: string) {
|
||||
let kwargs = {
|
||||
nohist: "1",
|
||||
|
@ -137,22 +137,16 @@ class Model {
|
||||
renderVersion: OV<number> = mobx.observable.box(0, {
|
||||
name: "renderVersion",
|
||||
});
|
||||
|
||||
appUpdateStatus = mobx.observable.box(getApi().getAppUpdateStatus(), {
|
||||
name: "appUpdateStatus",
|
||||
});
|
||||
|
||||
termThemes: OMap<string, string> = mobx.observable.array([], {
|
||||
termThemes: OV<TermThemesType> = mobx.observable.box(null, {
|
||||
name: "terminalThemes",
|
||||
deep: false,
|
||||
});
|
||||
termThemeSrcEl: OV<HTMLElement> = mobx.observable.box(null, {
|
||||
name: "termThemeSrcEl",
|
||||
});
|
||||
termRenderVersion: OV<number> = mobx.observable.box(0, {
|
||||
name: "termRenderVersion",
|
||||
});
|
||||
currGlobalTermTheme: string;
|
||||
|
||||
private constructor() {
|
||||
this.clientId = getApi().getId();
|
||||
@ -166,7 +160,6 @@ class Model {
|
||||
this.ws.reconnect();
|
||||
this.keybindManager = new KeybindManager(this);
|
||||
this.readConfigKeybindings();
|
||||
this.fetchTerminalThemes();
|
||||
this.initSystemKeybindings();
|
||||
this.initAppKeybindings();
|
||||
this.inputModel = new InputModel(this);
|
||||
@ -239,42 +232,6 @@ class Model {
|
||||
}
|
||||
}
|
||||
|
||||
fetchTerminalThemes() {
|
||||
const url = new URL(this.getBaseHostPort() + "/config/terminal-themes");
|
||||
fetch(url, { method: "get", body: null, headers: this.getFetchHeaders() })
|
||||
.then((resp) => {
|
||||
if (resp.status == 404) {
|
||||
return [];
|
||||
} else if (!resp.ok) {
|
||||
util.handleNotOkResp(resp, url);
|
||||
}
|
||||
return resp.json();
|
||||
})
|
||||
.then((themes) => {
|
||||
const tt = themes.map((theme) => theme.name.split(".")[0]);
|
||||
this.termThemes.replace(tt);
|
||||
});
|
||||
}
|
||||
|
||||
updateTermTheme(element: HTMLElement, themeFileName: string, reset: boolean) {
|
||||
const url = new URL(this.getBaseHostPort() + `/config/terminal-themes/${themeFileName}.json`);
|
||||
return fetch(url, { method: "get", body: null, headers: this.getFetchHeaders() })
|
||||
.then((resp) => resp.json())
|
||||
.then((themeVars: TermThemeType) => {
|
||||
Object.keys(themeVars).forEach((key) => {
|
||||
if (reset) {
|
||||
this.resetStyleVar(element, `--term-${key}`);
|
||||
} else {
|
||||
this.resetStyleVar(element, `--term-${key}`);
|
||||
this.setStyleVar(element, `--term-${key}`, themeVars[key]);
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`error applying theme: ${themeFileName}`, error);
|
||||
});
|
||||
}
|
||||
|
||||
bumpTermRenderVersion() {
|
||||
mobx.action(() => {
|
||||
this.termRenderVersion.set(this.termRenderVersion.get() + 1);
|
||||
@ -486,10 +443,10 @@ class Model {
|
||||
}
|
||||
}
|
||||
|
||||
getTermTheme(): TermThemeType {
|
||||
getTermThemeSettings(): TermThemeSettingsType {
|
||||
let cdata = this.clientData.get();
|
||||
if (cdata?.feopts?.termtheme) {
|
||||
return mobx.toJS(cdata.feopts.termtheme);
|
||||
if (cdata?.feopts?.termthemesettings) {
|
||||
return mobx.toJS(cdata.feopts.termthemesettings);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
@ -498,6 +455,22 @@ class Model {
|
||||
return this.termFontSize.get();
|
||||
}
|
||||
|
||||
getSudoPwStore(): string {
|
||||
let cdata = this.clientData.get();
|
||||
return cdata?.feopts?.sudopwstore ?? appconst.DefaultSudoPwStore;
|
||||
}
|
||||
|
||||
getSudoPwTimeout(): number {
|
||||
let cdata = this.clientData.get();
|
||||
const sudoPwTimeoutMs = cdata?.feopts?.sudopwtimeoutms ?? appconst.DefaultSudoPwTimeoutMs;
|
||||
return sudoPwTimeoutMs / 1000 / 60;
|
||||
}
|
||||
|
||||
getSudoPwClearOnSleep(): boolean {
|
||||
let cdata = this.clientData.get();
|
||||
return !cdata?.feopts?.nosudopwclearonsleep;
|
||||
}
|
||||
|
||||
updateTermFontSizeVars() {
|
||||
let lhe = this.recomputeLineHeightEnv();
|
||||
mobx.action(() => {
|
||||
@ -937,6 +910,27 @@ class Model {
|
||||
}
|
||||
}
|
||||
|
||||
mergeTermThemes(termThemes: TermThemesType) {
|
||||
mobx.action(() => {
|
||||
if (this.termThemes.get() == null) {
|
||||
this.termThemes.set(termThemes);
|
||||
return;
|
||||
}
|
||||
for (const [themeName, theme] of Object.entries(termThemes)) {
|
||||
if (theme == null) {
|
||||
delete this.termThemes.get()[themeName];
|
||||
continue;
|
||||
}
|
||||
this.termThemes.get()[themeName] = theme;
|
||||
}
|
||||
})();
|
||||
this.bumpTermRenderVersion();
|
||||
}
|
||||
|
||||
getTermThemes(): TermThemesType {
|
||||
return this.termThemes.get();
|
||||
}
|
||||
|
||||
updateScreenStatusIndicators(screenStatusIndicators: ScreenStatusIndicatorUpdateType[]) {
|
||||
for (const update of screenStatusIndicators) {
|
||||
this.getScreenById_single(update.screenid)?.setStatusIndicator(update.status);
|
||||
@ -983,7 +977,7 @@ class Model {
|
||||
if (update.connect.screenstatusindicators != null) {
|
||||
this.updateScreenStatusIndicators(update.connect.screenstatusindicators);
|
||||
}
|
||||
|
||||
this.mergeTermThemes(update.connect.termthemes ?? {});
|
||||
this.sessionListLoaded.set(true);
|
||||
this.remotesLoaded.set(true);
|
||||
} else if (update.screen != null) {
|
||||
@ -1056,6 +1050,8 @@ class Model {
|
||||
} else if (update.userinputrequest != null) {
|
||||
const userInputRequest: UserInputRequest = update.userinputrequest;
|
||||
this.modalsModel.pushModal(appconst.USER_INPUT, userInputRequest);
|
||||
} else if (update.termthemes != null) {
|
||||
this.mergeTermThemes(update.termthemes);
|
||||
} else if (update.sessiontombstone != null || update.screentombstone != null) {
|
||||
// nothing (ignore)
|
||||
} else {
|
||||
@ -1309,9 +1305,6 @@ class Model {
|
||||
newTheme = appconst.DefaultTheme;
|
||||
}
|
||||
const themeUpdated = newTheme != this.getThemeSource();
|
||||
const oldTermTheme = this.getTermTheme();
|
||||
const newTermTheme = clientData?.feopts?.termtheme;
|
||||
const ttUpdated = this.termThemeUpdated(newTermTheme, oldTermTheme);
|
||||
mobx.action(() => {
|
||||
this.clientData.set(clientData);
|
||||
})();
|
||||
@ -1332,36 +1325,6 @@ class Model {
|
||||
getApi().setNativeThemeSource(newTheme);
|
||||
this.bumpRenderVersion();
|
||||
}
|
||||
// Only for global terminal theme. For session and screen terminal theme,
|
||||
// they are handled in workspace view.
|
||||
if (newTermTheme) {
|
||||
const el = document.documentElement;
|
||||
const globaltt = newTermTheme["global"] ?? this.currGlobalTermTheme;
|
||||
const reset = newTermTheme["global"] == null;
|
||||
if (globaltt) {
|
||||
const rtn = this.updateTermTheme(el, globaltt, reset);
|
||||
rtn.then(() => {
|
||||
if (ttUpdated) {
|
||||
this.bumpTermRenderVersion();
|
||||
}
|
||||
});
|
||||
this.currGlobalTermTheme = globaltt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
termThemeUpdated(newTermTheme, oldTermTheme) {
|
||||
for (const key in oldTermTheme) {
|
||||
if (!(key in newTermTheme)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
for (const key in newTermTheme) {
|
||||
if (!oldTermTheme[key] || oldTermTheme[key] !== newTermTheme[key]) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
submitCommandPacket(cmdPk: FeCmdPacketType, interactive: boolean): Promise<CommandRtnType> {
|
||||
|
@ -5,6 +5,7 @@ import * as mobx from "mobx";
|
||||
import { sprintf } from "sprintf-js";
|
||||
import { boundMethod } from "autobind-decorator";
|
||||
import dayjs from "dayjs";
|
||||
import * as appconst from "@/app/appconst";
|
||||
|
||||
class WSControl {
|
||||
wsConn: any;
|
||||
@ -171,7 +172,13 @@ class WSControl {
|
||||
if (!this.open.get()) {
|
||||
return;
|
||||
}
|
||||
this.wsConn.send(JSON.stringify(data));
|
||||
let msg = JSON.stringify(data);
|
||||
const byteSize = new Blob([msg]).size;
|
||||
if (byteSize > appconst.MaxWebSocketSendSize) {
|
||||
console.log("ws message too large", byteSize, data.type, msg.substring(0, 100));
|
||||
return;
|
||||
}
|
||||
this.wsConn.send(msg);
|
||||
}
|
||||
|
||||
pushMessage(data: any) {
|
||||
|
@ -229,7 +229,6 @@ class SourceCodeRenderer extends React.Component<
|
||||
}, 2000);
|
||||
editor.onKeyDown((e: MonacoTypes.IKeyboardEvent) => {
|
||||
const waveEvent = adaptFromReactOrNativeKeyEvent(e.browserEvent);
|
||||
console.log("keydown?", waveEvent);
|
||||
if (
|
||||
GlobalModel.keybindManager.checkKeysPressed(waveEvent, [
|
||||
"codeedit:save",
|
||||
@ -568,7 +567,6 @@ class SourceCodeRenderer extends React.Component<
|
||||
);
|
||||
|
||||
const theme = `wave-theme-${GlobalModel.isDarkTheme.get() ? "dark" : "light"}`;
|
||||
console.log("lineis selected:", lineIsSelected.get());
|
||||
return (
|
||||
<div className="code-renderer">
|
||||
<If condition={lineIsSelected.get()}>
|
||||
|
@ -51,10 +51,9 @@ type TermWrapOpts = {
|
||||
onUpdateContentHeight: (termContext: RendererContext, height: number) => void;
|
||||
};
|
||||
|
||||
function getThemeFromCSSVars(themeSrcEl: HTMLElement): ITheme {
|
||||
function getThemeFromCSSVars(el: Element): ITheme {
|
||||
const theme: ITheme = {};
|
||||
const tse = themeSrcEl ?? document.documentElement;
|
||||
let rootStyle = getComputedStyle(tse);
|
||||
const rootStyle = getComputedStyle(el);
|
||||
theme.foreground = rootStyle.getPropertyValue("--term-foreground");
|
||||
theme.background = rootStyle.getPropertyValue("--term-background");
|
||||
theme.black = rootStyle.getPropertyValue("--term-black");
|
||||
@ -131,8 +130,7 @@ class TermWrap {
|
||||
let cols = windowWidthToCols(opts.winSize.width, opts.fontSize);
|
||||
this.termSize = { rows: opts.termOpts.rows, cols: cols };
|
||||
}
|
||||
const themeSrcEl = GlobalModel.termThemeSrcEl.get();
|
||||
let theme = getThemeFromCSSVars(themeSrcEl);
|
||||
let theme = getThemeFromCSSVars(this.connectedElem);
|
||||
this.terminal = new Terminal({
|
||||
rows: this.termSize.rows,
|
||||
cols: this.termSize.cols,
|
||||
|
16
src/types/custom.d.ts
vendored
16
src/types/custom.d.ts
vendored
@ -345,6 +345,7 @@ declare global {
|
||||
screenstatusindicators: ScreenStatusIndicatorUpdateType[];
|
||||
screennumrunningcommands: ScreenNumRunningCommandsUpdateType[];
|
||||
activesessionid: string;
|
||||
termthemes: TermThemesType;
|
||||
};
|
||||
|
||||
type BookmarksUpdateType = {
|
||||
@ -386,6 +387,13 @@ declare global {
|
||||
userinputrequest?: UserInputRequest;
|
||||
screentombstone?: any;
|
||||
sessiontombstone?: any;
|
||||
termthemes?: TermThemesType;
|
||||
};
|
||||
|
||||
type TermThemesType = {
|
||||
[key: string]: {
|
||||
[innerKey: string]: string;
|
||||
};
|
||||
};
|
||||
|
||||
type HistoryViewDataType = {
|
||||
@ -581,7 +589,7 @@ declare global {
|
||||
data: Uint8Array;
|
||||
};
|
||||
|
||||
type TermThemeType = {
|
||||
type TermThemeSettingsType = {
|
||||
[k: string]: string | null;
|
||||
};
|
||||
|
||||
@ -589,7 +597,10 @@ declare global {
|
||||
termfontsize: number;
|
||||
termfontfamily: string;
|
||||
theme: NativeThemeSource;
|
||||
termtheme: TermThemeType;
|
||||
termthemesettings: TermThemeSettingsType;
|
||||
sudopwstore: "on" | "off" | "notimeout";
|
||||
sudopwtimeoutms: number;
|
||||
nosudopwclearonsleep: boolean;
|
||||
};
|
||||
|
||||
type ConfirmFlagsType = {
|
||||
@ -651,6 +662,7 @@ declare global {
|
||||
maxtokens?: number;
|
||||
maxchoices?: number;
|
||||
baseurl?: string;
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
type PlaybookType = {
|
||||
|
@ -1,10 +1,13 @@
|
||||
function getTermThemes(termThemes: string[], noneLabel = "Inherit"): DropdownItem[] {
|
||||
function getTermThemes(termThemeOptions: string[], noneLabel = "Inherit"): DropdownItem[] {
|
||||
if (!termThemeOptions) {
|
||||
return [];
|
||||
}
|
||||
const tt: DropdownItem[] = [];
|
||||
tt.push({
|
||||
label: noneLabel,
|
||||
value: null,
|
||||
value: "inherit",
|
||||
});
|
||||
for (const themeName of termThemes) {
|
||||
for (const themeName of Object.keys(termThemeOptions)) {
|
||||
tt.push({
|
||||
label: themeName,
|
||||
value: themeName,
|
||||
|
@ -29,7 +29,7 @@ const SSHCommandVarName = "SSH_COMMAND"
|
||||
const MShellDebugVarName = "MSHELL_DEBUG"
|
||||
const SessionsDirBaseName = "sessions"
|
||||
const RcFilesDirBaseName = "rcfiles"
|
||||
const MShellVersion = "v0.6.0"
|
||||
const MShellVersion = "v0.7.0"
|
||||
const RemoteIdFile = "remoteid"
|
||||
const DefaultMShellInstallBinDir = "/opt/mshell/bin"
|
||||
const LogFileName = "mshell.log"
|
||||
|
@ -197,11 +197,7 @@ func StreamCommandWithExtraFd(ctx context.Context, ecmd *exec.Cmd, outputCh chan
|
||||
go func() {
|
||||
// ignore error (/dev/ptmx has read error when process is done)
|
||||
defer outputWg.Done()
|
||||
err := utilfn.CopyToChannel(outputCh, cmdPty)
|
||||
if err != nil {
|
||||
errStr := fmt.Sprintf("\r\nerror reading from pty: %v\r\n", err)
|
||||
outputCh <- []byte(errStr)
|
||||
}
|
||||
utilfn.CopyToChannel(outputCh, cmdPty)
|
||||
}()
|
||||
go func() {
|
||||
defer outputWg.Done()
|
||||
|
@ -37,6 +37,7 @@ import (
|
||||
"github.com/wavetermdev/waveterm/waveshell/pkg/wlog"
|
||||
"github.com/wavetermdev/waveterm/wavesrv/pkg/bufferedpipe"
|
||||
"github.com/wavetermdev/waveterm/wavesrv/pkg/cmdrunner"
|
||||
"github.com/wavetermdev/waveterm/wavesrv/pkg/configstore"
|
||||
"github.com/wavetermdev/waveterm/wavesrv/pkg/ephemeral"
|
||||
"github.com/wavetermdev/waveterm/wavesrv/pkg/pcloud"
|
||||
"github.com/wavetermdev/waveterm/wavesrv/pkg/releasechecker"
|
||||
@ -156,6 +157,7 @@ func HandleWs(w http.ResponseWriter, r *http.Request) {
|
||||
removeWSStateAfterTimeout(clientId, stateConnectTime, WSStateReconnectTime)
|
||||
}()
|
||||
log.Printf("WebSocket opened %s %s\n", state.ClientId, shell.RemoteAddr)
|
||||
|
||||
state.RunWSRead()
|
||||
}
|
||||
|
||||
@ -207,6 +209,33 @@ func HandleSetWinSize(w http.ResponseWriter, r *http.Request) {
|
||||
WriteJsonSuccess(w, true)
|
||||
}
|
||||
|
||||
func HandlePowerMonitor(w http.ResponseWriter, r *http.Request) {
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
var body sstore.PowerMonitorEventType
|
||||
err := decoder.Decode(&body)
|
||||
if err != nil {
|
||||
WriteJsonError(w, fmt.Errorf(ErrorDecodingJson, err))
|
||||
return
|
||||
}
|
||||
cdata, err := sstore.EnsureClientData(r.Context())
|
||||
if err != nil {
|
||||
WriteJsonError(w, err)
|
||||
return
|
||||
}
|
||||
switch body.Status {
|
||||
case "suspend":
|
||||
if !cdata.FeOpts.NoSudoPwClearOnSleep && cdata.FeOpts.SudoPwStore != "notimeout" {
|
||||
for _, proc := range remote.GetRemoteMap() {
|
||||
proc.ClearCachedSudoPw()
|
||||
}
|
||||
}
|
||||
WriteJsonSuccess(w, true)
|
||||
default:
|
||||
WriteJsonError(w, fmt.Errorf("unknown status: %s", body.Status))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// params: fg, active, open
|
||||
func HandleLogActiveState(w http.ResponseWriter, r *http.Request) {
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
@ -976,6 +1005,10 @@ func doShutdown(reason string) {
|
||||
log.Printf("[wave] closing db connection\n")
|
||||
sstore.CloseDB()
|
||||
log.Printf("[wave] *** shutting down local server\n")
|
||||
watcher := configstore.GetWatcher()
|
||||
if watcher != nil {
|
||||
watcher.Close()
|
||||
}
|
||||
time.Sleep(1 * time.Second)
|
||||
syscall.Kill(syscall.Getpid(), syscall.SIGINT)
|
||||
time.Sleep(5 * time.Second)
|
||||
@ -984,7 +1017,6 @@ func doShutdown(reason string) {
|
||||
}
|
||||
|
||||
func configDirHandler(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("running?")
|
||||
configPath := r.URL.Path
|
||||
configFullPath := path.Join(scbase.GetWaveHomeDir(), configPath)
|
||||
dirFile, err := os.Open(configFullPath)
|
||||
@ -1016,6 +1048,13 @@ func configDirHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write(dirListJson)
|
||||
}
|
||||
|
||||
func configWatcher() {
|
||||
watcher := configstore.GetWatcher()
|
||||
if watcher != nil {
|
||||
watcher.Start()
|
||||
}
|
||||
}
|
||||
|
||||
func startupActivityUpdate() {
|
||||
activity := telemetry.ActivityUpdate{
|
||||
NumConns: remote.NumRemotes(),
|
||||
@ -1080,7 +1119,7 @@ func main() {
|
||||
log.Printf("[error] %v\n", err)
|
||||
return
|
||||
}
|
||||
_, err = scbase.EnsureConfigDir()
|
||||
_, err = scbase.EnsureConfigDirs()
|
||||
if err != nil {
|
||||
log.Printf("[error] ensuring config directory: %v\n", err)
|
||||
return
|
||||
@ -1120,6 +1159,7 @@ func main() {
|
||||
startupActivityUpdate()
|
||||
installSignalHandlers()
|
||||
go telemetryLoop()
|
||||
go configWatcher()
|
||||
go stdinReadWatch()
|
||||
go runWebSocketServer()
|
||||
go func() {
|
||||
@ -1136,6 +1176,7 @@ func main() {
|
||||
gr.HandleFunc(bufferedpipe.BufferedPipeGetterUrl, AuthKeyWrapAllowHmac(bufferedpipe.HandleGetBufferedPipeOutput))
|
||||
gr.HandleFunc("/api/get-client-data", AuthKeyWrap(HandleGetClientData))
|
||||
gr.HandleFunc("/api/set-winsize", AuthKeyWrap(HandleSetWinSize))
|
||||
gr.HandleFunc("/api/power-monitor", AuthKeyWrap(HandlePowerMonitor))
|
||||
gr.HandleFunc("/api/log-active-state", AuthKeyWrap(HandleLogActiveState))
|
||||
gr.HandleFunc("/api/read-file", AuthKeyWrapAllowHmac(HandleReadFile))
|
||||
gr.HandleFunc("/api/write-file", AuthKeyWrap(HandleWriteFile)).Methods("POST")
|
||||
|
@ -8,6 +8,7 @@ require (
|
||||
github.com/alessio/shellescape v1.4.1
|
||||
github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2
|
||||
github.com/creack/pty v1.1.18
|
||||
github.com/fsnotify/fsnotify v1.6.0
|
||||
github.com/golang-migrate/migrate/v4 v4.16.2
|
||||
github.com/google/go-github/v60 v60.0.0
|
||||
github.com/google/uuid v1.3.0
|
||||
|
@ -9,6 +9,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/frankban/quicktest v1.14.5 h1:dfYrrRyLtiqT9GyKXgdh+k4inNeTvmGbuSgZ3lx3GhA=
|
||||
github.com/frankban/quicktest v1.14.5/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
|
||||
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
|
||||
github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
|
||||
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
|
||||
github.com/golang-migrate/migrate/v4 v4.16.2 h1:8coYbMKUyInrFk1lfGfRovTLAW7PhWp8qQDT2iKfuoA=
|
||||
@ -63,6 +65,7 @@ golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
|
||||
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
||||
golang.org/x/mod v0.10.0 h1:lFO9qtOdlre5W1jxS3r/4szv2/6iXxScdzjoBMXNhYk=
|
||||
golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
|
||||
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4=
|
||||
|
@ -84,9 +84,9 @@ const TermFontSizeMax = 24
|
||||
|
||||
const TsFormatStr = "2006-01-02 15:04:05"
|
||||
|
||||
const OpenAIPacketTimeout = 10 * time.Second
|
||||
const OpenAIPacketTimeout = 10 * 1000 * time.Millisecond
|
||||
const OpenAIStreamTimeout = 5 * time.Minute
|
||||
const OpenAICloudCompletionTelemetryOffErrorMsg = "To ensure responsible usage and prevent misuse, Wave AI requires telemetry to be enabled when using its free AI features.\n\nIf you prefer not to enable telemetry, you can still access Wave AI's features by providing your own OpenAI API key in the Settings menu. Please note that when using your personal API key, requests will be sent directly to the OpenAI API without being proxied through Wave's servers.\n\nIf you wish to continue using Wave AI's free features, you can easily enable telemetry by running the '/telemetry:on' command in the terminal. This will allow you to access the free AI features while helping to protect the platform from abuse."
|
||||
const OpenAICloudCompletionTelemetryOffErrorMsg = "To ensure responsible usage and prevent misuse, Wave AI requires telemetry to be enabled when using its free AI features.\n\nIf you prefer not to enable telemetry, you can still access Wave AI's features by providing your own OpenAI API key or AI Base URL in the Settings menu. Please note that when using your personal API key, requests will be sent directly to the OpenAI API or the API that you specified with the AI Base URL, without being proxied through Wave's servers.\n\nIf you wish to continue using Wave AI's free features, you can easily enable telemetry by running the '/telemetry:on' command in the terminal. This will allow you to access the free AI features while helping to protect the platform from abuse."
|
||||
|
||||
const (
|
||||
KwArgRenderer = "renderer"
|
||||
@ -642,10 +642,17 @@ func RunCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (scbus.Up
|
||||
}
|
||||
runPacket.Command = strings.TrimSpace(cmdStr)
|
||||
runPacket.ReturnState = resolveBool(pk.Kwargs["rtnstate"], isRtnStateCmd)
|
||||
|
||||
clientData, err := sstore.EnsureClientData(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot retrieve client data: %v", err)
|
||||
}
|
||||
feOpts := clientData.FeOpts
|
||||
|
||||
if sudoArg, ok := pk.Kwargs[KwArgSudo]; ok {
|
||||
runPacket.IsSudo = resolveBool(sudoArg, false)
|
||||
runPacket.IsSudo = resolveBool(sudoArg, false) && feOpts.SudoPwStore != "off"
|
||||
} else {
|
||||
runPacket.IsSudo = IsSudoCommand(cmdStr)
|
||||
runPacket.IsSudo = IsSudoCommand(cmdStr) && feOpts.SudoPwStore != "off"
|
||||
}
|
||||
rcOpts := remote.RunCommandOpts{
|
||||
SessionId: ids.SessionId,
|
||||
@ -2693,8 +2700,6 @@ func getCmdInfoEngineeredPrompt(userQuery string, curLineStr string, shellType s
|
||||
}
|
||||
|
||||
func doOpenAICmdInfoCompletion(cmd *sstore.CmdType, clientId string, opts *sstore.OpenAIOptsType, prompt []packet.OpenAIPromptMessageType, curLineStr string) {
|
||||
var hadError bool
|
||||
log.Println("had error: ", hadError)
|
||||
ctx, cancelFn := context.WithTimeout(context.Background(), OpenAIStreamTimeout)
|
||||
defer cancelFn()
|
||||
defer func() {
|
||||
@ -2702,7 +2707,6 @@ func doOpenAICmdInfoCompletion(cmd *sstore.CmdType, clientId string, opts *sstor
|
||||
if r != nil {
|
||||
panicMsg := fmt.Sprintf("panic: %v", r)
|
||||
log.Printf("panic in doOpenAICompletion: %s\n", panicMsg)
|
||||
hadError = true
|
||||
}
|
||||
}()
|
||||
var ch chan *packet.OpenAIPacketType
|
||||
@ -2730,12 +2734,15 @@ func doOpenAICmdInfoCompletion(cmd *sstore.CmdType, clientId string, opts *sstor
|
||||
return
|
||||
}
|
||||
writePacketToUpdateBus(ctx, cmd, asstMessagePk)
|
||||
packetTimeout := OpenAIPacketTimeout
|
||||
if opts.Timeout >= 0 {
|
||||
packetTimeout = time.Duration(opts.Timeout) * time.Millisecond
|
||||
}
|
||||
doneWaitingForPackets := false
|
||||
for !doneWaitingForPackets {
|
||||
select {
|
||||
case <-time.After(OpenAIPacketTimeout):
|
||||
case <-time.After(packetTimeout):
|
||||
// timeout reading from channel
|
||||
hadError = true
|
||||
doneWaitingForPackets = true
|
||||
asstOutputPk.Error = "timeout waiting for server response"
|
||||
updateAsstResponseAndWriteToUpdateBus(ctx, cmd, asstMessagePk, asstOutputMessageID)
|
||||
@ -2743,7 +2750,6 @@ func doOpenAICmdInfoCompletion(cmd *sstore.CmdType, clientId string, opts *sstor
|
||||
if ok {
|
||||
// got a packet
|
||||
if pk.Error != "" {
|
||||
hadError = true
|
||||
asstOutputPk.Error = pk.Error
|
||||
}
|
||||
if pk.Model != "" && pk.Index == 0 {
|
||||
@ -2823,10 +2829,14 @@ func doOpenAIStreamCompletion(cmd *sstore.CmdType, clientId string, opts *sstore
|
||||
writeErrorToPty(cmd, fmt.Sprintf("error calling OpenAI API: %v", err), outputPos)
|
||||
return
|
||||
}
|
||||
packetTimeout := OpenAIPacketTimeout
|
||||
if opts.Timeout >= 0 {
|
||||
packetTimeout = time.Duration(opts.Timeout) * time.Millisecond
|
||||
}
|
||||
doneWaitingForPackets := false
|
||||
for !doneWaitingForPackets {
|
||||
select {
|
||||
case <-time.After(OpenAIPacketTimeout):
|
||||
case <-time.After(packetTimeout):
|
||||
// timeout reading from channel
|
||||
hadError = true
|
||||
pk := openai.CreateErrorPacket(fmt.Sprintf("timeout waiting for server response"))
|
||||
@ -2895,7 +2905,7 @@ func OpenAICommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (scbus
|
||||
return nil, fmt.Errorf("error retrieving client open ai options")
|
||||
}
|
||||
opts := clientData.OpenAIOpts
|
||||
if opts.APIToken == "" {
|
||||
if opts.APIToken == "" && opts.BaseURL == "" {
|
||||
if clientData.ClientOpts.NoTelemetry {
|
||||
return nil, fmt.Errorf(OpenAICloudCompletionTelemetryOffErrorMsg)
|
||||
}
|
||||
@ -3640,13 +3650,13 @@ func TermSetThemeCommand(ctx context.Context, pk *scpacket.FeCommandPacketType)
|
||||
}
|
||||
themeName, themeNameOk := pk.Kwargs["name"]
|
||||
feOpts := clientData.FeOpts
|
||||
if feOpts.TermTheme == nil {
|
||||
feOpts.TermTheme = make(map[string]string)
|
||||
if feOpts.TermThemeSettings == nil {
|
||||
feOpts.TermThemeSettings = make(map[string]string)
|
||||
}
|
||||
if themeNameOk && themeName != "" {
|
||||
feOpts.TermTheme[id] = themeName
|
||||
feOpts.TermThemeSettings[id] = themeName
|
||||
} else {
|
||||
delete(feOpts.TermTheme, id)
|
||||
delete(feOpts.TermThemeSettings, id)
|
||||
}
|
||||
err = sstore.UpdateClientFeOpts(ctx, feOpts)
|
||||
if err != nil {
|
||||
@ -5798,6 +5808,22 @@ func validateFontFamily(fontFamily string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func CheckOptionAlias(kwargs map[string]string, aliases ...string) (string, bool) {
|
||||
for _, alias := range aliases {
|
||||
if val, found := kwargs[alias]; found {
|
||||
return val, found
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func validateSudoPwStore(config string) error {
|
||||
if utilfn.ContainsStr([]string{"on", "off", "notimeout"}, config) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%s is not a config option", config)
|
||||
}
|
||||
|
||||
func ClientSetCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (scbus.UpdatePacket, error) {
|
||||
clientData, err := sstore.EnsureClientData(ctx)
|
||||
if err != nil {
|
||||
@ -5856,13 +5882,13 @@ func ClientSetCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (sc
|
||||
}
|
||||
if termthemeStr, found := pk.Kwargs["termtheme"]; found {
|
||||
feOpts := clientData.FeOpts
|
||||
if feOpts.TermTheme == nil {
|
||||
feOpts.TermTheme = make(map[string]string)
|
||||
if feOpts.TermThemeSettings == nil {
|
||||
feOpts.TermThemeSettings = make(map[string]string)
|
||||
}
|
||||
if termthemeStr == "" {
|
||||
delete(feOpts.TermTheme, "global")
|
||||
delete(feOpts.TermThemeSettings, "root")
|
||||
} else {
|
||||
feOpts.TermTheme["global"] = termthemeStr
|
||||
feOpts.TermThemeSettings["root"] = termthemeStr
|
||||
}
|
||||
err = sstore.UpdateClientFeOpts(ctx, feOpts)
|
||||
if err != nil {
|
||||
@ -5870,7 +5896,7 @@ func ClientSetCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (sc
|
||||
}
|
||||
varsUpdated = append(varsUpdated, "termtheme")
|
||||
}
|
||||
if apiToken, found := pk.Kwargs["openaiapitoken"]; found {
|
||||
if apiToken, found := CheckOptionAlias(pk.Kwargs, "openaiapitoken", "aiapitoken"); found {
|
||||
err = validateOpenAIAPIToken(apiToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -5884,10 +5910,10 @@ func ClientSetCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (sc
|
||||
aiOpts.APIToken = apiToken
|
||||
err = sstore.UpdateClientOpenAIOpts(ctx, *aiOpts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error updating client openai api token: %v", err)
|
||||
return nil, fmt.Errorf("error updating client ai api token: %v", err)
|
||||
}
|
||||
}
|
||||
if aiModel, found := pk.Kwargs["openaimodel"]; found {
|
||||
if aiModel, found := CheckOptionAlias(pk.Kwargs, "openaimodel", "aimodel"); found {
|
||||
err = validateOpenAIModel(aiModel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -5901,16 +5927,16 @@ func ClientSetCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (sc
|
||||
aiOpts.Model = aiModel
|
||||
err = sstore.UpdateClientOpenAIOpts(ctx, *aiOpts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error updating client openai model: %v", err)
|
||||
return nil, fmt.Errorf("error updating client ai model: %v", err)
|
||||
}
|
||||
}
|
||||
if maxTokensStr, found := pk.Kwargs["openaimaxtokens"]; found {
|
||||
if maxTokensStr, found := CheckOptionAlias(pk.Kwargs, "openaimaxtokens", "aimaxtokens"); found {
|
||||
maxTokens, err := strconv.Atoi(maxTokensStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error updating client openai maxtokens, invalid number: %v", err)
|
||||
return nil, fmt.Errorf("error updating client ai maxtokens, invalid number: %v", err)
|
||||
}
|
||||
if maxTokens < 0 || maxTokens > 1000000 {
|
||||
return nil, fmt.Errorf("error updating client openai maxtokens, out of range: %d", maxTokens)
|
||||
return nil, fmt.Errorf("error updating client ai maxtokens, out of range: %d", maxTokens)
|
||||
}
|
||||
varsUpdated = append(varsUpdated, "openaimaxtokens")
|
||||
aiOpts := clientData.OpenAIOpts
|
||||
@ -5921,16 +5947,16 @@ func ClientSetCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (sc
|
||||
aiOpts.MaxTokens = maxTokens
|
||||
err = sstore.UpdateClientOpenAIOpts(ctx, *aiOpts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error updating client openai maxtokens: %v", err)
|
||||
return nil, fmt.Errorf("error updating client ai maxtokens: %v", err)
|
||||
}
|
||||
}
|
||||
if maxChoicesStr, found := pk.Kwargs["openaimaxchoices"]; found {
|
||||
if maxChoicesStr, found := CheckOptionAlias(pk.Kwargs, "openaimaxchoices", "aimaxchoices"); found {
|
||||
maxChoices, err := strconv.Atoi(maxChoicesStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error updating client openai maxchoices, invalid number: %v", err)
|
||||
return nil, fmt.Errorf("error updating client ai maxchoices, invalid number: %v", err)
|
||||
}
|
||||
if maxChoices < 0 || maxChoices > 10 {
|
||||
return nil, fmt.Errorf("error updating client openai maxchoices, out of range: %d", maxChoices)
|
||||
return nil, fmt.Errorf("error updating client ai maxchoices, out of range: %d", maxChoices)
|
||||
}
|
||||
varsUpdated = append(varsUpdated, "openaimaxchoices")
|
||||
aiOpts := clientData.OpenAIOpts
|
||||
@ -5941,10 +5967,10 @@ func ClientSetCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (sc
|
||||
aiOpts.MaxChoices = maxChoices
|
||||
err = sstore.UpdateClientOpenAIOpts(ctx, *aiOpts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error updating client openai maxchoices: %v", err)
|
||||
return nil, fmt.Errorf("error updating client ai maxchoices: %v", err)
|
||||
}
|
||||
}
|
||||
if aiBaseURL, found := pk.Kwargs["openaibaseurl"]; found {
|
||||
if aiBaseURL, found := CheckOptionAlias(pk.Kwargs, "openaibaseurl", "aibaseurl"); found {
|
||||
aiOpts := clientData.OpenAIOpts
|
||||
if aiOpts == nil {
|
||||
aiOpts = &sstore.OpenAIOptsType{}
|
||||
@ -5954,7 +5980,24 @@ func ClientSetCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (sc
|
||||
varsUpdated = append(varsUpdated, "openaibaseurl")
|
||||
err = sstore.UpdateClientOpenAIOpts(ctx, *aiOpts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error updating client openai base url: %v", err)
|
||||
return nil, fmt.Errorf("error updating client ai base url: %v", err)
|
||||
}
|
||||
}
|
||||
if aiTimeoutStr, found := CheckOptionAlias(pk.Kwargs, "openaitimeout", "aitimeout"); found {
|
||||
aiTimeout, err := strconv.ParseFloat(aiTimeoutStr, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error updating client ai timeout, invalid number: %v", err)
|
||||
}
|
||||
aiOpts := clientData.OpenAIOpts
|
||||
if aiOpts == nil {
|
||||
aiOpts = &sstore.OpenAIOptsType{}
|
||||
clientData.OpenAIOpts = aiOpts
|
||||
}
|
||||
aiOpts.Timeout = int(aiTimeout * 1000)
|
||||
varsUpdated = append(varsUpdated, "openaitimeout")
|
||||
err = sstore.UpdateClientOpenAIOpts(ctx, *aiOpts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error updating client ai timeout: %v", err)
|
||||
}
|
||||
}
|
||||
if webglStr, found := pk.Kwargs["webgl"]; found {
|
||||
@ -5967,8 +6010,60 @@ func ClientSetCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (sc
|
||||
}
|
||||
varsUpdated = append(varsUpdated, "webgl")
|
||||
}
|
||||
if sudoPwStoreStr, found := pk.Kwargs["sudopwstore"]; found {
|
||||
err := validateSudoPwStore(sudoPwStoreStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid sudo pw store, must be \"on\", \"off\", \"notimeout\": %v", err)
|
||||
}
|
||||
feOpts := clientData.FeOpts
|
||||
feOpts.SudoPwStore = strings.ToLower(sudoPwStoreStr)
|
||||
err = sstore.UpdateClientFeOpts(ctx, feOpts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error updating client feopts: %v", err)
|
||||
}
|
||||
// clear all sudo pw if turning off
|
||||
if feOpts.SudoPwStore == "off" {
|
||||
for _, proc := range remote.GetRemoteMap() {
|
||||
proc.ClearCachedSudoPw()
|
||||
}
|
||||
}
|
||||
varsUpdated = append(varsUpdated, "sudopwstore")
|
||||
}
|
||||
if sudoPwTimeoutStr, found := pk.Kwargs["sudopwtimeout"]; found {
|
||||
oldPwTimeout := clientData.FeOpts.SudoPwTimeoutMs / 1000 / 60 // ms to minutes
|
||||
if oldPwTimeout == 0 {
|
||||
oldPwTimeout = sstore.DefaultSudoTimeout
|
||||
}
|
||||
newSudoPwTimeout, err := resolveNonNegInt(sudoPwTimeoutStr, sstore.DefaultSudoTimeout)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid sudo pw timeout, must be a number greater than 0: %v", err)
|
||||
}
|
||||
if newSudoPwTimeout == 0 {
|
||||
return nil, fmt.Errorf("invalid sudo pw timeout, must be a number greater than 0")
|
||||
}
|
||||
feOpts := clientData.FeOpts
|
||||
feOpts.SudoPwTimeoutMs = newSudoPwTimeout * 60 * 1000 // minutes to ms
|
||||
err = sstore.UpdateClientFeOpts(ctx, feOpts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error updating client feopts: %v", err)
|
||||
}
|
||||
for _, proc := range remote.GetRemoteMap() {
|
||||
proc.ChangeSudoTimeout(int64(newSudoPwTimeout - oldPwTimeout))
|
||||
}
|
||||
varsUpdated = append(varsUpdated, "sudopwtimeout")
|
||||
}
|
||||
if sudoPwClearOnSleepStr, found := pk.Kwargs["sudopwclearonsleep"]; found {
|
||||
newSudoPwClearOnSleep := resolveBool(sudoPwClearOnSleepStr, true)
|
||||
feOpts := clientData.FeOpts
|
||||
feOpts.NoSudoPwClearOnSleep = !newSudoPwClearOnSleep
|
||||
err = sstore.UpdateClientFeOpts(ctx, feOpts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error updating client feopts: %v", err)
|
||||
}
|
||||
varsUpdated = append(varsUpdated, "sudopwclearonsleep")
|
||||
}
|
||||
if len(varsUpdated) == 0 {
|
||||
return nil, fmt.Errorf("/client:set requires a value to set: %s", formatStrs([]string{"termfontsize", "termfontfamily", "openaiapitoken", "openaimodel", "openaibaseurl", "openaimaxtokens", "openaimaxchoices", "webgl"}, "or", false))
|
||||
return nil, fmt.Errorf("/client:set requires a value to set: %s", formatStrs([]string{"termfontsize", "termfontfamily", "openaiapitoken", "openaimodel", "openaibaseurl", "openaimaxtokens", "openaimaxchoices", "openaitimeout", "webgl"}, "or", false))
|
||||
}
|
||||
clientData, err = sstore.EnsureClientData(ctx)
|
||||
if err != nil {
|
||||
@ -6008,11 +6103,12 @@ func ClientShowCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (s
|
||||
buf.WriteString(fmt.Sprintf(" %-15s %d\n", "termfontsize", clientData.FeOpts.TermFontSize))
|
||||
buf.WriteString(fmt.Sprintf(" %-15s %s\n", "termfontfamily", clientData.FeOpts.TermFontFamily))
|
||||
buf.WriteString(fmt.Sprintf(" %-15s %s\n", "termfontfamily", clientData.FeOpts.Theme))
|
||||
buf.WriteString(fmt.Sprintf(" %-15s %s\n", "openaiapitoken", clientData.OpenAIOpts.APIToken))
|
||||
buf.WriteString(fmt.Sprintf(" %-15s %s\n", "openaimodel", clientData.OpenAIOpts.Model))
|
||||
buf.WriteString(fmt.Sprintf(" %-15s %d\n", "openaimaxtokens", clientData.OpenAIOpts.MaxTokens))
|
||||
buf.WriteString(fmt.Sprintf(" %-15s %d\n", "openaimaxchoices", clientData.OpenAIOpts.MaxChoices))
|
||||
buf.WriteString(fmt.Sprintf(" %-15s %s\n", "openaibaseurl", clientData.OpenAIOpts.BaseURL))
|
||||
buf.WriteString(fmt.Sprintf(" %-15s %s\n", "aiapitoken", clientData.OpenAIOpts.APIToken))
|
||||
buf.WriteString(fmt.Sprintf(" %-15s %s\n", "aimodel", clientData.OpenAIOpts.Model))
|
||||
buf.WriteString(fmt.Sprintf(" %-15s %d\n", "aimaxtokens", clientData.OpenAIOpts.MaxTokens))
|
||||
buf.WriteString(fmt.Sprintf(" %-15s %d\n", "aimaxchoices", clientData.OpenAIOpts.MaxChoices))
|
||||
buf.WriteString(fmt.Sprintf(" %-15s %s\n", "aibaseurl", clientData.OpenAIOpts.BaseURL))
|
||||
buf.WriteString(fmt.Sprintf(" %-15s %ss\n", "aitimeout", strconv.FormatFloat((float64(clientData.OpenAIOpts.Timeout)/1000.0), 'f', -1, 64)))
|
||||
update := scbus.MakeUpdatePacket()
|
||||
update.AddUpdate(sstore.InfoMsgType{
|
||||
InfoTitle: fmt.Sprintf("client info"),
|
||||
|
102
wavesrv/pkg/configstore/filewatcher.go
Normal file
102
wavesrv/pkg/configstore/filewatcher.go
Normal file
@ -0,0 +1,102 @@
|
||||
package configstore
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"github.com/wavetermdev/waveterm/wavesrv/pkg/scbus"
|
||||
)
|
||||
|
||||
var instance *Watcher
|
||||
var once sync.Once
|
||||
|
||||
type Watcher struct {
|
||||
watcher *fsnotify.Watcher
|
||||
mutex sync.Mutex
|
||||
}
|
||||
|
||||
// GetWatcher returns the singleton instance of the Watcher
|
||||
func GetWatcher() *Watcher {
|
||||
once.Do(func() {
|
||||
watcher, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
log.Printf("failed to create file watcher: %v", err)
|
||||
return
|
||||
}
|
||||
instance = &Watcher{watcher: watcher}
|
||||
log.Printf("started config watcher: %v\n", configDirAbsPath)
|
||||
if err := instance.addPath(configDirAbsPath); err != nil {
|
||||
log.Printf("failed to add path %s to watcher: %v", configDirAbsPath, err)
|
||||
return
|
||||
}
|
||||
})
|
||||
return instance
|
||||
}
|
||||
|
||||
// addPath adds the specified path and all its subdirectories to the watcher
|
||||
func (w *Watcher) addPath(path string) error {
|
||||
return filepath.Walk(path, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.IsDir() {
|
||||
if err := w.watcher.Add(path); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("added to watcher: %s", path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (w *Watcher) Start() {
|
||||
for {
|
||||
select {
|
||||
case event, ok := <-w.watcher.Events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
w.handleEvent(event)
|
||||
case err, ok := <-w.watcher.Errors:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
log.Println("watcher error:", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Watcher) Close() {
|
||||
w.mutex.Lock()
|
||||
defer w.mutex.Unlock()
|
||||
if w.watcher != nil {
|
||||
w.watcher.Close()
|
||||
w.watcher = nil
|
||||
log.Println("file watcher closed.")
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Watcher) handleEvent(event fsnotify.Event) {
|
||||
config := make(ConfigReturn)
|
||||
fileName, normalizedPath := getNameAndPath(event)
|
||||
|
||||
if event.Op&fsnotify.Write == fsnotify.Write || event.Op&fsnotify.Create == fsnotify.Create || event.Op&fsnotify.Rename == fsnotify.Rename {
|
||||
content, err := readFileContents(normalizedPath)
|
||||
if err != nil {
|
||||
log.Printf("error reading file %s: %v", normalizedPath, err)
|
||||
return
|
||||
}
|
||||
config[fileName] = content
|
||||
}
|
||||
|
||||
if event.Op&fsnotify.Remove == fsnotify.Remove {
|
||||
config[fileName] = nil
|
||||
}
|
||||
|
||||
update := scbus.MakeUpdatePacket()
|
||||
update.AddUpdate(config)
|
||||
scbus.MainUpdateBus.DoUpdate(update)
|
||||
}
|
83
wavesrv/pkg/configstore/termthemes.go
Normal file
83
wavesrv/pkg/configstore/termthemes.go
Normal file
@ -0,0 +1,83 @@
|
||||
package configstore
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"github.com/wavetermdev/waveterm/wavesrv/pkg/scbase"
|
||||
)
|
||||
|
||||
const ConfigReturnTypeStr = "termthemes"
|
||||
const configDir = "config/terminal-themes/"
|
||||
|
||||
var configDirAbsPath = path.Join(scbase.GetWaveHomeDir(), configDir)
|
||||
|
||||
type ConfigReturn map[string]map[string]string
|
||||
|
||||
func (tt ConfigReturn) GetType() string {
|
||||
return ConfigReturnTypeStr
|
||||
}
|
||||
|
||||
func getNameAndPath(event fsnotify.Event) (string, string) {
|
||||
filePath := event.Name
|
||||
fileName := filepath.Base(filePath)
|
||||
|
||||
// Normalize the file path for consistency across platforms
|
||||
normalizedPath := filepath.ToSlash(filePath)
|
||||
return fileName, normalizedPath
|
||||
}
|
||||
|
||||
// readFileContents reads and unmarshals the JSON content from a file.
|
||||
func readFileContents(filePath string) (map[string]string, error) {
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var content map[string]string
|
||||
if err := json.Unmarshal(data, &content); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
// ScanConfigs reads all JSON files in the specified directory and its subdirectories.
|
||||
func ScanConfigs() (ConfigReturn, error) {
|
||||
config := make(ConfigReturn)
|
||||
|
||||
if _, err := os.Stat(configDirAbsPath); errors.Is(err, os.ErrNotExist) {
|
||||
log.Printf("directory does not exist: %s", configDirAbsPath)
|
||||
return ConfigReturn{}, nil
|
||||
}
|
||||
|
||||
err := filepath.Walk(configDirAbsPath, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() && filepath.Ext(info.Name()) == ".json" {
|
||||
content, err := readFileContents(path)
|
||||
if err != nil {
|
||||
log.Printf("error reading file %s: %v", path, err)
|
||||
return nil // continue walking despite error in reading file
|
||||
}
|
||||
// Use the relative path from the directory as the key to store themes
|
||||
relPath, err := filepath.Rel(configDirAbsPath, path)
|
||||
if err != nil {
|
||||
log.Printf("error getting relative file path %s: %v", path, err)
|
||||
return nil // continue walking despite error in getting relative path
|
||||
}
|
||||
config[relPath] = content
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
@ -2626,11 +2626,21 @@ func sendScreenUpdates(screens []*sstore.ScreenType) {
|
||||
}
|
||||
}
|
||||
|
||||
func (msh *MShellProc) startSudoPwClearChecker() {
|
||||
func (msh *MShellProc) startSudoPwClearChecker(clientData *sstore.ClientData) {
|
||||
ctx, cancelFn := context.WithCancel(context.Background())
|
||||
defer cancelFn()
|
||||
sudoPwStore := clientData.FeOpts.SudoPwStore
|
||||
for {
|
||||
clientData, err := sstore.EnsureClientData(ctx)
|
||||
if err != nil {
|
||||
log.Printf("*error: cannot obtain client data in sudo pw loop. using fallback: %v", err)
|
||||
} else {
|
||||
sudoPwStore = clientData.FeOpts.SudoPwStore
|
||||
}
|
||||
|
||||
shouldExit := false
|
||||
msh.WithLock(func() {
|
||||
if msh.sudoClearDeadline > 0 && time.Now().Unix() > msh.sudoClearDeadline {
|
||||
if msh.sudoClearDeadline > 0 && time.Now().Unix() > msh.sudoClearDeadline && sudoPwStore != "notimeout" {
|
||||
msh.sudoPw = nil
|
||||
msh.sudoClearDeadline = 0
|
||||
}
|
||||
@ -2668,13 +2678,25 @@ func (msh *MShellProc) sendSudoPassword(sudoPk *packet.SudoRequestPacketType) er
|
||||
}
|
||||
rawSecret = []byte(guiResponse.Text)
|
||||
}
|
||||
//new
|
||||
|
||||
ctx, cancelFn := context.WithCancel(context.Background())
|
||||
defer cancelFn()
|
||||
clientData, err := sstore.EnsureClientData(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("*error: cannot obtain client data: %v", err)
|
||||
}
|
||||
sudoPwTimeout := clientData.FeOpts.SudoPwTimeoutMs / 1000 / 60
|
||||
if sudoPwTimeout == 0 {
|
||||
// 0 maps to default
|
||||
sudoPwTimeout = sstore.DefaultSudoTimeout
|
||||
}
|
||||
pwTimeoutDur := time.Duration(sudoPwTimeout) * time.Minute
|
||||
msh.WithLock(func() {
|
||||
msh.sudoPw = rawSecret
|
||||
if msh.sudoClearDeadline == 0 {
|
||||
go msh.startSudoPwClearChecker()
|
||||
go msh.startSudoPwClearChecker(clientData)
|
||||
}
|
||||
msh.sudoClearDeadline = time.Now().Add(SudoTimeoutTime).Unix()
|
||||
msh.sudoClearDeadline = time.Now().Add(pwTimeoutDur).Unix()
|
||||
})
|
||||
|
||||
srvPrivKey, err := ecdh.P256().GenerateKey(rand.Reader)
|
||||
@ -2767,6 +2789,15 @@ func (msh *MShellProc) ClearCachedSudoPw() {
|
||||
})
|
||||
}
|
||||
|
||||
func (msh *MShellProc) ChangeSudoTimeout(deltaTime int64) {
|
||||
msh.WithLock(func() {
|
||||
if msh.sudoClearDeadline != 0 {
|
||||
updated := msh.sudoClearDeadline + deltaTime*60
|
||||
msh.sudoClearDeadline = max(0, updated)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (msh *MShellProc) ProcessPackets() {
|
||||
defer msh.WithLock(func() {
|
||||
if msh.Status == StatusConnected {
|
||||
|
@ -36,7 +36,7 @@ const WaveDirName = ".waveterm" // must match emain.ts
|
||||
const WaveDevDirName = ".waveterm-dev" // must match emain.ts
|
||||
const WaveAppPathVarName = "WAVETERM_APP_PATH"
|
||||
const WaveAuthKeyFileName = "waveterm.authkey"
|
||||
const MShellVersion = "v0.6.0" // must match base.MShellVersion
|
||||
const MShellVersion = "v0.7.0" // must match base.MShellVersion
|
||||
|
||||
// initialized by InitialzeWaveAuthKey (called by main-server)
|
||||
var WaveAuthKey string
|
||||
@ -234,7 +234,7 @@ func GetScreensDir() string {
|
||||
return sdir
|
||||
}
|
||||
|
||||
func EnsureConfigDir() (string, error) {
|
||||
func EnsureConfigDirs() (string, error) {
|
||||
scHome := GetWaveHomeDir()
|
||||
configDir := path.Join(scHome, "config")
|
||||
err := ensureDir(configDir)
|
||||
@ -250,6 +250,11 @@ func EnsureConfigDir() (string, error) {
|
||||
keybindingsFileObj.WriteString("[]\n")
|
||||
keybindingsFileObj.Close()
|
||||
}
|
||||
terminalThemesDir := path.Join(configDir, "terminal-themes")
|
||||
err = ensureDir(terminalThemesDir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return configDir, nil
|
||||
}
|
||||
|
||||
|
@ -13,6 +13,7 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/wavetermdev/waveterm/waveshell/pkg/packet"
|
||||
"github.com/wavetermdev/waveterm/wavesrv/pkg/configstore"
|
||||
"github.com/wavetermdev/waveterm/wavesrv/pkg/mapqueue"
|
||||
"github.com/wavetermdev/waveterm/wavesrv/pkg/remote"
|
||||
"github.com/wavetermdev/waveterm/wavesrv/pkg/scbus"
|
||||
@ -165,6 +166,11 @@ func (ws *WSState) handleConnection() error {
|
||||
connectUpdate.Remotes = remotes
|
||||
// restore status indicators
|
||||
connectUpdate.ScreenStatusIndicators, connectUpdate.ScreenNumRunningCommands = sstore.GetCurrentIndicatorState()
|
||||
configs, err := configstore.ScanConfigs()
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting configs: %w", err)
|
||||
}
|
||||
connectUpdate.TermThemes = &configs
|
||||
mu := scbus.MakeUpdatePacket()
|
||||
mu.AddUpdate(*connectUpdate)
|
||||
err = ws.Shell.WriteJson(mu)
|
||||
|
@ -43,6 +43,7 @@ const DBWALFileNameBackup = "backup.waveterm.db-wal"
|
||||
const MaxWebShareLineCount = 50
|
||||
const MaxWebShareScreenCount = 3
|
||||
const MaxLineStateSize = 4 * 1024 // 4k for now, can raise if needed
|
||||
const DefaultSudoTimeout = 5
|
||||
|
||||
const DefaultSessionName = "default"
|
||||
const LocalRemoteAlias = "local"
|
||||
@ -232,6 +233,10 @@ type ClientWinSizeType struct {
|
||||
FullScreen bool `json:"fullscreen,omitempty"`
|
||||
}
|
||||
|
||||
type PowerMonitorEventType struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type SidebarValueType struct {
|
||||
Collapsed bool `json:"collapsed"`
|
||||
Width int `json:"width"`
|
||||
@ -250,10 +255,14 @@ type ClientOptsType struct {
|
||||
}
|
||||
|
||||
type FeOptsType struct {
|
||||
TermFontSize int `json:"termfontsize,omitempty"`
|
||||
TermFontFamily string `json:"termfontfamily,omitempty"`
|
||||
Theme string `json:"theme,omitempty"`
|
||||
TermTheme map[string]string `json:"termtheme"`
|
||||
TermFontSize int `json:"termfontsize,omitempty"`
|
||||
TermFontFamily string `json:"termfontfamily,omitempty"`
|
||||
Theme string `json:"theme,omitempty"`
|
||||
TermThemeSettings map[string]string `json:"termthemesettings"`
|
||||
SudoPwStore string `json:"sudopwstore,omitempty"`
|
||||
SudoPwTimeoutMs int `json:"sudopwtimeoutms,omitempty"`
|
||||
SudoPwTimeout int `json:"sudopwtimeout,omitempty"`
|
||||
NoSudoPwClearOnSleep bool `json:"nosudopwclearonsleep,omitempty"`
|
||||
}
|
||||
|
||||
type ReleaseInfoType struct {
|
||||
@ -289,6 +298,8 @@ func (cdata *ClientData) Clean() *ClientData {
|
||||
Model: cdata.OpenAIOpts.Model,
|
||||
MaxTokens: cdata.OpenAIOpts.MaxTokens,
|
||||
MaxChoices: cdata.OpenAIOpts.MaxChoices,
|
||||
Timeout: cdata.OpenAIOpts.Timeout,
|
||||
BaseURL: cdata.OpenAIOpts.BaseURL,
|
||||
// omit API Token
|
||||
}
|
||||
if cdata.OpenAIOpts.APIToken != "" {
|
||||
@ -736,6 +747,7 @@ type OpenAIOptsType struct {
|
||||
BaseURL string `json:"baseurl,omitempty"`
|
||||
MaxTokens int `json:"maxtokens,omitempty"`
|
||||
MaxChoices int `json:"maxchoices,omitempty"`
|
||||
Timeout int `json:"timeout,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
|
@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/wavetermdev/waveterm/waveshell/pkg/packet"
|
||||
"github.com/wavetermdev/waveterm/waveshell/pkg/utilfn"
|
||||
"github.com/wavetermdev/waveterm/wavesrv/pkg/configstore"
|
||||
"github.com/wavetermdev/waveterm/wavesrv/pkg/scbus"
|
||||
)
|
||||
|
||||
@ -104,6 +105,7 @@ type ConnectUpdate struct {
|
||||
ScreenStatusIndicators []*ScreenStatusIndicatorType `json:"screenstatusindicators,omitempty"`
|
||||
ScreenNumRunningCommands []*ScreenNumRunningCommandsType `json:"screennumrunningcommands,omitempty"`
|
||||
ActiveSessionId string `json:"activesessionid,omitempty"`
|
||||
TermThemes *configstore.ConfigReturn `json:"termthemes,omitempty"`
|
||||
}
|
||||
|
||||
func (ConnectUpdate) GetType() string {
|
||||
|
@ -119,7 +119,7 @@ func (ws *WSShell) ReadPump() {
|
||||
defer func() {
|
||||
ws.Conn.Close()
|
||||
}()
|
||||
ws.Conn.SetReadLimit(4096)
|
||||
ws.Conn.SetReadLimit(64 * 1024)
|
||||
ws.Conn.SetReadDeadline(time.Now().Add(readWait))
|
||||
for {
|
||||
_, message, err := ws.Conn.ReadMessage()
|
||||
|
Loading…
Reference in New Issue
Block a user