13d272c2d4
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 6m52s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 6m19s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 6m25s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 6m29s
Project CI / Backend tests (pull_request) Failing after 11s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m1s
Project CI / Repository checks (pull_request) Failing after 10s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m45s
Project CI / AI game creator shell web tests (pull_request) Failing after 3m29s
Project CI / Frontend tests (pull_request) Failing after 9m36s
Project CI / Native shell tests (pull_request) Successful in 13m30s
- WorkspaceLauncher 发布活动项目面板的 effect 不再依赖每次渲染都换身份的 openActiveProject:回调改走 ref,依赖只留数据 - useDirectActiveTurns 轮询只在快照内容变化时更新状态,空态保持引用稳定,避免每 5 秒换数组身份带动下游 effect - WindowChrome 的 context value 用 useMemo 收口,避免所有 useWindowChrome 消费方被无谓重渲染 - 新增回归测试 directActiveTurns.test.tsx:轮询返回值不变时快照引用必须不变 - pitfalls 记录现象/根因/定位手段(console.error 里抓调用栈)与验证数据:修复后 35 秒 0 条深度错误、renderer 工作集 4.2GB -> 254MB
239 lines
7.3 KiB
TypeScript
239 lines
7.3 KiB
TypeScript
import { getCurrentWindow } from '@tauri-apps/api/window';
|
|
import { Copy, Minus, Square, X } from 'lucide-react';
|
|
import {
|
|
type ReactNode,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useState,
|
|
} from 'react';
|
|
|
|
import brandIcon from '../../../../packages/shared/src/icons/taonier-product-ip.png';
|
|
import { ActiveProjectRunsPanel } from '../features/app-shell/ActiveProjectRunsPanel';
|
|
import { subscribeTauriEvent } from '../services/tauriEventSubscription';
|
|
import { AppUpdateNotice } from './AppUpdateNotice';
|
|
import {
|
|
WINDOW_CHROME_DEFAULT_TITLE,
|
|
type WindowChromeActiveProjectRuns,
|
|
WindowChromeContext,
|
|
type WindowChromeContextValue,
|
|
} from './windowChromeContext';
|
|
|
|
type WindowChromeProps = {
|
|
children: ReactNode;
|
|
};
|
|
|
|
function isTauriRuntime() {
|
|
return (
|
|
typeof window !== 'undefined' &&
|
|
Boolean(
|
|
(window as Window & { __TAURI_INTERNALS__?: unknown })
|
|
.__TAURI_INTERNALS__,
|
|
)
|
|
);
|
|
}
|
|
|
|
function getNativeWindow() {
|
|
if (!isTauriRuntime()) {
|
|
return null;
|
|
}
|
|
try {
|
|
return getCurrentWindow();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function WindowChrome({ children }: WindowChromeProps) {
|
|
const [title, setTitleState] = useState(WINDOW_CHROME_DEFAULT_TITLE);
|
|
const [walletSlot, setWalletSlot] = useState<HTMLDivElement | null>(null);
|
|
const [activeProjectRuns, setActiveProjectRuns] =
|
|
useState<WindowChromeActiveProjectRuns | null>(null);
|
|
|
|
const setTitle = useCallback((nextTitle: string | null | undefined) => {
|
|
const normalizedTitle = nextTitle?.trim();
|
|
setTitleState(normalizedTitle || WINDOW_CHROME_DEFAULT_TITLE);
|
|
}, []);
|
|
|
|
/**
|
|
* context value 必须 memo:内联对象会让所有 `useWindowChrome()` 消费方在标题栏
|
|
* 每次渲染时都重新拿到新对象,进而连带重跑它们依赖 context 的 effect。
|
|
*/
|
|
const contextValue = useMemo<WindowChromeContextValue>(
|
|
() => ({
|
|
isWindowChrome: true,
|
|
title,
|
|
setTitle,
|
|
walletSlot,
|
|
activeProjectRuns,
|
|
setActiveProjectRuns,
|
|
}),
|
|
[title, setTitle, walletSlot, activeProjectRuns],
|
|
);
|
|
|
|
const [isMaximized, setIsMaximized] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const nativeWindow = getNativeWindow();
|
|
if (!nativeWindow) {
|
|
return;
|
|
}
|
|
|
|
let disposed = false;
|
|
let unlistenResize: (() => void) | undefined;
|
|
|
|
const syncMaximizedState = () => {
|
|
void nativeWindow
|
|
.isMaximized()
|
|
.then((maximized) => {
|
|
if (!disposed) {
|
|
setIsMaximized(maximized);
|
|
}
|
|
})
|
|
.catch(() => undefined);
|
|
};
|
|
|
|
syncMaximizedState();
|
|
// 直接用窗口订阅入口而不是 `nativeWindow.onResized`:后者在 tauri 2.11 上会
|
|
// 走库内那条「读监听注册表条目再注销」的竞态路径(挂载即注销时抛
|
|
// `listeners[eventId].handlerId`),窗口标题栏又是每次启动必挂载的组件。
|
|
void subscribeTauriEvent('tauri://resize', syncMaximizedState, {
|
|
target: { kind: 'Window', label: nativeWindow.label },
|
|
})
|
|
.then((unlisten) => {
|
|
if (disposed) {
|
|
unlisten();
|
|
} else {
|
|
unlistenResize = unlisten;
|
|
}
|
|
})
|
|
.catch(() => undefined);
|
|
|
|
return () => {
|
|
disposed = true;
|
|
unlistenResize?.();
|
|
};
|
|
}, []);
|
|
|
|
async function minimizeWindow() {
|
|
const nativeWindow = getNativeWindow();
|
|
if (!nativeWindow) {
|
|
return;
|
|
}
|
|
await nativeWindow.minimize().catch(() => undefined);
|
|
}
|
|
|
|
async function toggleMaximizeWindow() {
|
|
const nativeWindow = getNativeWindow();
|
|
if (!nativeWindow) {
|
|
return;
|
|
}
|
|
await nativeWindow
|
|
.toggleMaximize()
|
|
.then(() => nativeWindow.isMaximized())
|
|
.then(setIsMaximized)
|
|
.catch(() => undefined);
|
|
}
|
|
|
|
async function closeWindow() {
|
|
const nativeWindow = getNativeWindow();
|
|
if (!nativeWindow) {
|
|
return;
|
|
}
|
|
await nativeWindow.close().catch(() => undefined);
|
|
}
|
|
|
|
return (
|
|
<WindowChromeContext.Provider value={contextValue}>
|
|
<div className="window-chrome">
|
|
<AppUpdateNotice />
|
|
<header className="window-chrome__bar" aria-label="窗口标题栏">
|
|
<div className="window-chrome__leading">
|
|
<div className="window-chrome__brand" aria-label="陶泥儿 GameAgent">
|
|
<span className="window-chrome__brand-mark">
|
|
<img src={brandIcon} alt="" />
|
|
</span>
|
|
<span className="window-chrome__brand-copy">
|
|
<strong>陶泥儿</strong>
|
|
<span>GameAgent</span>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="window-chrome__drag-region" data-tauri-drag-region>
|
|
<div className="window-chrome__title-wrap">
|
|
{activeProjectRuns &&
|
|
(activeProjectRuns.activeTurns.length > 0 ||
|
|
activeProjectRuns.readFailed) ? (
|
|
<ActiveProjectRunsPanel
|
|
activeTurns={activeProjectRuns.activeTurns}
|
|
currentProjectPath={activeProjectRuns.currentProjectPath}
|
|
readFailed={activeProjectRuns.readFailed}
|
|
onOpenProject={activeProjectRuns.onOpenProject}
|
|
placement="titlebar"
|
|
/>
|
|
) : (
|
|
<>
|
|
<span
|
|
className="window-chrome__workspace-dot"
|
|
aria-hidden="true"
|
|
/>
|
|
<span
|
|
className="window-chrome__title"
|
|
title={title}
|
|
aria-label={`当前工作区:${title}`}
|
|
>
|
|
{title}
|
|
</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="window-chrome__trailing">
|
|
<div
|
|
ref={setWalletSlot}
|
|
className="window-chrome__wallet-slot"
|
|
aria-label="泥点"
|
|
/>
|
|
<div className="window-chrome__controls" aria-label="窗口控制">
|
|
<button
|
|
type="button"
|
|
className="window-chrome__control"
|
|
aria-label="最小化"
|
|
title="最小化"
|
|
onClick={() => void minimizeWindow()}
|
|
>
|
|
<Minus aria-hidden="true" size={15} strokeWidth={1.8} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="window-chrome__control"
|
|
aria-label={isMaximized ? '还原窗口' : '最大化'}
|
|
title={isMaximized ? '还原窗口' : '最大化'}
|
|
onClick={() => void toggleMaximizeWindow()}
|
|
>
|
|
{isMaximized ? (
|
|
<Copy aria-hidden="true" size={13} strokeWidth={1.8} />
|
|
) : (
|
|
<Square aria-hidden="true" size={13} strokeWidth={1.8} />
|
|
)}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="window-chrome__control window-chrome__control--close"
|
|
aria-label="关闭"
|
|
title="关闭"
|
|
onClick={() => void closeWindow()}
|
|
>
|
|
<X aria-hidden="true" size={15} strokeWidth={1.8} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
<div className="window-chrome__content">{children}</div>
|
|
</div>
|
|
</WindowChromeContext.Provider>
|
|
);
|
|
}
|