Skip to content

Commit 477bf60

Browse files
committed
feat(shell): add command palette and Ctrl+K
1 parent 6b81f41 commit 477bf60

4 files changed

Lines changed: 487 additions & 4 deletions

File tree

‎app/src/App.tsx‎

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,15 @@ import { getCurrentWindow } from "@tauri-apps/api/window";
2727
// 「从未访问过的页不进首屏 module graph」与「访问过的页常驻、永不卸载(TD-004)」两条语义
2828
// 都逐字保留(保活由下面的 PageSlot + mountedPages 实施,本任务未动它们一个字节)。
2929
// @ai-context: 仍不引入路由库(规格 §3 红线 2):入口依旧是 useState<Page>。
30-
import { navComponent, type PageKey } from "./shell/navRegistry";
30+
// 批 3 T11:⌘K 面板的 `onPick` 收口要用它校验页面键(运行期校验,不信任回调实参)
31+
import { isPageKey, navComponent, type PageKey } from "./shell/navRegistry";
3132
// 批 3 T7:A′ 顶栏(规格 §6.1)从本文件的 95 行内联 `<nav>` 提成独立组件 —— 两档溢出是媒体查询的活,
3233
// 内联 style 表达不了;顶栏的宽度也因此第一次有了可测的单一落点(TopBar.tsx / TopBar.css)。
3334
import { TopBar, TopBarAction } from "./shell/TopBar";
35+
// 批 3 T11:⌘K 命令面板(规格 §6.1)—— 规格 §6.1 要它替代手写的 `focus*` 跳转入口,
36+
// 本批只做**页面级跳转 + 对话面板**两条动作(`focus*` 状态机原样保留,收敛属 T12)。
37+
// 自足实现、未 import 原语层(非目标 2):遮罩/Esc/焦点都在它自己文件里,批 4 换成 `Modal`。
38+
import { CommandPalette } from "./shell/CommandPalette";
3439
// 批 3 T7(控制方裁决 A3):AI toast 从 56px 导航行搬到**固定覆盖层** —— 层级走六档标尺,
3540
// 不许写裸数字(`ui/zIndex.guard.test.ts` 在看着)。实测 toast 是 1024 溢出的唯一主因
3641
// (单项 373.75 px = 视口的 36.5%;含它 1375.74 px、剔除它 997.99 px),而规格 §6.1 的顶栏
@@ -274,6 +279,23 @@ function MainShell() {
274279
return () => window.removeEventListener("keydown", onKey);
275280
}, []);
276281

282+
// 批 3 T11:⌘K / Ctrl+K 命令面板(规格 §6.1)。与上面的 Ctrl+Shift+A 并列而**互不遮蔽**:
283+
// 本处理器显式排除 Shift 与 Alt,且两条组合各自判 `e.key`(先判更具体的组合是纪律,不是巧合)。
284+
// ⚠️ 平台取舍:规格写的是「⌘K」(macOS 的 Command),本实现只判 `ctrlKey` —— 本应用是 Windows
285+
// 目标(Tauri 主窗),且 T7 顶栏按钮的文案/提示已定为「Ctrl+K」(单一说法)。
286+
// ⇒ macOS 的 `metaKey` 适配登记给做 macOS 支持的那一批(批 4/6 任一处),不在本任务里加半套。
287+
const [paletteOpen, setPaletteOpen] = useState(false);
288+
useEffect(() => {
289+
const onKey = (e: KeyboardEvent) => {
290+
if (e.ctrlKey && !e.shiftKey && !e.altKey && (e.key === "k" || e.key === "K")) {
291+
e.preventDefault();
292+
setPaletteOpen(true);
293+
}
294+
};
295+
window.addEventListener("keydown", onKey);
296+
return () => window.removeEventListener("keydown", onKey);
297+
}, []);
298+
277299
return (
278300
// 全局错误边界在 App 外层(批 2b 结构调整后包住 provider+壳,职责不变)
279301
<div style={{ height: "100vh", display: "flex", flexDirection: "column", fontFamily: "system-ui, sans-serif" }}>
@@ -282,15 +304,16 @@ function MainShell() {
282304
{/* 批 3 T7:A′ 顶栏(规格 §6.1)。原内联 9-Tab + 双 `marginLeft:auto` 布局已删除,
283305
改为 `shell/TopBar`(8 个域 Tab + ⌘K + ⚙ 齿轮,两档溢出走媒体查询)。
284306
`right` 插槽里是**两个常驻状态件**(都是已接线功能,不许在本步消失):
285-
· `dock-toggle` —— REQ-274 全局 AI 对话面板的**唯一显式入口**(T11 决定它进命令面板还是留在顶栏);
307+
· `dock-toggle` —— REQ-274 全局 AI 对话面板的**唯一显式入口**(T11-b 裁定:**保留**;
308+
T11 的命令面板里另给一条「对话面板」命令,那条是**增量**入口,不替代它);
286309
· 采集徽标 —— ADR-007,切页/最小化后仍可见采集状态(规格 §6.1 明确列了「采集状态」)。
287310
AI toast **不在**这里:它是本文件下方 `MainShell` 最外层渲染的 fixed 覆盖层(裁决 A3)。 */}
288311
<TopBar
289312
page={page}
290313
onSelect={(key) => setPage(key)}
291314
onOpenSettings={() => setPage("settings")}
292-
// T11 接线命令面板(⌘K 面板与它的数据源都在 T11/T12;本步按钮就位、回调留空)
293-
onOpenPalette={() => {}}
315+
// 批 3 T11:⌘K 按钮与 Ctrl+K 快捷键都开同一个面板(T7 时这里还是空实现)
316+
onOpenPalette={() => setPaletteOpen(true)}
294317
right={
295318
<>
296319
<TopBarAction
@@ -481,6 +504,22 @@ function MainShell() {
481504
{aiToast.text}
482505
</div>
483506
)}
507+
{/* 批 3 T11:⌘K 命令面板(规格 §6.1)。它是**壳级覆盖层**,与 toast 一样挂在导航壳最外层、
508+
不是任何页面的子节点(切页不重挂、也不参与页面布局)。
509+
`onPick` 只收口两种动作 —— 页面级 `setPage`(key 仍经 `isPageKey` 运行期校验,
510+
回调实参不被信任)与打开对话面板;`focus*` 深链状态机原样不动(收敛是 T12)。
511+
Esc / 点遮罩 / 焦点都在组件内部(自足实现,见 `shell/CommandPalette.tsx` 文件头)。 */}
512+
<CommandPalette
513+
open={paletteOpen}
514+
onClose={() => setPaletteOpen(false)}
515+
onPick={(pick) => {
516+
if (pick.kind === "dock") {
517+
setDockOpen(true);
518+
return;
519+
}
520+
if (isPageKey(pick.key)) setPage(pick.key);
521+
}}
522+
/>
484523
{/* REQ-274:全局 AI 对话面板(常驻挂载——开合仅切 display,选中态/后台任务保活)。
485524
批 2:闸门只加在**首开之前** —— dockMounted 一旦为真永不复位,此后 open/close
486525
仍是纯 display 切换(保活语义逐字保留);独立 Suspense(fallback=null) 与 PageSlot

‎app/src/shell/CommandPalette.css‎

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/*
2+
* CommandPalette.css — ⌘K 命令面板的**静态布局**(规格 §6.1;批 3 Task 11)。
3+
*
4+
* Why 自带一层样式、不复用原语层:批 3 非目标 2 禁止 import 原语层(那会把它的整层 CSS
5+
* 与 motion.css 一起拉进首屏,吃掉批 2 挣来的包体余量)。⇒ 本文件是「遮罩 + 面板 +
6+
* 一行一条命令」的最小集合;批 4 迁移到 `Modal` 时**整份删除**(迁移点写在 CommandPalette.tsx 文件头)。
7+
*
8+
* 层级**不在这里**:它走 `zIndex("modal")` 的行内 style —— 裸数字会被 `ui/zIndex.guard.test.ts`
9+
* 当场拦下(本文件因此一个 z-index 都不写)。
10+
*
11+
* 批 3 不加任何动效:本文件不得出现 transition / animation / @keyframes(属批 6;
12+
* `CommandPalette.test.tsx` 有一条静态守卫逐条扫,判据前先剥注释)。
13+
*
14+
* 遮罩底色与透明度消费 token(`--ed-overlay` + `--ed-overlay-alpha`),与 `primitives/Modal.css`
15+
* 用同一条 `color-mix` 写法 —— 不写 rgba 字面量,颜色只存在于 token 里。
16+
*/
17+
18+
.ed-cmdk__overlay {
19+
position: fixed;
20+
inset: 0;
21+
display: flex;
22+
align-items: flex-start;
23+
justify-content: center;
24+
/* 面板落在视口偏上处(命令台常规位置):用 padding 定位而不是 transform,免得与批 6 的动效属性同名 */
25+
padding: 96px 16px 16px;
26+
background: color-mix(in srgb, var(--ed-overlay) calc(var(--ed-overlay-alpha, 0.34) * 100%), transparent);
27+
}
28+
29+
.ed-cmdk {
30+
width: 560px;
31+
max-width: 100%;
32+
max-height: 60vh;
33+
display: flex;
34+
flex-direction: column;
35+
overflow: hidden;
36+
background: var(--ed-bg-raised, #fff);
37+
border: 1px solid var(--ed-border, #eae7e0);
38+
border-radius: var(--ed-radius-overlay, 10px);
39+
box-shadow: var(--ed-shadow-2);
40+
}
41+
42+
.ed-cmdk__input {
43+
flex: 0 0 auto;
44+
width: 100%;
45+
box-sizing: border-box;
46+
padding: 12px 16px;
47+
font-family: inherit;
48+
font-size: 15px;
49+
color: var(--ed-ink-1, #1a1a1a);
50+
background: transparent;
51+
border: none;
52+
border-bottom: 1px solid var(--ed-border, #eae7e0);
53+
outline: none;
54+
}
55+
56+
.ed-cmdk__list {
57+
flex: 1 1 auto;
58+
min-height: 0;
59+
overflow-y: auto;
60+
margin: 0;
61+
padding: 4px 0;
62+
list-style: none;
63+
}
64+
65+
.ed-cmdk__item {
66+
display: flex;
67+
align-items: center;
68+
justify-content: space-between;
69+
gap: 12px;
70+
padding: 8px 16px;
71+
font-size: 13px;
72+
color: var(--ed-ink-2, #3a3a36);
73+
cursor: pointer;
74+
}
75+
76+
/* 选中项:只改底色 / 字色 / 字重(不含位移与过渡 —— 动效属批 6) */
77+
.ed-cmdk__item--active {
78+
background: var(--ed-bg-sunken, #f1eee7);
79+
color: var(--ed-ink-1, #1a1a1a);
80+
font-weight: 600;
81+
}
82+
83+
.ed-cmdk__hint {
84+
flex: 0 0 auto;
85+
font-size: 12px;
86+
color: var(--ed-ink-3, #6e6e68);
87+
}
88+
89+
.ed-cmdk__empty {
90+
padding: 12px 16px;
91+
font-size: 13px;
92+
color: var(--ed-ink-3, #6e6e68);
93+
}
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* @ai-context ⌘K 命令面板的契约守卫(规格 §6.1;批 3 Task 11)。
4+
*
5+
* 判据分五层,每层都能**独立变红**(每条判据的变异体实测见 `task-11-report.md` §5):
6+
* ① 契约:`role="dialog"` + `aria-modal` + 输入框自动聚焦(**与 `Modal` 对齐的五条契约之三**,
7+
* 另两条 = ESC 关 / 点遮罩关,在 ② 层);
8+
* ② 关闭路径:Esc 关(window 冒泡相)· 点遮罩关(`mousedown`,与 `Modal` 同款)· 面板内点击**不**关;
9+
* ③ 键盘导航:↑↓ 改选中项(`aria-selected`)· Enter 执行并关闭 · 过滤后选中位回到首项;
10+
* ④ IME:**组合中按 Enter 不提交**(中文输入法不能误触)—— 判据自带**阳性对照**(同一条命令
11+
* 在不带 `isComposing` 的 Enter 下必须提交),否则「没提交」可能只是「Enter 从来没生效」;
12+
* ⑤ 静态纪律:9 个页面跳转命令来自注册表(不硬编码 label)· 不 import 原语层 · 层级走 `zIndex()`
13+
* 且无裸数字 · CSS 无 transition/animation(动效属批 6)。
14+
*
15+
* ⚠️ 本仓测试底座(照抄勿改):`vitest.config.ts` 全局 `environment: "node"` ⇒ 首行必须
16+
* `// @vitest-environment jsdom`;**未装** `jest-dom` 与 `user-event` ⇒ 断言用原生 DOM API、
17+
* 交互用 `fireEvent`;**没有** `globals` ⇒ RTL 的 auto-cleanup 不生效,必须显式 `afterEach(cleanup)`。
18+
*/
19+
import { readFileSync } from "node:fs";
20+
import { dirname, join } from "node:path";
21+
import { fileURLToPath } from "node:url";
22+
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
23+
import { afterEach, describe, expect, it, vi } from "vitest";
24+
import { CommandPalette } from "./CommandPalette";
25+
import { ALL_ENTRIES } from "./navRegistry";
26+
27+
const HERE = dirname(fileURLToPath(import.meta.url));
28+
const TSX = readFileSync(join(HERE, "CommandPalette.tsx"), "utf8");
29+
/** 判据前先剥注释(与 `TopBar.test.tsx:34` 同一口径):文件头解释「不得出现 transition」时点到了那个词 */
30+
const CSS = readFileSync(join(HERE, "CommandPalette.css"), "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
31+
/** `App.tsx` 的**只留代码**版本(剥块注释 + 整行 `//`)—— 与 `TopBar.test.tsx:43` 同一口径 */
32+
const APP_CODE = readFileSync(join(HERE, "..", "App.tsx"), "utf8")
33+
.replace(/\/\*[\s\S]*?\*\//g, "")
34+
.replace(/^\s*\/\/.*$/gm, "");
35+
36+
afterEach(cleanup);
37+
38+
const base = { open: true, onClose: vi.fn(), onPick: vi.fn() };
39+
40+
describe("CommandPalette 契约(规格 §6.1)", () => {
41+
it("① role=dialog + aria-modal + 输入框自动聚焦(批 4 迁移到 Modal 前的契约对齐)", () => {
42+
render(<CommandPalette {...base} />);
43+
const dialog = screen.getByTestId("command-palette");
44+
expect(dialog.getAttribute("role")).toBe("dialog");
45+
expect(dialog.getAttribute("aria-modal")).toBe("true");
46+
expect(dialog.getAttribute("aria-label")).toBe("命令面板");
47+
// 焦点:面板打开即落在输入框(键盘用户不必先按 Tab 找入口)
48+
expect(document.activeElement).toBe(screen.getByTestId("command-palette-input"));
49+
});
50+
51+
it("① open=false 渲染 null(关闭后不留遮罩/焦点陷阱)", () => {
52+
const { container } = render(<CommandPalette {...base} open={false} />);
53+
expect(container.firstChild).toBeNull();
54+
});
55+
56+
it("② Esc 关闭;关闭后 Esc 不再触发(监听随 open 解绑,避免常驻 window 监听)", () => {
57+
const onClose = vi.fn();
58+
const { unmount } = render(<CommandPalette {...base} onClose={onClose} />);
59+
fireEvent.keyDown(window, { key: "Escape" });
60+
expect(onClose).toHaveBeenCalledTimes(1);
61+
unmount();
62+
fireEvent.keyDown(window, { key: "Escape" });
63+
expect(onClose, "卸载后 Esc 仍触发 ⇒ window 监听没解绑").toHaveBeenCalledTimes(1);
64+
});
65+
66+
it("② 点遮罩关闭;面板内点击不关闭(缩进层次:遮罩与面板是两个节点)", () => {
67+
const onClose = vi.fn();
68+
render(<CommandPalette {...base} onClose={onClose} />);
69+
const panel = screen.getByTestId("command-palette");
70+
fireEvent.mouseDown(panel); // 面板内按下(含面板自身的空白区)
71+
expect(onClose, "面板内点击把面板关掉了").not.toHaveBeenCalled();
72+
const overlay = screen.getByTestId("command-palette-overlay");
73+
expect(overlay.contains(panel), "面板不在遮罩里 ⇒ 点遮罩判据测的不是本组件").toBe(true);
74+
fireEvent.mouseDown(overlay);
75+
expect(onClose).toHaveBeenCalledTimes(1);
76+
});
77+
78+
it("③ ↑↓ 改选中项(aria-selected 单一真源),Enter 执行并关闭", () => {
79+
const onPick = vi.fn();
80+
const onClose = vi.fn();
81+
render(<CommandPalette {...base} onPick={onPick} onClose={onClose} />);
82+
const input = screen.getByTestId("command-palette-input");
83+
// 首项默认选中(classroom = 注册表第 1 项)
84+
expect(screen.getByTestId(`command-page:${ALL_ENTRIES[0].key}`).getAttribute("aria-selected")).toBe("true");
85+
fireEvent.keyDown(input, { key: "ArrowDown" });
86+
expect(screen.getByTestId(`command-page:${ALL_ENTRIES[1].key}`).getAttribute("aria-selected")).toBe("true");
87+
expect(screen.getByTestId(`command-page:${ALL_ENTRIES[0].key}`).getAttribute("aria-selected")).toBe("false");
88+
fireEvent.keyDown(input, { key: "ArrowUp" });
89+
expect(screen.getByTestId(`command-page:${ALL_ENTRIES[0].key}`).getAttribute("aria-selected")).toBe("true");
90+
fireEvent.keyDown(input, { key: "Enter" });
91+
expect(onPick).toHaveBeenCalledWith({ kind: "page", key: ALL_ENTRIES[0].key });
92+
expect(onClose).toHaveBeenCalledTimes(1);
93+
});
94+
95+
it("③ 输入过滤:无匹配时给空态;过滤后选中位回到首项(不指向已被滤掉的命令)", () => {
96+
render(<CommandPalette {...base} />);
97+
const input = screen.getByTestId("command-palette-input");
98+
fireEvent.keyDown(input, { key: "ArrowDown" }); // 先把选中位挪到第 2 项
99+
fireEvent.change(input, { target: { value: "面板" } }); // 只剩「对话面板」一条
100+
expect(screen.queryByTestId(`command-page:${ALL_ENTRIES[0].key}`)).toBeNull();
101+
expect(screen.getByTestId("command-dock").getAttribute("aria-selected")).toBe("true");
102+
fireEvent.change(input, { target: { value: "zzz-不存在的命令" } });
103+
expect(screen.getByTestId("command-palette-empty")).toBeTruthy();
104+
});
105+
106+
it("③ 面板里的「对话面板」是增量入口(onPick kind=dock),顶栏 dock-toggle 不因此消失", () => {
107+
const onPick = vi.fn();
108+
render(<CommandPalette {...base} onPick={onPick} />);
109+
fireEvent.change(screen.getByTestId("command-palette-input"), { target: { value: "面板" } });
110+
fireEvent.click(screen.getByTestId("command-dock"));
111+
expect(onPick).toHaveBeenCalledWith({ kind: "dock" });
112+
});
113+
114+
it("④ IME 组合中按 Enter 不提交(判据自带阳性对照:同一命令普通 Enter 必须提交)", () => {
115+
const onPick = vi.fn();
116+
const onClose = vi.fn();
117+
render(<CommandPalette {...base} onPick={onPick} onClose={onClose} />);
118+
const input = screen.getByTestId("command-palette-input");
119+
fireEvent.keyDown(input, { key: "Enter", isComposing: true });
120+
expect(onPick, "组合中按 Enter 提交了(中文输入法会误触)").not.toHaveBeenCalled();
121+
expect(onClose, "组合中按 Enter 把面板关了").not.toHaveBeenCalled();
122+
// 阳性对照:去掉 isComposing 后同一条命令必须提交 —— 否则上一行只是「Enter 从来没生效」
123+
fireEvent.keyDown(input, { key: "Enter" });
124+
expect(onPick).toHaveBeenCalledWith({ kind: "page", key: ALL_ENTRIES[0].key });
125+
expect(onClose).toHaveBeenCalledTimes(1);
126+
});
127+
128+
it("⑤ 命令列表含 9 个页面跳转命令,label 逐字取自注册表(不硬编码、含设置页)", () => {
129+
render(<CommandPalette {...base} />);
130+
expect(ALL_ENTRIES, "注册表条目数变了 ⇒ 本判据的锚点失效").toHaveLength(9);
131+
for (const e of ALL_ENTRIES) {
132+
const item = screen.getByTestId(`command-page:${e.key}`);
133+
expect(item.querySelector(".ed-cmdk__label")?.textContent, e.key).toBe(e.label);
134+
}
135+
expect(screen.queryByTestId("command-page:not-a-page")).toBeNull(); // 阴性样本
136+
});
137+
138+
it("⑤ 静态纪律:不 import 原语层 · 层级走 zIndex() 标尺且无裸数字", () => {
139+
expect(/ui\/primitives/.test(TSX), "面板 import 了原语层(会把整层 CSS 拉进首屏)").toBe(false);
140+
expect(TSX.includes("<" + "svg"), "面板里出现内联 svg(图标一律走 ui/icons)").toBe(false);
141+
expect(/zIndex\(/.test(TSX), "层级没有走六档标尺").toBe(true);
142+
expect(/zIndex:\s*\d/.test(TSX), "层级写了裸数字").toBe(false);
143+
});
144+
145+
it("⑤ CSS 里没有动效声明(transition/animation/@keyframes 属批 6)", () => {
146+
const banned = [/transition\s*:/, /animation\s*:/, /@keyframes/];
147+
// 仪器自检:同一组正则对一段**确定含动效**的 CSS 必须命中(否则下面的「0 命中」是假绿)
148+
expect(banned.some((re) => re.test(".x { transition: opacity 200ms; }"))).toBe(true);
149+
const hits = banned.filter((re) => re.test(CSS)).map(String);
150+
expect(hits, `批 3 的面板 CSS 不得含动效(属批 6):${hits.join(" / ")}`).toEqual([]);
151+
});
152+
});
153+
154+
describe("App.tsx 的 ⌘K 接线(**静态**判据)", () => {
155+
/**
156+
* 仪器局限(如实声明,不许当成运行期证据):这四条是**源码文本**判据。
157+
* `App` 整树依赖 Tauri IPC(`invoke` / `listen` / `getCurrentWindow`),仓内**没有任何测试 import 它**
158+
* (实测 `importing_App=[]`,136 个测试文件逐个扫)⇒ 「按一次 Ctrl+K 面板真的开了」只有真机 / WebView2
159+
* 能给证据,本批未跑(§报告「没能验证的地方」)。这四条挡的是**接线被删/被改回去**这类静默回归。
160+
*/
161+
it("顶栏按钮 · Ctrl+K 监听 · 面板挂载三处都在,且 onPick 收口经 isPageKey(反例自检:空实现必须不通过)", () => {
162+
expect(/onOpenPalette=\{\(\) => setPaletteOpen\(true\)\}/.test(APP_CODE), "⌘K 按钮没接到面板").toBe(true);
163+
expect(/<CommandPalette[\s\S]*?open=\{paletteOpen\}/.test(APP_CODE), "面板挂载丢了").toBe(true);
164+
expect(/onClose=\{\(\) => setPaletteOpen\(false\)\}/.test(APP_CODE), "面板的关闭回调丢了").toBe(true);
165+
expect(/isPageKey\(pick\.key\)/.test(APP_CODE), "onPick 不过 isPageKey 就 setPage").toBe(true);
166+
// Ctrl+K 必须显式排除 Shift/Alt —— 否则会与既有的 Ctrl+Shift+A(对话面板)抢同一个事件
167+
expect(/e\.ctrlKey && !e\.shiftKey && !e\.altKey && \(e\.key === "k" \|\| e\.key === "K"\)/.test(APP_CODE)).toBe(true);
168+
// 反例自检:同一组判据对「T7 时期的空实现」必须不命中,否则这四条是恒真的
169+
expect(/onOpenPalette=\{\(\) => setPaletteOpen\(true\)\}/.test("onOpenPalette={() => {}}")).toBe(false);
170+
expect(/isPageKey\(pick\.key\)/.test("setPage(pick.key)")).toBe(false);
171+
});
172+
});

0 commit comments

Comments
 (0)