diff --git a/src/app/app.tsx b/src/app/app.tsx index d66cda18..b9e1a126 100644 --- a/src/app/app.tsx +++ b/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 = mobx.observable.box(false, { name: "dcWait" }); mainContentRef: React.RefObject = React.createRef(); + termThemesLoaded: OV = 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 ( -
- -
-
-
- logo + <> + +
+ + +
+
+
+ logo +
+
+ + +
+ +
+
+
+ + + + + + + + + +
-
-
- -
- -
-
-
- - - - - - - - - - + +
- -
+ ); } } diff --git a/src/app/clientsettings/clientsettings.tsx b/src/app/clientsettings/clientsettings.tsx index fa3325d1..dd53cd3a 100644 --- a/src/app/clientsettings/clientsettings.tsx +++ b/src/app/clientsettings/clientsettings.tsx @@ -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); } @@ -207,8 +206,8 @@ class ClientSettingsView extends React.Component<{ model: RemotesModel }, { hove 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; return ( diff --git a/src/app/common/elements/index.tsx b/src/app/common/elements/index.tsx index cf87b853..0b170499 100644 --- a/src/app/common/elements/index.tsx +++ b/src/app/common/elements/index.tsx @@ -18,4 +18,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"; diff --git a/src/app/common/elements/termstyle.tsx b/src/app/common/elements/termstyle.tsx new file mode 100644 index 00000000..900ce87b --- /dev/null +++ b/src/app/common/elements/termstyle.tsx @@ -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(, 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 ( + <> + + + + + ); + } +} + +export { TermStyleList }; diff --git a/src/app/common/modals/screensettings.tsx b/src/app/common/modals/screensettings.tsx index 04418b28..eb89fc6e 100644 --- a/src/app/common/modals/screensettings.tsx +++ b/src/app/common/modals/screensettings.tsx @@ -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 ( diff --git a/src/app/common/modals/sessionsettings.tsx b/src/app/common/modals/sessionsettings.tsx index a208353a..837a3414 100644 --- a/src/app/common/modals/sessionsettings.tsx +++ b/src/app/common/modals/sessionsettings.tsx @@ -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 ( diff --git a/src/app/history/history.tsx b/src/app/history/history.tsx index 7aecebd0..ae0a6992 100644 --- a/src/app/history/history.tsx +++ b/src/app/history/history.tsx @@ -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; diff --git a/src/app/workspace/screen/screenview.tsx b/src/app/workspace/screen/screenview.tsx index fb9517aa..481779fa 100644 --- a/src/app/workspace/screen/screenview.tsx +++ b/src/app/workspace/screen/screenview.tsx @@ -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 ( -
+
{ @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 (
@@ -212,59 +212,6 @@ class TabSettings extends React.Component<{ screen: Screen }, {}> { @mobxReact.observer class WorkspaceView extends React.Component<{}, {}> { sessionRef = React.createRef(); - 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 (
{
- + diff --git a/src/models/commandrunner.ts b/src/models/commandrunner.ts index 05488842..e4a0e849 100644 --- a/src/models/commandrunner.ts +++ b/src/models/commandrunner.ts @@ -381,29 +381,40 @@ class CommandRunner { return GlobalModel.submitCommand("client", "set", null, kwargs, interactive); } - setGlobalTermTheme(theme: string, interactive: boolean): Promise { + setRootTermTheme(theme: string, interactive: boolean): Promise { + 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 { - 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 { + 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); } diff --git a/src/models/model.ts b/src/models/model.ts index 3ba346db..348f88f6 100644 --- a/src/models/model.ts +++ b/src/models/model.ts @@ -137,22 +137,16 @@ class Model { renderVersion: OV = mobx.observable.box(0, { name: "renderVersion", }); - appUpdateStatus = mobx.observable.box(getApi().getAppUpdateStatus(), { name: "appUpdateStatus", }); - - termThemes: OMap = mobx.observable.array([], { + termThemes: OV = mobx.observable.box(null, { name: "terminalThemes", deep: false, }); - termThemeSrcEl: OV = mobx.observable.box(null, { - name: "termThemeSrcEl", - }); termRenderVersion: OV = 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 {}; } @@ -937,6 +894,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 +961,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 +1034,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 +1289,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 +1309,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 { diff --git a/src/plugins/terminal/term.ts b/src/plugins/terminal/term.ts index 486a466b..93f8e55f 100644 --- a/src/plugins/terminal/term.ts +++ b/src/plugins/terminal/term.ts @@ -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, diff --git a/src/types/custom.d.ts b/src/types/custom.d.ts index 34b04375..1546e0cd 100644 --- a/src/types/custom.d.ts +++ b/src/types/custom.d.ts @@ -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,7 @@ declare global { termfontsize: number; termfontfamily: string; theme: NativeThemeSource; - termtheme: TermThemeType; + termthemesettings: TermThemeSettingsType; }; type ConfirmFlagsType = { diff --git a/src/util/themeutil.ts b/src/util/themeutil.ts index ceb93ebe..5433b12b 100644 --- a/src/util/themeutil.ts +++ b/src/util/themeutil.ts @@ -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, diff --git a/wavesrv/cmd/main-server.go b/wavesrv/cmd/main-server.go index ee205940..aff0d6e8 100644 --- a/wavesrv/cmd/main-server.go +++ b/wavesrv/cmd/main-server.go @@ -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() } @@ -976,6 +978,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) @@ -1016,6 +1022,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 +1093,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 +1133,7 @@ func main() { startupActivityUpdate() installSignalHandlers() go telemetryLoop() + go configWatcher() go stdinReadWatch() go runWebSocketServer() go func() { diff --git a/wavesrv/go.mod b/wavesrv/go.mod index 252b64f2..e95b03fa 100644 --- a/wavesrv/go.mod +++ b/wavesrv/go.mod @@ -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 diff --git a/wavesrv/go.sum b/wavesrv/go.sum index 13d2e2ce..ca70668e 100644 --- a/wavesrv/go.sum +++ b/wavesrv/go.sum @@ -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= diff --git a/wavesrv/pkg/cmdrunner/cmdrunner.go b/wavesrv/pkg/cmdrunner/cmdrunner.go index fd4c0ac1..96d29e2a 100644 --- a/wavesrv/pkg/cmdrunner/cmdrunner.go +++ b/wavesrv/pkg/cmdrunner/cmdrunner.go @@ -3640,13 +3640,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 { @@ -5856,13 +5856,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 { diff --git a/wavesrv/pkg/configstore/filewatcher.go b/wavesrv/pkg/configstore/filewatcher.go new file mode 100644 index 00000000..7ad944ab --- /dev/null +++ b/wavesrv/pkg/configstore/filewatcher.go @@ -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) +} diff --git a/wavesrv/pkg/configstore/termthemes.go b/wavesrv/pkg/configstore/termthemes.go new file mode 100644 index 00000000..bfb44fde --- /dev/null +++ b/wavesrv/pkg/configstore/termthemes.go @@ -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 +} diff --git a/wavesrv/pkg/scbase/scbase.go b/wavesrv/pkg/scbase/scbase.go index 99f99880..67c11bf2 100644 --- a/wavesrv/pkg/scbase/scbase.go +++ b/wavesrv/pkg/scbase/scbase.go @@ -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 } diff --git a/wavesrv/pkg/scws/scws.go b/wavesrv/pkg/scws/scws.go index 2e4df20f..37f4d7a3 100644 --- a/wavesrv/pkg/scws/scws.go +++ b/wavesrv/pkg/scws/scws.go @@ -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) diff --git a/wavesrv/pkg/sstore/sstore.go b/wavesrv/pkg/sstore/sstore.go index 2cc6a2a7..c0a5b5e4 100644 --- a/wavesrv/pkg/sstore/sstore.go +++ b/wavesrv/pkg/sstore/sstore.go @@ -250,10 +250,10 @@ 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"` } type ReleaseInfoType struct { diff --git a/wavesrv/pkg/sstore/updatetypes.go b/wavesrv/pkg/sstore/updatetypes.go index 2b777f42..2291cfad 100644 --- a/wavesrv/pkg/sstore/updatetypes.go +++ b/wavesrv/pkg/sstore/updatetypes.go @@ -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 {