ae3ab5feb5
- os/ 67 文件(DesktopOS/Dock/Launcher/MenuBar/MissionControl/NotificationCenter/AppStore/Window 系统 + os*Store/hooks) - pages/Hub + api/modules/hub.ts + ImportHubModal(已有) - Inbox 采用上游 Mailbox(approvals/messages 双 Tab + MailAccessControlDrawer/useMailPendingCount/mailDetail),保留云超服 Harvest 组件 - App.tsx 引入 OS Shell 分支(isOsPath→DesktopOSPage, Router 外)+ /hub/admin 路由 + BackendModeRouter + RuntimeAvailabilityGuard,保留云超服 AuthGuard/chatSocket/OPC 主题/vi-th-my-lo - 双改合并:usePluginLoader(loadPawApp)/pluginMarket(isMarketPluginApp)/registry store(removeBySource)/hostExternals(removePlugin)/LanguageSwitcher(persistRemotely+ja-ru-pt-id)/BackendLoadingPage(override props)/ApprovalCard(reasoning)/auth.ts(mode)/auth/gate.ts - SidebarSettingsPanel 加 Desktop Mode 入口(OS Shell 切换) - locales 合并 os/hub/projectDirectory/inbox/account(zh/en/vi 用上游,lo/my/th 用 en 占位) - 验证:tsc 0 错误、build 通过(1m23s)、阶段5 新增 35 文件 271 测试全过;全量回归 30 失败均预存在(stash 验证)
117 lines
3.5 KiB
TypeScript
117 lines
3.5 KiB
TypeScript
/**
|
|
* Launcher.tsx — Start-menu overlay listing all registered apps in a grid
|
|
* with a search filter. Selecting an app opens its window and closes the
|
|
* launcher. Only apps whose route id resolves in the registry are shown.
|
|
*/
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Search } from "lucide-react";
|
|
import { useOsWindows } from "./osWindowStore";
|
|
import type { OsAppDef } from "./osApps";
|
|
import { buttonRoleProps } from "./a11y";
|
|
import { useOsStyles } from "./useOsStyles";
|
|
import { useOsAppLauncher } from "./useOsAppLauncher";
|
|
|
|
interface LauncherProps {
|
|
/** Apps to show (already filtered to installed + available). */
|
|
apps: OsAppDef[];
|
|
}
|
|
|
|
export default function Launcher({ apps: source }: LauncherProps) {
|
|
const { styles } = useOsStyles();
|
|
const { t } = useTranslation();
|
|
// Actions only (referentially stable) — window updates never re-render
|
|
// the launcher grid.
|
|
const setLauncher = useOsWindows((s) => s.setLauncher);
|
|
const launchApp = useOsAppLauncher();
|
|
const [query, setQuery] = useState("");
|
|
|
|
const apps = useMemo(
|
|
() =>
|
|
source.filter((a) => {
|
|
const label = t(a.labelKey, a.fallback).toLowerCase();
|
|
return label.includes(query.toLowerCase());
|
|
}),
|
|
[source, query, t],
|
|
);
|
|
|
|
const launch = useCallback(
|
|
async (app: OsAppDef) => {
|
|
if (await launchApp(app.routeId)) {
|
|
setLauncher(false);
|
|
}
|
|
},
|
|
[launchApp, setLauncher],
|
|
);
|
|
|
|
useEffect(() => {
|
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
if (event.key === "Escape") {
|
|
setLauncher(false);
|
|
} else if (
|
|
event.key === "Enter" &&
|
|
event.target instanceof HTMLInputElement &&
|
|
!event.isComposing &&
|
|
apps[0]
|
|
) {
|
|
void launch(apps[0]);
|
|
}
|
|
};
|
|
window.addEventListener("keydown", onKeyDown);
|
|
return () => window.removeEventListener("keydown", onKeyDown);
|
|
}, [apps, launch, setLauncher]);
|
|
|
|
return (
|
|
<div
|
|
className={styles.launcher}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label={t("os.launchpad", "Launchpad")}
|
|
onPointerDown={() => setLauncher(false)}
|
|
>
|
|
<div
|
|
className={styles.launcherSurface}
|
|
onPointerDown={(event) => event.stopPropagation()}
|
|
>
|
|
<div className={styles.launcherSearch}>
|
|
<Search size={17} aria-hidden />
|
|
<input
|
|
autoFocus
|
|
aria-label={t("common.search", "Search apps...")}
|
|
placeholder={t("common.search", "Search apps...")}
|
|
value={query}
|
|
onChange={(event) => setQuery(event.target.value)}
|
|
/>
|
|
</div>
|
|
<div className={styles.launcherGrid}>
|
|
{apps.map((a) => {
|
|
const Icon = a.Icon;
|
|
const activate = () => void launch(a);
|
|
return (
|
|
<div
|
|
key={a.routeId}
|
|
className={styles.launcherItem}
|
|
onClick={activate}
|
|
{...buttonRoleProps(activate, t(a.labelKey, a.fallback))}
|
|
>
|
|
<div
|
|
className={styles.launcherIcon}
|
|
style={{ background: a.accent }}
|
|
>
|
|
<Icon size={27} />
|
|
</div>
|
|
<span>{t(a.labelKey, a.fallback)}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
{apps.length === 0 && (
|
|
<div className={styles.launcherEmpty}>
|
|
{t("common.noData", "No apps")}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|