refactor: rebuild desktop app with Tauri

This commit is contained in:
leookun
2026-08-24 02:49:00 +08:00
parent a3ec2a0dfc
commit 4053a7fb20
808 changed files with 87359 additions and 181491 deletions
@@ -0,0 +1,16 @@
.shell {
--app-edge: 8px;
--app-menu-width: 204px;
--window-safe-top: var(--window-header-height);
--window-menu-top: var(--window-safe-top);
--window-corner-safe-bottom: 0px;
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
background: var(--vscode-editor-background);
}
.windows {
--window-corner-safe-bottom: 6px;
}
+29
View File
@@ -0,0 +1,29 @@
import { Outlet } from "react-router-dom";
import styles from "./AppFrame.module.scss";
import { AppHeader } from "./AppHeader";
const currentPlatform = desktopPlatform();
document.documentElement.dataset.platform = currentPlatform;
export function AppFrame() {
const platform = currentPlatform;
const platformClasses = platform === "windows" ? [styles.windows] : [];
const nativeDesktop = "__TAURI_INTERNALS__" in window;
return (
<div className={[styles.shell, ...platformClasses].filter(Boolean).join(" ")}>
<AppHeader platform={platform} nativeDesktop={nativeDesktop} />
<Outlet />
</div>
);
}
export type DesktopPlatform = "macos" | "windows" | "linux";
function desktopPlatform(): DesktopPlatform {
// return "windows";
const agent = navigator.userAgent;
if (/Macintosh|Mac OS X/.test(agent)) return "macos";
if (/Windows/.test(agent)) return "windows";
return "linux";
}
@@ -0,0 +1,49 @@
@use "../styles/typography" as type;
.root {
position: fixed;
z-index: 12000;
top: 0;
right: 0;
left: 0;
height: var(--window-header-height);
background: transparent;
-webkit-user-select: none;
user-select: none;
}
.dragLayer {
position: absolute;
inset: 0;
}
.uiLayer {
position: absolute;
z-index: 1;
inset: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 8px;
padding-right: 0px;
pointer-events: none;
}
.identity {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
color: var(--vscode-foreground);
font-size: type.$font-size-base;
font-weight: 500;
pointer-events: none;
img {
width: 20px;
height: 20px;
flex: 0 0 auto;
}
}
+26
View File
@@ -0,0 +1,26 @@
import appIcon from "../../src-tauri/icons/32x32.png";
import type { DesktopPlatform } from "./AppFrame";
import { WindowControls } from "./WindowControls";
import styles from "./AppHeader.module.scss";
type AppHeaderProps = {
platform: DesktopPlatform;
nativeDesktop: boolean;
};
export function AppHeader({ platform, nativeDesktop }: AppHeaderProps) {
const showNativeUi = nativeDesktop && platform !== "macos";
return <header className={styles.root}>
<div className={styles.dragLayer} data-tauri-drag-region aria-hidden="true" />
<div className={styles.uiLayer}>
{showNativeUi && <>
<div className={styles.identity} aria-label="Cursor BYOK">
<img src={appIcon} alt="" />
<span>Cursor 助手 v0.1.0</span>
</div>
<WindowControls />
</>}
</div>
</header>;
}
@@ -0,0 +1,157 @@
@use "../styles/typography" as type;
.root {
--app-region-gap: 16px;
--app-toolbar-height: 36px;
--app-page-max-width: 1280px;
--app-page-padding: 18px;
--app-content-left: calc(var(--app-edge) + var(--app-menu-width) );
--app-content-top: calc(var(--window-safe-top) + var(--app-toolbar-height) + 10px );
background: var(--vscode-editor-background);
}
.menuCard {
position: fixed;
z-index: 10;
top: calc(var(--window-menu-top));
bottom: var(--app-edge);
left: var(--app-edge);
width: var(--app-menu-width);
overflow: hidden;
}
.navigation {
width: 100%;
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
padding: 6px;
-webkit-user-select: none;
user-select: none;
}
.navigationList {
--scroll-shadow-bottom-size: 28px;
flex: 1 1 auto;
width: 100%;
min-height: 0;
}
.navigationGroup {
display: flex;
align-items: center;
color: color-mix(in srgb, var(--vscode-descriptionForeground) 65%, transparent);
font-size: type.$font-size-xs;
font-weight: normal;
padding: 5px 9px 2px;
margin-top: 0;
}
.navigationRow {
flex: 0 0 36px;
display: flex;
align-items: stretch;
a {
width: 100%;
height: 36px;
display: flex;
align-items: center;
gap: 5px;
padding: 0 11px;
color: inherit;
text-decoration: none;
border-radius: 6px;
&:hover {
background: var(--vscode-list-hoverBackground);
}
&[aria-current="page"] {
color: var(--vscode-list-activeSelectionForeground);
background: var(--vscode-list-activeSelectionBackground);
}
}
}
.dismissReason {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 14px;
font-size: type.$font-size-base;
textarea {
min-height: 78px;
padding: 8px 10px;
resize: vertical;
color: var(--vscode-input-foreground);
background: var(--vscode-input-background);
border: 1px solid var(--vscode-input-border, var(--vscode-sideBar-border));
border-radius: 6px;
outline: none;
&:focus {
border-color: var(--vscode-focusBorder);
}
&:disabled {
opacity: 0.6;
}
}
}
.content {
width: calc(100% - var(--app-content-left));
height: 100%;
position: relative;
min-width: 0;
min-height: 0;
margin-left: var(--app-content-left);
overflow: hidden;
}
.keepAliveContainer,
.keepAlivePage {
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
}
.actionRegion {
position: absolute;
z-index: 12500;
top: var(--window-safe-top);
right: max(var(--app-page-padding), calc((100% - var(--app-page-max-width)) / 2));
height: calc(var(--app-content-top) - var(--window-safe-top));
display: flex;
align-items: center;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
-webkit-touch-callout: none;
-webkit-user-drag: none;
-webkit-app-region: drag;
}
.actions {
height: var(--app-toolbar-height);
display: flex;
align-items: center;
justify-content: flex-end;
padding: 2px 2px;
gap: 3px;
}
.pageActions {
display: flex;
align-items: center;
gap: 3px;
&:empty {
display: none;
}
}
+243
View File
@@ -0,0 +1,243 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { IconifyIcon } from "@iconify/react/offline";
import KeepAliveRouteOutlet from "keepalive-for-react-router";
import { NavLink, useLocation } from "react-router-dom";
import cursorIconUrl from "../assets/icons/cursor.svg";
import { api } from "../api";
import { AdMenu } from "../components/ads/AdMenu";
import { FloatingAd } from "../components/ads/FloatingAd";
import { AdActionType, type AdAction, type AdSlot } from "../components/ads/types";
import { PageLayout } from "../components/layout/PageLayout";
import { Card } from "../components/ui/Card";
import controls from "../components/ui/Controls.module.scss";
import { Icon } from "../components/ui/Icon";
import { Modal } from "../components/ui/Modal";
import { TooltipTrigger } from "../components/ui/TooltipTrigger";
import { flatColorAreaChartIcon, flatColorOrganizationIcon, flatColorSalesPerformanceIcon, flatColorSettingsIcon, refreshIcon } from "../components/ui/icons";
import { useMessage } from "../components/ui/message";
import { VirtualList } from "../components/virtual/VirtualList";
import { appStore, useAppStore } from "../store/appStore";
import styles from "./AppLayout.module.scss";
import { PageActionsTarget } from "./PageActions";
type MenuItem =
| { kind: "page"; path: string; label: string; icon: IconifyIcon | string }
| { kind: "group"; label: string };
const menuItems: MenuItem[] = [
{ kind: "page", path: "/", label: "数据概览", icon: flatColorAreaChartIcon },
{
kind: "page",
path: "/calls",
label: "调用详细",
icon: flatColorSalesPerformanceIcon,
},
{
kind: "page",
path: "/providers",
label: "上游配置",
icon: flatColorOrganizationIcon,
},
{ kind: "group", label: "Harness" },
{
kind: "page",
path: "/settings",
label: "系统设置",
icon: flatColorSettingsIcon,
},
{
kind: "page",
path: "/harness/cursor",
label: "Cursor 设置",
icon: cursorIconUrl,
},
];
const keptAlivePages = ["/", "/calls", "/providers", "/settings", "/harness/cursor"];
const readAdStorageKey = "cursor-byok:read-ad-ids";
const dismissedAdStorageKey = "cursor-byok:dismissed-ad-ids";
function loadStoredAdIds(key: string): Set<string> {
try {
const value: unknown = JSON.parse(localStorage.getItem(key) ?? "[]");
return new Set(Array.isArray(value) ? value.filter((id): id is string => typeof id === "string") : []);
} catch {
return new Set();
}
}
export function AppLayout() {
const { busy } = useAppStore();
const message = useMessage();
const location = useLocation();
const [actionTarget, setActionTarget] = useState<HTMLDivElement | null>(null);
const [ads, setAds] = useState<AdSlot[]>([]);
const [activeAd, setActiveAd] = useState<AdSlot | null>(null);
const [dismissCandidate, setDismissCandidate] = useState<AdSlot | null>(null);
const [dismissReason, setDismissReason] = useState("");
const [readAdIds, setReadAdIds] = useState(() => loadStoredAdIds(readAdStorageKey));
const [dismissedAdIds, setDismissedAdIds] = useState(() => loadStoredAdIds(dismissedAdStorageKey));
const dismissedAdIdsRef = useRef(dismissedAdIds);
dismissedAdIdsRef.current = dismissedAdIds;
const adTriggers = useRef(new Map<string, HTMLButtonElement>());
const closeAd = useCallback(() => setActiveAd(null), []);
const visibleAds = ads.filter((ad) => ad.enabled && ad.placement === "menu" && !dismissedAdIds.has(ad.id));
useEffect(() => {
let disposed = false;
let pending = false;
let lastRequestedAt = 0;
const refreshAds = () => {
const now = Date.now();
if (pending || now - lastRequestedAt < 10_000) return;
pending = true;
lastRequestedAt = now;
void api.ads(dismissedAdIdsRef.current)
.then((runtime) => {
if (disposed) return;
setAds(runtime.slots);
setActiveAd((current) => current
? runtime.slots.find((ad) => ad.id === current.id) ?? null
: null);
})
.catch(() => {
if (disposed) return;
setAds([]);
setActiveAd(null);
})
.finally(() => { pending = false; });
};
const refreshVisibleAds = () => {
if (document.visibilityState === "visible") refreshAds();
};
refreshAds();
window.addEventListener("focus", refreshAds);
document.addEventListener("visibilitychange", refreshVisibleAds);
return () => {
disposed = true;
window.removeEventListener("focus", refreshAds);
document.removeEventListener("visibilitychange", refreshVisibleAds);
};
}, []);
const openAd = useCallback((ad: AdSlot) => {
setReadAdIds((current) => {
if (current.has(ad.id)) return current;
const next = new Set(current).add(ad.id);
try {
localStorage.setItem(readAdStorageKey, JSON.stringify([...next]));
} catch {
// Read state remains valid for the current session when storage is unavailable.
}
return next;
});
setActiveAd((current) => current?.id === ad.id ? null : ad);
}, []);
const performAdAction = useCallback((action: AdAction) => {
if (action.type !== AdActionType.OpenBrowser) return;
void api.openExternalUrl(action.url)
.catch((cause) => message(cause instanceof Error ? cause.message : String(cause)));
}, [message]);
const openDismissAd = useCallback((ad: AdSlot) => {
setDismissReason("");
setDismissCandidate(ad);
}, []);
const closeDismissAd = useCallback(() => {
setDismissCandidate(null);
setDismissReason("");
}, []);
const dismissAd = useCallback(() => {
if (!dismissCandidate) return;
const dismissedId = dismissCandidate.id;
const reason = dismissReason.trim();
setDismissedAdIds((current) => {
const next = new Set(current).add(dismissedId);
try {
localStorage.setItem(dismissedAdStorageKey, JSON.stringify([...next]));
} catch {
// Dismissed state remains valid for the current session when storage is unavailable.
}
return next;
});
setActiveAd((current) => current?.id === dismissedId ? null : current);
setDismissCandidate(null);
setDismissReason("");
void api.dismissAd(dismissedId, reason).catch(() => {
// Dismissal is intentionally fire-and-forget so reporting never blocks the UI.
});
}, [dismissCandidate, dismissReason]);
return <PageLayout className={styles.root}>
<Card as="aside" className={styles.menuCard}>
<nav className={styles.navigation} aria-label={t("主菜单")}>
<VirtualList
items={menuItems}
itemKey={(item) => item.kind === "group" ? `group-${item.label}` : item.path}
estimatedItemHeight={36}
itemGap={3}
className={`${styles.navigationList} scroll-shadow-bottom`}
>
{(item) => item.kind === "group"
? <div className={styles.navigationGroup} key={`group-${item.label}`}>{item.label}</div>
: <div className={styles.navigationRow} key={item.path}>
<NavLink to={item.path} end={item.path === "/"}>
{typeof item.icon === "string"
? <Icon src={item.icon} size="1.3em" />
: <Icon icon={item.icon} size="1.3em" />}
<span>{item.label}</span>
</NavLink>
</div>}
</VirtualList>
<AdMenu ads={visibleAds} activeAdId={activeAd?.id} dismissingAdId={dismissCandidate?.id} readAdIds={readAdIds} triggerRefs={adTriggers} onOpen={openAd} onDismiss={openDismissAd} />
</nav>
</Card>
{activeAd && <FloatingAd ad={activeAd} trigger={adTriggers.current.get(activeAd.id) ?? null} onClose={closeAd} onAction={performAdAction} />}
<Modal
id="dismiss-ad-dialog"
open={dismissCandidate !== null}
title={t("不再显示此广告")}
closeLabel={t("取消")}
submitLabel={t("确认")}
onClose={closeDismissAd}
onSubmit={() => void dismissAd()}
>
<p>{t("你确认不想再看到此广告吗?")}</p>
<label className={styles.dismissReason}>
<span>{t("原因(可选)")}</span>
<textarea
value={dismissReason}
maxLength={2000}
rows={4}
placeholder={t("可以告诉我们原因")}
onChange={(event) => setDismissReason(event.target.value)}
/>
</label>
</Modal>
<main className={styles.content}>
<div className={styles.actionRegion}>
<Card className={styles.actions}>
{location.pathname !== "/" && <TooltipTrigger label={t("刷新")}><button className={controls.iconButton} aria-label={t("刷新")} disabled={busy} onClick={() => void appStore.refresh()}>
<Icon className={busy ? controls.spin : ""} icon={refreshIcon} size="1.1em" />
</button></TooltipTrigger>}
<div ref={setActionTarget} className={styles.pageActions} />
</Card>
</div>
<PageActionsTarget.Provider value={actionTarget}>
<KeepAliveRouteOutlet
activeCacheKey={location.pathname}
include={keptAlivePages}
max={keptAlivePages.length}
enableActivity
containerClassName={styles.keepAliveContainer}
cacheNodeClassName={styles.keepAlivePage}
/>
</PageActionsTarget.Provider>
</main>
</PageLayout>;
}
+11
View File
@@ -0,0 +1,11 @@
import { createContext, useContext, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { useKeepAliveContext } from "keepalive-for-react";
export const PageActionsTarget = createContext<HTMLElement | null>(null);
export function PageActions({ children }: { children: ReactNode }) {
const target = useContext(PageActionsTarget);
const { active } = useKeepAliveContext();
return active && target ? createPortal(children, target) : null;
}
@@ -0,0 +1,36 @@
.root {
position: relative;
z-index: 2;
height: 30px;
display: flex;
align-items: center;
gap: 1px;
pointer-events: auto;
-webkit-app-region: no-drag;
}
.button {
width: 30px;
height: 30px;
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: 0px;
color: var(--vscode-descriptionForeground);
background: transparent;
&:hover {
color: var(--vscode-foreground);
background: var(--vscode-toolbar-hoverBackground, var(--vscode-list-hoverBackground));
}
&:focus-visible {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: -1px;
}
}
.close:hover {
color: var(--vscode-foreground);
}
@@ -0,0 +1,69 @@
import { getCurrentWindow } from "@tauri-apps/api/window";
import { useEffect, useState } from "react";
import { Icon } from "../components/ui/Icon";
import {
windowCloseIcon,
windowMaximizeIcon,
windowMinimizeIcon,
windowRestoreIcon,
} from "../components/ui/icons";
import styles from "./WindowControls.module.scss";
export function WindowControls() {
const [maximized, setMaximized] = useState(false);
useEffect(() => {
const appWindow = getCurrentWindow();
let disposed = false;
let unlisten: (() => void) | undefined;
const updateMaximized = async () => {
const next = await appWindow.isMaximized();
if (!disposed) setMaximized(next);
};
void updateMaximized();
void appWindow.onResized(() => void updateMaximized()).then((stop) => {
if (disposed) stop();
else unlisten = stop;
});
return () => {
disposed = true;
unlisten?.();
};
}, []);
const appWindow = getCurrentWindow();
const maximizeLabel = maximized ? t("还原窗口") : t("最大化窗口");
return <div className={styles.root}>
<button
type="button"
className={styles.button}
aria-label={t("最小化窗口")}
title={t("最小化窗口")}
onClick={() => void appWindow.minimize()}
>
<Icon icon={windowMinimizeIcon} size="1.1em" />
</button>
<button
type="button"
className={styles.button}
aria-label={maximizeLabel}
title={maximizeLabel}
onClick={() => void appWindow.toggleMaximize()}
>
<Icon icon={maximized ? windowRestoreIcon : windowMaximizeIcon} size="1.1em" />
</button>
<button
type="button"
className={[styles.button, styles.close].join(" ")}
aria-label={t("关闭窗口")}
title={t("关闭窗口")}
onClick={() => void appWindow.close()}
>
<Icon icon={windowCloseIcon} size="1.1em" />
</button>
</div>;
}