c9df6edc63
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m40s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 7m15s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m17s
Project CI / Frontend tests (pull_request) Successful in 2m51s
Project CI / Backend tests (pull_request) Successful in 7m38s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m58s
Project CI / Repository checks (pull_request) Successful in 6m18s
Project CI / Native shell tests (pull_request) Has been cancelled
- 窗口标题栏标签主文案恒为当前项目名:WindowChromeActiveProjectRuns 新增 currentProjectName,由 WorkspaceLauncher 发布当前项目清单名,WindowChrome 透传给标题栏入口 - 正在运行的项目降级为圆点、运行中数量徽标与展开菜单:ActiveProjectRunsPanel 主文案不再取开始时间最晚的在跑项目 - 展开菜单里当前项目那一行沿用清单项目名,meta 首位标「当前」,aria-current 保留 - 运行快照读取失败不再整块替换标签:圆点转告警色,结论落在菜单内一行;既无在跑项目又无当前项目名时保留原来的纯提示 - 新增 .launcher-runs-panel-dot.is-warning 与 .launcher-runs-titlebar-menu-note 样式 - 用例:directActiveTurns 新增三条回归(别的项目在跑时标签仍是当前项目、当前项目自己跑时列表带「当前」、读取失败不顶掉当前项目名),WindowChrome 新增当前项目优先级用例,并把「只显示最后开始的项目」用例改名为「不在项目内时…」 - 文档:修正主规范 2026-09-15 段的标题栏条目、里程碑与实施计划文末修订,decision-log 追加 2026-10-04 决策
249 lines
7.6 KiB
TypeScript
249 lines
7.6 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 { APP_NAME } from '../app/appMetadata';
|
|
import { appUpdateCheckEnabled } from '../app/featureFlags';
|
|
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">
|
|
{appUpdateCheckEnabled ? <AppUpdateNotice /> : null}
|
|
<header
|
|
className="window-chrome__bar"
|
|
data-window-chrome-bar
|
|
aria-label="窗口标题栏"
|
|
>
|
|
<div className="window-chrome__leading">
|
|
<div
|
|
className="window-chrome__brand"
|
|
aria-label={`${APP_NAME} GameAgent`}
|
|
>
|
|
<span className="window-chrome__brand-mark">
|
|
<img src={brandIcon} alt="" />
|
|
</span>
|
|
<span className="window-chrome__brand-copy">
|
|
<strong>{APP_NAME}</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}
|
|
currentProjectName={activeProjectRuns.currentProjectName}
|
|
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>
|
|
);
|
|
}
|