UI only preview server (+ deployments) (#2919)

adds a new "preview server" for UI testing. hooking up to cloudflare
pages deployments. for now, just a test "about modal" component. will
add more later.
This commit is contained in:
Mike Sawka 2026-02-23 12:48:28 -08:00 committed by GitHub
parent 195277de45
commit 9abd590176
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 466 additions and 233 deletions

View file

@ -58,6 +58,20 @@ tasks:
WCLOUD_WS_ENDPOINT: "wss://wsapi-dev.waveterm.dev/"
WAVETERM_NOCONFIRMQUIT: "1"
preview:
desc: Run the standalone component preview server with HMR (no Electron, no backend).
dir: frontend/preview
cmd: npx vite
deps:
- npm:install
build:preview:
desc: Build the component preview server for static deployment.
dir: frontend/preview
cmd: npx vite build
deps:
- npm:install
electron:winquickdev:
desc: Run the Electron application via the Vite dev server (quick dev - Windows amd64 only, no generate, no wsh).
cmd: npm run dev

View file

@ -9,15 +9,17 @@ import { isDev } from "@/util/isdev";
import { useState } from "react";
import { getApi } from "../store/global";
interface AboutModalProps {}
interface AboutModalVProps {
versionString: string;
updaterChannel: string;
onClose: () => void;
}
const AboutModal = ({}: AboutModalProps) => {
const AboutModalV = ({ versionString, updaterChannel, onClose }: AboutModalVProps) => {
const currentDate = new Date();
const [details] = useState(() => getApi().getAboutModalDetails());
const [updaterChannel] = useState(() => getApi().getUpdaterChannel());
return (
<Modal className="pt-[34px] pb-[34px]" onClose={() => modalsModel.popModal()}>
<Modal className="pt-[34px] pb-[34px]" onClose={onClose}>
<div className="flex flex-col gap-[26px] w-full">
<div className="flex flex-col items-center justify-center gap-4 self-stretch w-full text-center">
<Logo />
@ -29,8 +31,7 @@ const AboutModal = ({}: AboutModalProps) => {
</div>
</div>
<div className="items-center gap-4 self-stretch w-full text-center">
Client Version {details.version} ({isDev() ? "dev-" : ""}
{details.buildTime})
Client Version {versionString}
<br />
Update Channel: {updaterChannel}
</div>
@ -68,6 +69,18 @@ const AboutModal = ({}: AboutModalProps) => {
);
};
AboutModalV.displayName = "AboutModalV";
interface AboutModalProps {}
const AboutModal = ({}: AboutModalProps) => {
const [details] = useState(() => getApi().getAboutModalDetails());
const [updaterChannel] = useState(() => getApi().getUpdaterChannel());
const versionString = `${details.version} (${isDev() ? "dev-" : ""}${details.buildTime})`;
return <AboutModalV versionString={versionString} updaterChannel={updaterChannel} onClose={() => modalsModel.popModal()} />;
};
AboutModal.displayName = "AboutModal";
export { AboutModal };
export { AboutModal, AboutModalV };

View file

@ -37,233 +37,229 @@ interface TabProps {
onLoaded: () => void;
}
const Tab = memo(
forwardRef<HTMLDivElement, TabProps>(
(
{ id, active, isBeforeActive, isDragging, tabWidth, isNew, onLoaded, onSelect, onClose, onDragStart },
ref
) => {
const [tabData, _] = useWaveObjectValue<Tab>(makeORef("tab", id));
const [originalName, setOriginalName] = useState("");
const [isEditable, setIsEditable] = useState(false);
const indicator = useAtomValue(getTabIndicatorAtom(id));
const TabInner = forwardRef<HTMLDivElement, TabProps>((props, ref) => {
const { id, active, isBeforeActive, isDragging, tabWidth, isNew, onLoaded, onSelect, onClose, onDragStart } = props;
const [tabData, _] = useWaveObjectValue<Tab>(makeORef("tab", id));
const [originalName, setOriginalName] = useState("");
const [isEditable, setIsEditable] = useState(false);
const indicator = useAtomValue(getTabIndicatorAtom(id));
const editableRef = useRef<HTMLDivElement>(null);
const editableTimeoutRef = useRef<NodeJS.Timeout>(null);
const loadedRef = useRef(false);
const tabRef = useRef<HTMLDivElement>(null);
const editableRef = useRef<HTMLDivElement>(null);
const editableTimeoutRef = useRef<NodeJS.Timeout>(null);
const loadedRef = useRef(false);
const tabRef = useRef<HTMLDivElement>(null);
useImperativeHandle(ref, () => tabRef.current as HTMLDivElement);
useImperativeHandle(ref, () => tabRef.current as HTMLDivElement);
useEffect(() => {
if (tabData?.name) {
setOriginalName(tabData.name);
}
}, [tabData]);
useEffect(() => {
return () => {
if (editableTimeoutRef.current) {
clearTimeout(editableTimeoutRef.current);
}
};
}, []);
const selectEditableText = useCallback(() => {
if (!editableRef.current) {
return;
}
editableRef.current.focus();
const range = document.createRange();
const selection = window.getSelection();
range.selectNodeContents(editableRef.current);
selection.removeAllRanges();
selection.addRange(range);
}, []);
const handleRenameTab: React.MouseEventHandler<HTMLDivElement> = (event) => {
event?.stopPropagation();
setIsEditable(true);
editableTimeoutRef.current = setTimeout(() => {
selectEditableText();
}, 50);
};
const handleBlur = () => {
let newText = editableRef.current.innerText.trim();
newText = newText || originalName;
editableRef.current.innerText = newText;
setIsEditable(false);
fireAndForget(() => ObjectService.UpdateTabName(id, newText));
setTimeout(() => refocusNode(null), 10);
};
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (event) => {
if ((event.metaKey || event.ctrlKey) && event.key === "a") {
event.preventDefault();
selectEditableText();
return;
}
// this counts glyphs, not characters
const curLen = Array.from(editableRef.current.innerText).length;
if (event.key === "Enter") {
event.preventDefault();
event.stopPropagation();
if (editableRef.current.innerText.trim() === "") {
editableRef.current.innerText = originalName;
}
editableRef.current.blur();
} else if (event.key === "Escape") {
editableRef.current.innerText = originalName;
editableRef.current.blur();
event.preventDefault();
event.stopPropagation();
} else if (curLen >= 14 && !["Backspace", "Delete", "ArrowLeft", "ArrowRight"].includes(event.key)) {
event.preventDefault();
event.stopPropagation();
}
};
useEffect(() => {
if (!loadedRef.current) {
onLoaded();
loadedRef.current = true;
}
}, [onLoaded]);
useEffect(() => {
if (tabRef.current && isNew) {
const initialWidth = `${(tabWidth / 3) * 2}px`;
tabRef.current.style.setProperty("--initial-tab-width", initialWidth);
tabRef.current.style.setProperty("--final-tab-width", `${tabWidth}px`);
}
}, [isNew, tabWidth]);
// Prevent drag from being triggered on mousedown
const handleMouseDownOnClose = (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
event.stopPropagation();
};
const handleTabClick = () => {
const currentIndicator = globalStore.get(getTabIndicatorAtom(id));
if (currentIndicator?.clearonfocus) {
clearTabIndicatorFromFocus(id);
}
onSelect();
};
const handleContextMenu = useCallback(
(e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
e.preventDefault();
let menu: ContextMenuItem[] = [];
const currentIndicator = globalStore.get(getTabIndicatorAtom(id));
if (currentIndicator) {
menu.push(
{
label: "Clear Tab Indicator",
click: () => setTabIndicator(id, null),
},
{
label: "Clear All Indicators",
click: () => clearAllTabIndicators(),
},
{ type: "separator" }
);
}
menu.push(
{ label: "Rename Tab", click: () => handleRenameTab(null) },
{
label: "Copy TabId",
click: () => fireAndForget(() => navigator.clipboard.writeText(id)),
},
{ type: "separator" }
);
const fullConfig = globalStore.get(atoms.fullConfigAtom);
const bgPresets: string[] = [];
for (const key in fullConfig?.presets ?? {}) {
if (key.startsWith("bg@")) {
bgPresets.push(key);
}
}
bgPresets.sort((a, b) => {
const aOrder = fullConfig.presets[a]["display:order"] ?? 0;
const bOrder = fullConfig.presets[b]["display:order"] ?? 0;
return aOrder - bOrder;
});
if (bgPresets.length > 0) {
const submenu: ContextMenuItem[] = [];
const oref = makeORef("tab", id);
for (const presetName of bgPresets) {
const preset = fullConfig.presets[presetName];
if (preset == null) {
continue;
}
submenu.push({
label: preset["display:name"] ?? presetName,
click: () =>
fireAndForget(async () => {
await ObjectService.UpdateObjectMeta(oref, preset);
RpcApi.ActivityCommand(TabRpcClient, { settabtheme: 1 }, { noresponse: true });
recordTEvent("action:settabtheme");
}),
});
}
menu.push({ label: "Backgrounds", type: "submenu", submenu }, { type: "separator" });
}
menu.push({ label: "Close Tab", click: () => onClose(null) });
ContextMenuModel.showContextMenu(menu, e);
},
[handleRenameTab, id, onClose]
);
return (
<div
ref={tabRef}
className={clsx("tab", {
active,
dragging: isDragging,
"before-active": isBeforeActive,
"new-tab": isNew,
})}
onMouseDown={onDragStart}
onClick={handleTabClick}
onContextMenu={handleContextMenu}
data-tab-id={id}
>
<div className="tab-inner">
<div
ref={editableRef}
className={clsx("name", { focused: isEditable })}
contentEditable={isEditable}
onDoubleClick={handleRenameTab}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
suppressContentEditableWarning={true}
>
{tabData?.name}
</div>
{indicator && (
<div
className="tab-indicator pointer-events-none"
style={{ color: indicator.color || "#fbbf24" }}
title="Activity notification"
>
<i className={makeIconClass(indicator.icon, true, { defaultIcon: "bell" })} />
</div>
)}
<Button
className="ghost grey close"
onClick={onClose}
onMouseDown={handleMouseDownOnClose}
title="Close Tab"
>
<i className="fa fa-solid fa-xmark" />
</Button>
</div>
</div>
);
useEffect(() => {
if (tabData?.name) {
setOriginalName(tabData.name);
}
)
);
}, [tabData]);
useEffect(() => {
return () => {
if (editableTimeoutRef.current) {
clearTimeout(editableTimeoutRef.current);
}
};
}, []);
const selectEditableText = useCallback(() => {
if (!editableRef.current) {
return;
}
editableRef.current.focus();
const range = document.createRange();
const selection = window.getSelection();
range.selectNodeContents(editableRef.current);
selection.removeAllRanges();
selection.addRange(range);
}, []);
const handleRenameTab: React.MouseEventHandler<HTMLDivElement> = (event) => {
event?.stopPropagation();
setIsEditable(true);
editableTimeoutRef.current = setTimeout(() => {
selectEditableText();
}, 50);
};
const handleBlur = () => {
let newText = editableRef.current.innerText.trim();
newText = newText || originalName;
editableRef.current.innerText = newText;
setIsEditable(false);
fireAndForget(() => ObjectService.UpdateTabName(id, newText));
setTimeout(() => refocusNode(null), 10);
};
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (event) => {
if ((event.metaKey || event.ctrlKey) && event.key === "a") {
event.preventDefault();
selectEditableText();
return;
}
// this counts glyphs, not characters
const curLen = Array.from(editableRef.current.innerText).length;
if (event.key === "Enter") {
event.preventDefault();
event.stopPropagation();
if (editableRef.current.innerText.trim() === "") {
editableRef.current.innerText = originalName;
}
editableRef.current.blur();
} else if (event.key === "Escape") {
editableRef.current.innerText = originalName;
editableRef.current.blur();
event.preventDefault();
event.stopPropagation();
} else if (curLen >= 14 && !["Backspace", "Delete", "ArrowLeft", "ArrowRight"].includes(event.key)) {
event.preventDefault();
event.stopPropagation();
}
};
useEffect(() => {
if (!loadedRef.current) {
onLoaded();
loadedRef.current = true;
}
}, [onLoaded]);
useEffect(() => {
if (tabRef.current && isNew) {
const initialWidth = `${(tabWidth / 3) * 2}px`;
tabRef.current.style.setProperty("--initial-tab-width", initialWidth);
tabRef.current.style.setProperty("--final-tab-width", `${tabWidth}px`);
}
}, [isNew, tabWidth]);
// Prevent drag from being triggered on mousedown
const handleMouseDownOnClose = (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
event.stopPropagation();
};
const handleTabClick = () => {
const currentIndicator = globalStore.get(getTabIndicatorAtom(id));
if (currentIndicator?.clearonfocus) {
clearTabIndicatorFromFocus(id);
}
onSelect();
};
const handleContextMenu = useCallback(
(e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
e.preventDefault();
let menu: ContextMenuItem[] = [];
const currentIndicator = globalStore.get(getTabIndicatorAtom(id));
if (currentIndicator) {
menu.push(
{
label: "Clear Tab Indicator",
click: () => setTabIndicator(id, null),
},
{
label: "Clear All Indicators",
click: () => clearAllTabIndicators(),
},
{ type: "separator" }
);
}
menu.push(
{ label: "Rename Tab", click: () => handleRenameTab(null) },
{
label: "Copy TabId",
click: () => fireAndForget(() => navigator.clipboard.writeText(id)),
},
{ type: "separator" }
);
const fullConfig = globalStore.get(atoms.fullConfigAtom);
const bgPresets: string[] = [];
for (const key in fullConfig?.presets ?? {}) {
if (key.startsWith("bg@")) {
bgPresets.push(key);
}
}
bgPresets.sort((a, b) => {
const aOrder = fullConfig.presets[a]["display:order"] ?? 0;
const bOrder = fullConfig.presets[b]["display:order"] ?? 0;
return aOrder - bOrder;
});
if (bgPresets.length > 0) {
const submenu: ContextMenuItem[] = [];
const oref = makeORef("tab", id);
for (const presetName of bgPresets) {
const preset = fullConfig.presets[presetName];
if (preset == null) {
continue;
}
submenu.push({
label: preset["display:name"] ?? presetName,
click: () =>
fireAndForget(async () => {
await ObjectService.UpdateObjectMeta(oref, preset);
RpcApi.ActivityCommand(TabRpcClient, { settabtheme: 1 }, { noresponse: true });
recordTEvent("action:settabtheme");
}),
});
}
menu.push({ label: "Backgrounds", type: "submenu", submenu }, { type: "separator" });
}
menu.push({ label: "Close Tab", click: () => onClose(null) });
ContextMenuModel.showContextMenu(menu, e);
},
[handleRenameTab, id, onClose]
);
return (
<div
ref={tabRef}
className={clsx("tab", {
active,
dragging: isDragging,
"before-active": isBeforeActive,
"new-tab": isNew,
})}
onMouseDown={onDragStart}
onClick={handleTabClick}
onContextMenu={handleContextMenu}
data-tab-id={id}
>
<div className="tab-inner">
<div
ref={editableRef}
className={clsx("name", { focused: isEditable })}
contentEditable={isEditable}
onDoubleClick={handleRenameTab}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
suppressContentEditableWarning={true}
>
{tabData?.name}
</div>
{indicator && (
<div
className="tab-indicator pointer-events-none"
style={{ color: indicator.color || "#fbbf24" }}
title="Activity notification"
>
<i className={makeIconClass(indicator.icon, true, { defaultIcon: "bell" })} />
</div>
)}
<Button
className="ghost grey close"
onClick={onClose}
onMouseDown={handleMouseDownOnClose}
title="Close Tab"
>
<i className="fa fa-solid fa-xmark" />
</Button>
</div>
</div>
);
});
const Tab = memo(TabInner);
Tab.displayName = "Tab";
export { Tab };

View file

@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<title>Wave Preview Server</title>
<link rel="stylesheet" href="/fontawesome/css/fontawesome.min.css" />
<link rel="stylesheet" href="/fontawesome/css/brands.min.css" />
<link rel="stylesheet" href="/fontawesome/css/solid.min.css" />
<link rel="stylesheet" href="/fontawesome/css/sharp-solid.min.css" />
<link rel="stylesheet" href="/fontawesome/css/sharp-regular.min.css" />
<link rel="stylesheet" href="/fontawesome/css/custom-icons.min.css" />
</head>
<body class="init" data-colorscheme="dark">
<div id="main"></div>
<script type="module" src="./preview.tsx"></script>
</body>
</html>

View file

@ -0,0 +1,8 @@
/* Copyright 2026, Command Line Inc.
SPDX-License-Identifier: Apache-2.0 */
/* Re-export the main tailwind setup, adding extra @source so Tailwind v4
scans the full frontend/app tree (the preview vite root is frontend/preview/,
so the automatic scan would otherwise miss frontend/app/**). */
@import "../tailwindsetup.css";
@source "../app";

View file

@ -0,0 +1,117 @@
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import Logo from "@/app/asset/logo.svg";
import React, { lazy, Suspense } from "react";
import { createRoot } from "react-dom/client";
import "../app/app.scss";
// preview.css should come *after* app.scss (don't remove the newline above otherwise prettier will reorder these imports)
// preview.css re-exports tailwindsetup.css and adds @source "../app" so Tailwind v4 scans frontend/app/** for class names
import "./preview.css";
// Vite glob import — statically analyzed at build time, lazily loaded at runtime.
// Each *.preview.tsx file is auto-discovered; its filename (minus the suffix) becomes the key.
// Files may use a default export or any named export — the first export found is used as the component.
const previewModules = import.meta.glob<{ default?: React.ComponentType; [key: string]: unknown }>(
"./previews/*.preview.tsx"
);
// Derive a human-readable key from the file path, e.g.:
// "./previews/modal-about.preview.tsx" → "modal-about"
function pathToKey(path: string): string {
return path.replace(/^\.\/previews\//, "").replace(/\.preview\.tsx$/, "");
}
// Build a map of key → lazy React component.
// Each preview file is expected to have a default export that is the preview component.
const previews: Record<string, React.LazyExoticComponent<React.ComponentType>> = Object.fromEntries(
Object.entries(previewModules).map(([path, loader]) => [
pathToKey(path),
lazy(() =>
loader().then((mod) => ({ default: (mod.default ?? Object.values(mod)[0]) as React.ComponentType }))
),
])
);
function PreviewIndex() {
return (
<div className="min-h-screen bg-background text-foreground font-sans flex flex-col items-center justify-center gap-6">
<div className="flex flex-col items-center gap-3">
<Logo />
<h1 className="text-title font-semibold tracking-tight text-foreground">Wave Preview Server</h1>
</div>
<div className="w-px h-8 bg-border" />
<div className="flex flex-col items-center gap-3">
<p className="text-muted text-xs mb-1">Available previews:</p>
{Object.keys(previews).map((name) => (
<a
key={name}
href={`?preview=${name}`}
className="font-mono text-accent bg-accentbg px-3 py-1.5 rounded text-sm hover:opacity-80 transition-opacity"
>
{name}
</a>
))}
</div>
</div>
);
}
function PreviewHeader({ previewName }: { previewName: string }) {
return (
<div
className="fixed top-0 left-0 right-0 flex items-center gap-3 px-4 py-2 bg-panel border-b border-border"
style={{ zIndex: 100000 }}
>
<a
href="/"
className="flex items-center gap-1.5 text-accent text-sm hover:opacity-80 transition-opacity font-mono"
>
index
</a>
<div className="w-px h-4 bg-border" />
<span className="text-muted text-xs font-mono">{previewName}</span>
</div>
);
}
function PreviewApp() {
const params = new URLSearchParams(window.location.search);
const previewName = params.get("preview");
if (previewName) {
const PreviewComponent = previews[previewName];
if (PreviewComponent) {
return (
<>
<PreviewHeader previewName={previewName} />
<div className="min-h-screen bg-background text-foreground font-sans flex flex-col items-center justify-center">
<Suspense fallback={null}>
<PreviewComponent />
</Suspense>
</div>
</>
);
}
return (
<>
<PreviewHeader previewName={previewName} />
<div className="min-h-screen bg-background text-foreground font-sans flex flex-col items-center justify-center gap-4">
<p className="text-error">Preview not found: {previewName}</p>
<a href="/" className="text-accent text-sm hover:opacity-80">
Back to index
</a>
</div>
</>
);
}
return <PreviewIndex />;
}
const root = createRoot(document.getElementById("main")!);
root.render(<PreviewApp />);

View file

View file

@ -0,0 +1,14 @@
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { AboutModalV } from "@/app/modals/about";
export function AboutModalPreview() {
return (
<AboutModalV
versionString="0.11.0 (1740000000)"
updaterChannel="stable"
onClose={() => console.log("close")}
/>
);
}

View file

@ -0,0 +1,29 @@
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react-swc";
import path from "path";
import { defineConfig } from "vite";
import svgr from "vite-plugin-svgr";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
root: __dirname,
base: "./",
// Serve the workspace-root public/ directory so Font Awesome and other
// static assets (served by Electron in the real app) are available here too.
publicDir: path.resolve(__dirname, "../../public"),
plugins: [
tsconfigPaths(),
svgr({
svgrOptions: { exportType: "default", ref: true, svgo: false, titleProp: true },
include: "**/*.svg",
}),
react(),
tailwindcss(),
],
server: {
port: 7007,
},
});

View file

@ -266,3 +266,15 @@ declare interface VitePreloadErrorEvent extends Event {
declare interface WindowEventMap {
"vite:preloadError": VitePreloadErrorEvent;
}
// import.meta.glob — provided by Vite at build time
interface ImportMeta {
glob<T = Record<string, unknown>>(
pattern: string | string[],
options?: { eager?: boolean; import?: string; query?: string | Record<string, string> }
): Record<string, () => Promise<T>>;
glob<T = Record<string, unknown>>(
pattern: string | string[],
options: { eager: true; import?: string; query?: string | Record<string, string> }
): Record<string, T>;
}

View file

@ -25,7 +25,7 @@
"build:prod": "electron-vite build --mode production",
"coverage": "vitest run --coverage",
"test": "vitest",
"postinstall": "electron-builder install-app-deps"
"postinstall": "node ./postinstall.cjs"
},
"devDependencies": {
"@eslint/js": "^8.57.0",

11
postinstall.cjs Normal file
View file

@ -0,0 +1,11 @@
const skip =
process.env.WAVETERM_SKIP_APP_DEPS === "1" || process.env.CF_PAGES === "1" || process.env.CF_PAGES === "true";
if (skip) {
console.log("postinstall: skipping electron-builder install-app-deps");
process.exit(0);
}
import("child_process").then(({ execSync }) => {
execSync("electron-builder install-app-deps", { stdio: "inherit" });
});