waveterm/frontend/app/view/preview/preview.tsx
Mike Sawka 77bbf74ef9
fix bug with CodeEditor/monaco model, more preview refactoring (#2353)
the primary purpose of this PR is to fix a showstopper bug in the
CodeEditor component by setting "path" to a stable UUID. the bug was
that it started as empty string, so it created a shared model between
all of the codeeditor components. now each will get their own monaco
model.

also took this opportunity to do more more preview view refactoring,
splitting up code, and more tailwind migrations.
2025-09-15 16:01:29 -07:00

167 lines
5.8 KiB
TypeScript

// Copyright 2025, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { CenteredDiv } from "@/app/element/quickelems";
import { RpcApi } from "@/app/store/wshclientapi";
import { TabRpcClient } from "@/app/store/wshrpcutil";
import { BlockHeaderSuggestionControl } from "@/app/suggestion/suggestion";
import { globalStore } from "@/store/global";
import { isBlank, makeConnRoute } from "@/util/util";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { memo, useEffect } from "react";
import { CSVView } from "./csvview";
import { DirectoryPreview } from "./preview-directory";
import { CodeEditPreview } from "./preview-edit";
import { ErrorOverlay } from "./preview-error-overlay";
import { MarkdownPreview } from "./preview-markdown";
import type { PreviewModel } from "./preview-model";
import { StreamingPreview } from "./preview-streaming";
export type SpecializedViewProps = {
model: PreviewModel;
parentRef: React.RefObject<HTMLDivElement>;
};
const SpecializedViewMap: { [view: string]: ({ model }: SpecializedViewProps) => React.JSX.Element } = {
streaming: StreamingPreview,
markdown: MarkdownPreview,
codeedit: CodeEditPreview,
csv: CSVViewPreview,
directory: DirectoryPreview,
};
function canPreview(mimeType: string): boolean {
if (mimeType == null) {
return false;
}
return mimeType.startsWith("text/markdown") || mimeType.startsWith("text/csv");
}
function CSVViewPreview({ model, parentRef }: SpecializedViewProps) {
const fileContent = useAtomValue(model.fileContent);
const fileName = useAtomValue(model.statFilePath);
return <CSVView parentRef={parentRef} readonly={true} content={fileContent} filename={fileName} />;
}
const SpecializedView = memo(({ parentRef, model }: SpecializedViewProps) => {
const specializedView = useAtomValue(model.specializedView);
const mimeType = useAtomValue(model.fileMimeType);
const setCanPreview = useSetAtom(model.canPreview);
const path = useAtomValue(model.statFilePath);
useEffect(() => {
setCanPreview(canPreview(mimeType));
}, [mimeType, setCanPreview]);
if (specializedView.errorStr != null) {
return <CenteredDiv>{specializedView.errorStr}</CenteredDiv>;
}
const SpecializedViewComponent = SpecializedViewMap[specializedView.specializedView];
if (!SpecializedViewComponent) {
return <CenteredDiv>Invalid Specialized View Component ({specializedView.specializedView})</CenteredDiv>;
}
return <SpecializedViewComponent key={path} model={model} parentRef={parentRef} />;
});
const fetchSuggestions = async (
model: PreviewModel,
query: string,
reqContext: SuggestionRequestContext
): Promise<FetchSuggestionsResponse> => {
const conn = await globalStore.get(model.connection);
let route = makeConnRoute(conn);
if (isBlank(conn) || conn.startsWith("aws:")) {
route = null;
}
if (reqContext?.dispose) {
RpcApi.DisposeSuggestionsCommand(TabRpcClient, reqContext.widgetid, { noresponse: true, route: route });
return null;
}
const fileInfo = await globalStore.get(model.statFile);
if (fileInfo == null) {
return null;
}
const sdata = {
suggestiontype: "file",
"file:cwd": fileInfo.path,
query: query,
widgetid: reqContext.widgetid,
reqnum: reqContext.reqnum,
"file:connection": conn,
};
return await RpcApi.FetchSuggestionsCommand(TabRpcClient, sdata, {
route: route,
});
};
function PreviewView({
blockRef,
contentRef,
model,
}: {
blockId: string;
blockRef: React.RefObject<HTMLDivElement>;
contentRef: React.RefObject<HTMLDivElement>;
model: PreviewModel;
}) {
const connStatus = useAtomValue(model.connStatus);
const [errorMsg, setErrorMsg] = useAtom(model.errorMsgAtom);
const connection = useAtomValue(model.connectionImmediate);
const fileInfo = useAtomValue(model.statFile);
useEffect(() => {
console.log("fileInfo or connection changed", fileInfo, connection);
if (!fileInfo) {
return;
}
setErrorMsg(null);
}, [connection, fileInfo]);
if (connStatus?.status != "connected") {
return null;
}
const handleSelect = (s: SuggestionType, queryStr: string): boolean => {
if (s == null) {
if (isBlank(queryStr)) {
globalStore.set(model.openFileModal, false);
return true;
}
model.handleOpenFile(queryStr);
return true;
}
model.handleOpenFile(s["file:path"]);
return true;
};
const handleTab = (s: SuggestionType, query: string): string => {
if (s["file:mimetype"] == "directory") {
return s["file:name"] + "/";
} else {
return s["file:name"];
}
};
const fetchSuggestionsFn = async (query, ctx) => {
return await fetchSuggestions(model, query, ctx);
};
return (
<>
<div key="fullpreview" className="flex flex-col w-full overflow-hidden scrollbar-hide-until-hover">
{errorMsg && <ErrorOverlay errorMsg={errorMsg} resetOverlay={() => setErrorMsg(null)} />}
<div ref={contentRef} className="flex-grow overflow-hidden">
<SpecializedView parentRef={contentRef} model={model} />
</div>
</div>
<BlockHeaderSuggestionControl
blockRef={blockRef}
openAtom={model.openFileModal}
onClose={() => model.updateOpenFileModalAndError(false)}
onSelect={handleSelect}
onTab={handleTab}
fetchSuggestions={fetchSuggestionsFn}
placeholderText="Open File..."
/>
</>
);
}
export { PreviewView };