From 566bf461ff06dfc087a642fd016c5fa7c6ed6842 Mon Sep 17 00:00:00 2001
From: Mike Sawka <mike@commandline.dev>
Date: Fri, 6 Sep 2024 12:59:28 -0700
Subject: [PATCH] implement img streaming (local and remote) for markdown
 (#348)

---
 frontend/app/element/markdown.less    | 11 ++--
 frontend/app/element/markdown.tsx     | 74 ++++++++++++++++++++++++---
 frontend/app/view/preview/preview.tsx | 17 +++---
 frontend/types/custom.d.ts            |  5 ++
 frontend/util/util.ts                 |  8 +++
 pkg/web/web.go                        |  1 +
 6 files changed, 98 insertions(+), 18 deletions(-)

diff --git a/frontend/app/element/markdown.less b/frontend/app/element/markdown.less
index 8a266239e..6e4cbcc05 100644
--- a/frontend/app/element/markdown.less
+++ b/frontend/app/element/markdown.less
@@ -37,10 +37,13 @@
             color: #32afff;
         }
 
-        table {
-            tr th {
-                color: var(--app-text-color);
-            }
+        p,
+        ul,
+        ol,
+        dl,
+        table,
+        details table {
+            margin-bottom: 10px;
         }
 
         ul {
diff --git a/frontend/app/element/markdown.tsx b/frontend/app/element/markdown.tsx
index dfbc5807e..8caaf0974 100644
--- a/frontend/app/element/markdown.tsx
+++ b/frontend/app/element/markdown.tsx
@@ -2,16 +2,18 @@
 // SPDX-License-Identifier: Apache-2.0
 
 import { CopyButton } from "@/app/element/copybutton";
+import { WshServer } from "@/app/store/wshserver";
+import { getWebServerEndpoint } from "@/util/endpoints";
+import * as util from "@/util/util";
+import { useAtomValueSafe } from "@/util/util";
 import { clsx } from "clsx";
+import { Atom } from "jotai";
+import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
 import React, { CSSProperties, useCallback, useMemo, useRef } from "react";
 import ReactMarkdown from "react-markdown";
 import rehypeRaw from "rehype-raw";
-import remarkGfm from "remark-gfm";
-
-import { useAtomValueSafe } from "@/util/util";
-import { Atom } from "jotai";
-import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
 import RemarkFlexibleToc, { TocItem } from "remark-flexible-toc";
+import remarkGfm from "remark-gfm";
 import { useHeight } from "../hook/useHeight";
 import "./markdown.less";
 
@@ -75,6 +77,63 @@ const CodeBlock = ({ children, onClickExecute }: CodeBlockProps) => {
     );
 };
 
+const MarkdownSource = (props: any) => {
+    return null;
+};
+
+const MarkdownImg = (props: any) => {
+    const [resolvedSrc, setResolvedSrc] = React.useState<string>(props.src);
+    const [resolvedStr, setResolvedStr] = React.useState<string>(null);
+    const [resolving, setResolving] = React.useState<boolean>(true);
+    const resolveOpts: MarkdownResolveOpts = props.resolveOpts;
+
+    React.useEffect(() => {
+        if (props.src.startsWith("http://") || props.src.startsWith("https://")) {
+            setResolving(false);
+            setResolvedSrc(props.src);
+            setResolvedStr(null);
+            return;
+        }
+        if (props.src.startsWith("data:image/")) {
+            setResolving(false);
+            setResolvedSrc(props.src);
+            setResolvedStr(null);
+            return;
+        }
+        if (resolveOpts == null) {
+            setResolving(false);
+            setResolvedSrc(null);
+            setResolvedStr(`[img:${props.src}]`);
+            return;
+        }
+        const resolveFn = async () => {
+            const route = util.makeConnRoute(resolveOpts.connName);
+            const fileInfo = await WshServer.RemoteFileJoinCommand([resolveOpts.baseDir, props.src], { route: route });
+            const usp = new URLSearchParams();
+            usp.set("path", fileInfo.path);
+            if (!util.isBlank(resolveOpts.connName)) {
+                usp.set("connection", resolveOpts.connName);
+            }
+            const streamingUrl = getWebServerEndpoint() + "/wave/stream-file?" + usp.toString();
+            setResolvedSrc(streamingUrl);
+            setResolvedStr(null);
+            setResolving(false);
+        };
+        resolveFn();
+    }, [props.src]);
+
+    if (resolving) {
+        return null;
+    }
+    if (resolvedStr != null) {
+        return <span>{resolvedStr}</span>;
+    }
+    if (resolvedSrc != null) {
+        return <img {...props} src={resolvedSrc} />;
+    }
+    return <span>[img]</span>;
+};
+
 type MarkdownProps = {
     text?: string;
     textAtom?: Atom<string> | Atom<Promise<string>>;
@@ -82,9 +141,10 @@ type MarkdownProps = {
     style?: React.CSSProperties;
     className?: string;
     onClickExecute?: (cmd: string) => void;
+    resolveOpts?: MarkdownResolveOpts;
 };
 
-const Markdown = ({ text, textAtom, showTocAtom, style, className, onClickExecute }: MarkdownProps) => {
+const Markdown = ({ text, textAtom, showTocAtom, style, className, resolveOpts, onClickExecute }: MarkdownProps) => {
     const textAtomValue = useAtomValueSafe(textAtom);
     const tocRef = useRef<TocItem[]>([]);
     const showToc = useAtomValueSafe(showTocAtom) ?? false;
@@ -114,6 +174,8 @@ const Markdown = ({ text, textAtom, showTocAtom, style, className, onClickExecut
         h4: (props: any) => <Heading {...props} hnum={4} />,
         h5: (props: any) => <Heading {...props} hnum={5} />,
         h6: (props: any) => <Heading {...props} hnum={6} />,
+        img: (props: any) => <MarkdownImg {...props} resolveOpts={resolveOpts} />,
+        source: (props: any) => <MarkdownSource {...props} resolveOpts={resolveOpts} />,
         code: Code,
         pre: (props: any) => <CodeBlock {...props} onClickExecute={onClickExecute} />,
     };
diff --git a/frontend/app/view/preview/preview.tsx b/frontend/app/view/preview/preview.tsx
index 2b77b92a7..149288de7 100644
--- a/frontend/app/view/preview/preview.tsx
+++ b/frontend/app/view/preview/preview.tsx
@@ -43,13 +43,6 @@ const SpecializedViewMap: { [view: string]: ({ model }: SpecializedViewProps) =>
     directory: DirectoryPreview,
 };
 
-function makeConnRoute(conn: string): string {
-    if (util.isBlank(conn)) {
-        return "conn:local";
-    }
-    return "conn:" + conn;
-}
-
 function isTextFile(mimeType: string): boolean {
     if (mimeType == null) {
         return false;
@@ -686,9 +679,17 @@ function makePreviewModel(blockId: string, nodeModel: NodeModel): PreviewModel {
 }
 
 function MarkdownPreview({ model }: SpecializedViewProps) {
+    const connName = jotai.useAtomValue(model.connection);
+    const fileInfo = jotai.useAtomValue(model.statFile);
+    const resolveOpts: MarkdownResolveOpts = React.useMemo<MarkdownResolveOpts>(() => {
+        return {
+            connName: connName,
+            baseDir: fileInfo.dir,
+        };
+    }, [connName, fileInfo.dir]);
     return (
         <div className="view-preview view-preview-markdown">
-            <Markdown textAtom={model.fileContent} showTocAtom={model.markdownShowToc} />
+            <Markdown textAtom={model.fileContent} showTocAtom={model.markdownShowToc} resolveOpts={resolveOpts} />
         </div>
     );
 }
diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts
index 6bf485dd4..e645e6c40 100644
--- a/frontend/types/custom.d.ts
+++ b/frontend/types/custom.d.ts
@@ -268,6 +268,11 @@ declare global {
     }
 
     type SuggestionsType = SuggestionConnectionItem | SuggestionConnectionScope;
+
+    type MarkdownResolveOpts = {
+        connName: string;
+        baseDir: string;
+    };
 }
 
 export {};
diff --git a/frontend/util/util.ts b/frontend/util/util.ts
index f6c37e467..72729e855 100644
--- a/frontend/util/util.ts
+++ b/frontend/util/util.ts
@@ -264,6 +264,13 @@ function countGraphemes(str: string): number {
     return Array.from(seg.segment(str)).length;
 }
 
+function makeConnRoute(conn: string): string {
+    if (isBlank(conn)) {
+        return "conn:local";
+    }
+    return "conn:" + conn;
+}
+
 export {
     atomWithDebounce,
     atomWithThrottle,
@@ -279,6 +286,7 @@ export {
     jotaiLoadableValue,
     jsonDeepEqual,
     lazy,
+    makeConnRoute,
     makeExternLink,
     makeIconClass,
     stringToBase64,
diff --git a/pkg/web/web.go b/pkg/web/web.go
index ab002504a..96b1e77c6 100644
--- a/pkg/web/web.go
+++ b/pkg/web/web.go
@@ -310,6 +310,7 @@ func handleStreamFile(w http.ResponseWriter, r *http.Request) {
 	} else {
 		err := handleRemoteStreamFile(w, r, conn, fileName, no404 != "")
 		if err != nil {
+			log.Printf("error streaming remote file %q %q: %v\n", conn, fileName, err)
 			http.Error(w, fmt.Sprintf("error streaming file: %v", err), http.StatusInternalServerError)
 		}
 	}