diff --git a/package.json b/package.json index fbf746bb4..c4f1c6b43 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "mobx": "^6.6.0", "mobx-react": "^7.5.0", "monaco-editor": "^0.41.0", + "mustache": "^4.2.0", "node-fetch": "^3.2.10", "react": "^18.1.0", "react-dom": "^18.1.0", diff --git a/src/emain.ts b/src/emain.ts index bdf9e179d..a99172b6e 100644 --- a/src/emain.ts +++ b/src/emain.ts @@ -169,8 +169,13 @@ function getMods(input: any) { } function shNavHandler(event: any, url: any) { - console.log("navigation canceled", url); event.preventDefault(); + if (url.startsWith("https://") || url.startsWith("http://") || url.startsWith("file://")) { + console.log("open external, shNav", url); + electron.shell.openExternal(url); + } else { + console.log("navigation canceled", url); + } } function shFrameNavHandler(event: any, url: any) { @@ -183,6 +188,7 @@ function shFrameNavHandler(event: any, url: any) { if (event.frame.name == "webview") { // "webview" links always open in new window // this will *not* effect the initial load because srcdoc does not count as an electron navigation + console.log("open external, frameNav", url); electron.shell.openExternal(url); return; } @@ -301,13 +307,19 @@ function createMainWindow(clientData) { }); win.webContents.setWindowOpenHandler(({ url, frameName }) => { if (url.startsWith("https://docs.getprompt.dev/")) { + console.log("openExternal docs", url); electron.shell.openExternal(url); } else if (url.startsWith("https://discord.gg/")) { + console.log("openExternal discord", url); electron.shell.openExternal(url); } else if (url.startsWith("https://extern/?")) { let qmark = url.indexOf("?"); let param = url.substr(qmark + 1); let newUrl = decodeURIComponent(param); + console.log("openExternal extern", newUrl); + electron.shell.openExternal(newUrl); + } else if (url.startsWith("http://") || url.startsWith("https://") || url.startsWith("file://")) { + console.log("openExternal fallback", url); electron.shell.openExternal(newUrl); } console.log("window-open denied", url); diff --git a/src/plugins.ts b/src/plugins.ts index 5b27a0695..09aea67ad 100644 --- a/src/plugins.ts +++ b/src/plugins.ts @@ -2,6 +2,7 @@ import { RendererPluginType } from "./types"; import { SimpleImageRenderer } from "./view/image"; import { SimpleMarkdownRenderer } from "./view/markdown"; import { SourceCodeRenderer } from "./view/code"; +import { SimpleMustacheRenderer } from "./view/mustache"; import { OpenAIRenderer, OpenAIRendererModel } from "./view/openai"; import { isBlank } from "./util"; import { sprintf } from "sprintf-js"; @@ -28,6 +29,17 @@ const MarkdownPlugin: RendererPluginType = { simpleComponent: SimpleMarkdownRenderer, }; +const MustachePlugin: RendererPluginType = { + name: "mustache", + rendererType: "simple", + heightType: "pixels", + dataType: "blob", + collapseType: "hide", + globalCss: null, + mimeTypes: ["text/plain"], + simpleComponent: SimpleMustacheRenderer, +}; + const CodePlugin: RendererPluginType = { name: "code", rendererType: "simple", @@ -87,6 +99,7 @@ if ((window as any).PluginModel == null) { PluginModel.registerRendererPlugin(MarkdownPlugin); PluginModel.registerRendererPlugin(CodePlugin); PluginModel.registerRendererPlugin(OpenAIPlugin); + PluginModel.registerRendererPlugin(MustachePlugin); (window as any).PluginModel = PluginModel; } diff --git a/src/prompt.less b/src/prompt.less index 4c84e05cd..26e0c2428 100644 --- a/src/prompt.less +++ b/src/prompt.less @@ -254,7 +254,7 @@ input[type="checkbox"] { padding-top: 10px; padding-bottom: 15px; } - + .monaco-editor .monaco-editor-background { background-color: rgba(255, 255, 255, 0.075) !important; } @@ -331,7 +331,26 @@ input[type="checkbox"] { font-size: 12px; } -.renderer-container .markdown { +.renderer-container.mustache-renderer { + color: @term-white; + .cmd-hints { + display: inline-block !important; + position: relative; + margin-right: 26px; + } + .hint-item { + border-radius: 4px 4px 0 0; + padding: 3px 9px 2px 8px; + line-height: 15px; + text-align: center; + } + .refresh-button { + color: rgb(52, 52, 52); + background-color: @term-white; + } +} + +.renderer-container .content { padding: 5px; line-height: 1.5; width: fit-content; @@ -350,6 +369,15 @@ input[type="checkbox"] { margin: 2px 10px 6px 10px; padding: 4px 4px 4px 6px; } + + h1, + h2, + h3, + h4, + h5, + h6 { + color: @term-white; + } } .openai-renderer { diff --git a/src/prompt.ts b/src/prompt.ts index 2eafd5ae8..cb9d4c376 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -7,6 +7,7 @@ import { Main } from "./main"; import { GlobalModel } from "./model"; import { v4 as uuidv4 } from "uuid"; import { loadFonts } from "./util"; +import * as DOMPurify from "dompurify"; // @ts-ignore let VERSION = __PROMPT_VERSION__; @@ -31,5 +32,6 @@ document.addEventListener("DOMContentLoaded", () => { (window as any).mobx = mobx; (window as any).sprintf = sprintf; +(window as any).DOMPurify = DOMPurify; console.log("PROMPT", VERSION, BUILD); diff --git a/src/view/mustache.tsx b/src/view/mustache.tsx new file mode 100644 index 000000000..c20fe36be --- /dev/null +++ b/src/view/mustache.tsx @@ -0,0 +1,218 @@ +import * as React from "react"; +import * as mobx from "mobx"; +import * as mobxReact from "mobx-react"; +import cn from "classnames"; +import { boundMethod } from "autobind-decorator"; +import { If, For, When, Otherwise, Choose } from "tsx-control-statements/components"; +import * as T from "../types"; +import { sprintf } from "sprintf-js"; +import { isBlank } from "../util"; +import mustache from "mustache"; +import * as DOMPurify from "dompurify"; +import { GlobalModel } from "../model"; + +type OV = mobx.IObservableValue; + +const MaxMustacheSize = 200000; + +@mobxReact.observer +class SimpleMustacheRenderer extends React.Component< + { data: Blob; context: T.RendererContext; opts: T.RendererOpts; savedHeight: number; lineState: T.LineStateType }, + {} +> { + templateLoading: OV = mobx.observable.box(true, { name: "templateLoading" }); + templateLoadError: OV = mobx.observable.box(null, { name: "templateLoadError" }); + dataLoading: OV = mobx.observable.box(true, { name: "dataLoading" }); + dataLoadError: OV = mobx.observable.box(null, { name: "dataLoadError" }); + mustacheTemplateText: OV = mobx.observable.box(null, { name: "mustacheTemplateText" }); + parsedData: OV = mobx.observable.box(null, { name: "parsedData" }); + + componentDidMount() { + this.reloadTemplate(); + this.reloadData(); + } + + reloadTemplate() { + if (isBlank(this.props.lineState.template)) { + mobx.action(() => { + this.templateLoading.set(false); + this.templateLoadError.set(`no 'template' specified`); + })(); + return; + } + mobx.action(() => { + this.templateLoading.set(true); + this.templateLoadError.set(null); + })(); + let context = this.props.context; + let lineState = this.props.lineState; + let quotedTemplateName = JSON.stringify(lineState.template); + let rtnp = GlobalModel.readRemoteFile(context.screenId, context.lineId, lineState.template); + rtnp.then((file) => { + if (file.notFound) { + this.trySetTemplateLoadError(`mustache template ${quotedTemplateName} not found`); + return null; + } + return file.text(); + }) + .then((text) => { + if (isBlank(text)) { + this.trySetTemplateLoadError(`blank mustache template ${quotedTemplateName}`); + return; + } + mobx.action(() => { + this.mustacheTemplateText.set(text); + this.templateLoading.set(false); + })(); + return; + }) + .catch((e) => { + this.trySetTemplateLoadError(`loading mustache template ${quotedTemplateName}: ${e}`); + }); + } + + reloadData() { + // load json content + let dataBlob = this.props.data; + if (dataBlob == null || dataBlob.notFound) { + mobx.action(() => { + this.dataLoading.set(false); + this.dataLoadError.set( + `file {dataBlob && dataBlob.name ? JSON.stringify(dataBlob.name) : ""} not found` + ); + })(); + return; + } + mobx.action(() => { + this.dataLoading.set(true); + this.dataLoadError.set(null); + })(); + let rtnp = dataBlob.text(); + let quotedDataName = dataBlob.name || '"terminal output"'; + rtnp.then((text) => { + mobx.action(() => { + try { + this.parsedData.set(JSON.parse(text)); + this.dataLoading.set(false); + } catch (e) { + this.trySetDataLoadError(`parsing json data from ${quotedDataName}: ${e}`); + } + })(); + }).catch((e) => { + this.trySetDataLoadError(`loading json data ${quotedDataName}: ${e}`); + }); + } + + trySetTemplateLoadError(msg: string) { + if (this.templateLoadError.get() != null) { + return; + } + mobx.action(() => { + this.templateLoadError.set(msg); + })(); + } + + trySetDataLoadError(msg: string) { + if (this.dataLoadError.get() != null) { + return; + } + mobx.action(() => { + this.dataLoadError.set(msg); + })(); + } + + @boundMethod + doRefresh() { + this.reloadTemplate(); + } + + renderCmdHints() { + return ( +
+
+
+ refresh +
+
+
+ ); + } + + render() { + let errorMessage = this.dataLoadError.get() ?? this.templateLoadError.get(); + if (errorMessage != null) { + return ( +
+
ERROR: {errorMessage}
+ {this.renderCmdHints()} +
+ ); + } + if (this.templateLoading.get() || this.dataLoading.get()) { + return ( +
+
+ loading content +
+ {this.renderCmdHints()} +
+ ); + } + let opts = this.props.opts; + let maxWidth = opts.maxSize.width; + let minWidth = opts.maxSize.width; + if (minWidth > 1000) { + minWidth = 1000; + } + let templateText = this.mustacheTemplateText.get(); + let templateData = this.parsedData.get() || {}; + let renderedText = null; + try { + renderedText = mustache.render(templateText, templateData); + renderedText = DOMPurify.sanitize(renderedText); + } catch (e) { + return ( +
+
ERROR running template: {e.message}
+ {this.renderCmdHints()} +
+ ); + } + // TODO non-term content font-size (default to 16) + return ( +
+
+
+
+ {this.renderCmdHints()} +
+ ); + } +} + +export { SimpleMustacheRenderer }; diff --git a/yarn.lock b/yarn.lock index 75f9a709d..12e29d8c6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5628,6 +5628,11 @@ multicast-dns@^7.2.5: dns-packet "^5.2.2" thunky "^1.0.2" +mustache@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/mustache/-/mustache-4.2.0.tgz#e5892324d60a12ec9c2a73359edca52972bf6f64" + integrity sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ== + nanoid@^3.3.6: version "3.3.6" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.6.tgz#443380c856d6e9f9824267d960b4236ad583ea4c"