Skip to content

Commit c652f67

Browse files
committed
fix(deep-link): reset sticky focus* after consume
批 5 T16(裁决 C6):只做**最小收敛** —— 给 5 个粘滞字段(focusSessionId / focusNoteId / focusNoteSearch / focusSystemId / focusGroupId)补 onFocus*Consumed 复位回调,**声明形态与类型一字不动**(App.tsx 仍是 `const [focusX, setFocusX] = useState<…>(null)` ⇒ CommandPalette.kb.test.tsx:173-181 的既有断言继续绿)。 - useNotesDeepLink:新增 onConsumed?: () => void(消费完成后调用一次;**成功与失败 两条终止路径都调** —— 失败不复位会让陈旧值卡死同目标重试,因为同值 setState 不产生 prop 变化)。**丢弃分支(disposed / seq 过期)故意不复位**:复位触发的父重渲染会让 本 effect 的 cleanup 把正在跑的那一次取消掉(StrictMode 双调用下目标永不选中)。 - 三页接线:SessionsPage(focusSessionId)· KnowledgePage(focusSystemId)· NotesPage(focusNoteId/focusNoteSearch/focusGroupId **共用一个**回调,透传深链 hook)。 页面内消费即调回调 ⇒ 消费后字段归零 ⇒ **同目标再次跳转能重新触发**(旧形态下固定值 不产生 prop 变化 ⇒ effect 不重跑 ⇒ 深链静默失效,这是最小收敛修掉的真实缺陷)。 - App.tsx:三组复位透传(focusNoteId 那组一次清三个字段)。584 → 590(≤600 红线内)。 - 事实更正:useNotesDeepLink.ts 的 @ai-context 逐字「清空责任在 App(本页无 onFocus*Consumed 回调)」与 App.tsx 实现不符(App 里没有该复位)⇒ 按 C6 逐字改写为 「消费后由本 hook 调用 onConsumed …**批 5 C6 起本页有该回调**」(标签不许说谎)。 判据(app/src/hooks/useNotesDeepLink.test.ts,新增 8 用例):K2 消费后 onConsumed 恰 1 次(笔记 / 组两条路径)· K3 连续两次跳**同一**对象都能触发 · 加载失败同样复位 · K4 注释与实现一致 · K5 App.tsx ≤600 镜像 · 接线(App 三组复位 + 三页消费点,静态判据, 照 CommandPalette.kb.test.tsx ⑤ 的既有范式)。每条各带变异体(见 T16 报告)。 行数(零新增豁免登记,122/122):App.tsx 584 → 590 · SessionsPage **332(登记值 332, 零增长** —— 两处纯注释折行让出 2 行以容纳新 prop 与消费点)· NotesPage 287 → 288 (同款折行)· KnowledgePage 441 → 444 · useNotesDeepLink 106 → 119 · 新测试 154 行。 豁免表只同步 App / KnowledgePage 两行的数字(--write 的 2 处改动),**无新增条目**。
1 parent 5f39cef commit c652f67

7 files changed

Lines changed: 233 additions & 27 deletions

File tree

‎app/src/App.tsx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,8 @@ function MainShell() {
443443
{/* v0.7.1:active 驱动列表刷新(display:none 挂载不刷新根治)+ 查看笔记跨页直达 */}
444444
<SessionsPage
445445
focusSessionId={focusSessionId}
446+
// 批 5 C6:深链消费后复位(同会话再次跳转才能重新触发——旧形态固定值粘滞)
447+
onFocusSessionConsumed={() => setFocusSessionId(null)}
446448
// v0.16.1:工作台深链 / 精修启动 → AI 对话页(focus 消费后即清空)
447449
focusRefineTaskId={focusRefineTaskId}
448450
onFocusRefineTaskConsumed={() => setFocusRefineTaskId(null)}
@@ -458,6 +460,8 @@ function MainShell() {
458460
focusNoteId={focusNoteId}
459461
focusNoteSearch={focusNoteSearch}
460462
focusGroupId={focusGroupId}
463+
// 批 5 C6:三个 focus* 共用一个复位回调(消费即清——复位后同目标再次跳转仍触发)
464+
onFocusNoteConsumed={() => { setFocusNoteId(null); setFocusNoteSearch(null); setFocusGroupId(null); }}
461465
// v0.20.10(批 5):ⓘ「复习本组」深链 → 复习页组预选(T12:入口收敛为 goReviewGroup)
462466
onOpenReview={goReviewGroup}
463467
onOpenSystem={goSystem}
@@ -503,6 +507,8 @@ function MainShell() {
503507
v0.13.7:focusSystemId 跨页直达(组行徽标/结算简报 → 自动选中体系) */}
504508
<KnowledgePage
505509
focusSystemId={focusSystemId}
510+
// 批 5 C6:深链消费后复位(同体系再次跳转才能重新触发)
511+
onFocusSystemConsumed={() => setFocusSystemId(null)}
506512
createSystemSignal={createSystemSignal}
507513
onOpenNote={(id) => { setFocusNoteId(id); setPage("notes"); }}
508514
onOpenGroup={goGroup}
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* useNotesDeepLink.test.ts — 批 5 T16(裁决 C6)的判据集:`focus*` 的**消费复位**契约。
4+
*
5+
* @ai-context: C6 只做**最小收敛**——给 5 个粘滞字段(`focusSessionId` / `focusNoteId` /
6+
* `focusNoteSearch` / `focusSystemId` / `focusGroupId`)补 `onFocus*Consumed`
7+
* 复位,**声明形态与类型一字不动**(`CommandPalette.kb.test.tsx:173-181` 继续绿)。
8+
* 本文件判两件事:
9+
* ① **行为**(hook 级,注入假 `notesApi`):消费 ⇒ 回调恰一次 ⇒ App 置 null ⇒
10+
* **同目标再次跳转仍触发**(旧形态下固定值不产生 prop 变化 ⇒ effect 不重跑
11+
* ⇒ 深链静默失效——这是最小收敛修掉的真实缺陷);
12+
* ② **接线**(静态,读源码文本):App 的三组复位 + 三页的消费点。**仪器局限**:
13+
* 静态判据只能证明「写了」,运行期行为只有真机给证据 —— 与
14+
* `CommandPalette.kb.test.tsx` ⑤ 同款声明,口径也照它(读前剥块注释与整行 `//`)。
15+
*/
16+
import { readFileSync } from "node:fs";
17+
import { dirname, join } from "node:path";
18+
import { fileURLToPath } from "node:url";
19+
import { useState } from "react";
20+
import { act, cleanup, renderHook } from "@testing-library/react";
21+
import { afterEach, describe, expect, it, vi } from "vitest";
22+
import type { Note } from "../types";
23+
import type { NotesListData } from "./useNotesListData";
24+
import { useNotesDeepLink } from "./useNotesDeepLink";
25+
26+
const { invokeMock } = vi.hoisted(() => ({ invokeMock: vi.fn() }));
27+
vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock }));
28+
29+
const HERE = dirname(fileURLToPath(import.meta.url));
30+
/** 读源码的**只留代码**口径(与 `CommandPalette.kb.test.tsx` 的 APP_CODE 逐字同款) */
31+
const codeOf = (rel: string) =>
32+
readFileSync(join(HERE, "..", rel), "utf8").replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
33+
34+
const NOTE_42: Note = {
35+
id: 42, title: "眼影入门", content: "少量多次", source: "manual", tags: "[]", pin: 0,
36+
created_at: 1, updated_at: 2,
37+
};
38+
39+
/** 深链只碰 `notesApi` 的 5 个成员(setKeyword/setTagFilter/setNotes/setStatus/seqRef)⇒ 注入最小假体 */
40+
function makeNotesApi() {
41+
const seqRef = { current: 0 };
42+
const calls = {
43+
keyword: [] as string[], tagFilter: [] as (number | null)[], notes: [] as Note[][], status: [] as string[],
44+
};
45+
const api = {
46+
setKeyword: (v: string) => calls.keyword.push(v),
47+
setTagFilter: (v: number | null) => calls.tagFilter.push(v),
48+
setNotes: (rows: Note[]) => calls.notes.push(rows),
49+
setStatus: (s: string) => calls.status.push(s),
50+
seqRef,
51+
} as unknown as NotesListData;
52+
return { api, calls };
53+
}
54+
55+
const noop = () => {};
56+
/** 冲干 `invoke` 的 await 链(mock 立即 resolve;深链体内是 async IIFE ⇒ 多轮微任务) */
57+
const settle = () => act(async () => { await new Promise((r) => setTimeout(r, 0)); });
58+
59+
afterEach(() => {
60+
cleanup();
61+
invokeMock.mockReset();
62+
invokeMock.mockResolvedValue([]);
63+
});
64+
65+
describe("① 消费复位的行为契约(K2/K3;注入假 notesApi)", () => {
66+
it("K2 消费后归零:focusNoteId 有值 ⇒ 深链消费后 onConsumed **恰调用 1 次**(不是 0、不是 2)", async () => {
67+
const { api, calls } = makeNotesApi();
68+
const consumed = vi.fn();
69+
invokeMock.mockResolvedValue([NOTE_42]);
70+
renderHook(() => useNotesDeepLink({
71+
focusNoteId: 42, notesApi: api, setSelected: vi.fn(), setEditing: noop, setView: noop,
72+
setGroupFilter: noop, onConsumed: consumed,
73+
}));
74+
await settle();
75+
expect(consumed, "消费后没有回调 App 复位(字段粘滞)").toHaveBeenCalledTimes(1);
76+
// 同一轮里深链该做的事一件不少:清搜索/标签态 + 全量重载 + 注入列表
77+
expect(calls.keyword).toEqual([""]);
78+
expect(calls.tagFilter).toEqual([null]);
79+
expect(calls.notes).toEqual([[NOTE_42]]);
80+
});
81+
82+
it("K3 连续两次跳**同一**笔记都能触发(旧形态:固定值粘滞 ⇒ 第二次静默失效)", async () => {
83+
const { api } = makeNotesApi();
84+
const setSelected = vi.fn();
85+
invokeMock.mockResolvedValue([NOTE_42]);
86+
const { result } = renderHook(() => {
87+
const [focusNoteId, setFocusNoteId] = useState<number | null>(null);
88+
useNotesDeepLink({
89+
focusNoteId, notesApi: api, setSelected, setEditing: noop, setView: noop, setGroupFilter: noop,
90+
onConsumed: () => setFocusNoteId(null), // App 侧同款:消费即复位
91+
});
92+
return { focusNoteId, jump: setFocusNoteId };
93+
});
94+
await act(async () => { result.current.jump(42); await new Promise((r) => setTimeout(r, 0)); });
95+
expect(result.current.focusNoteId, "K2:消费后字段未归零").toBeNull();
96+
expect(setSelected).toHaveBeenCalledTimes(1);
97+
await act(async () => { result.current.jump(42); await new Promise((r) => setTimeout(r, 0)); });
98+
expect(setSelected, "K3:同目标第二次跳转没有重新消费").toHaveBeenCalledTimes(2);
99+
expect(setSelected).toHaveBeenLastCalledWith(NOTE_42);
100+
});
101+
102+
it("K2 focusGroupId 走**同一个**复位回调:消费后字段归零,而组过滤态留在本页(复位不撤销过滤)", () => {
103+
const { api } = makeNotesApi();
104+
const consumed = vi.fn();
105+
const setGroupFilter = vi.fn();
106+
const { rerender } = renderHook(
107+
({ gid }: { gid: number | null }) => useNotesDeepLink({
108+
focusGroupId: gid, notesApi: api, setSelected: noop, setEditing: noop, setView: noop,
109+
setGroupFilter, onConsumed: consumed,
110+
}),
111+
{ initialProps: { gid: 7 as number | null } },
112+
);
113+
expect(setGroupFilter).toHaveBeenCalledWith(7);
114+
expect(consumed).toHaveBeenCalledTimes(1);
115+
rerender({ gid: null }); // App 侧复位
116+
expect(setGroupFilter, "复位把本页已应用的组过滤也撤销了").toHaveBeenCalledTimes(1);
117+
rerender({ gid: 7 }); // 同组再次跳转
118+
expect(consumed, "同组第二次跳转没有重新消费").toHaveBeenCalledTimes(2);
119+
});
120+
121+
it("加载失败**同样**复位:否则陈旧值卡死同目标重试(同值 setState 无 prop 变化 ⇒ effect 不重跑)", async () => {
122+
const { api, calls } = makeNotesApi();
123+
const consumed = vi.fn();
124+
invokeMock.mockRejectedValue(new Error("db locked"));
125+
renderHook(() => useNotesDeepLink({
126+
focusNoteId: 42, notesApi: api, setSelected: vi.fn(), setEditing: noop, setView: noop,
127+
setGroupFilter: noop, onConsumed: consumed,
128+
}));
129+
await settle();
130+
expect(calls.status).toEqual(["加载失败: Error: db locked"]);
131+
expect(consumed).toHaveBeenCalledTimes(1);
132+
});
133+
});
134+
135+
describe("② 注释与实现一致(K4)+ 行数红线镜像(K5)", () => {
136+
it("K4 `useNotesDeepLink.ts` 的 @ai-context 出现 onConsumed,且不再说「本页无回调」", () => {
137+
const src = readFileSync(join(HERE, "useNotesDeepLink.ts"), "utf8");
138+
const ctx = src.slice(0, src.indexOf("*/")); // 文件头 @ai-context 块
139+
expect(ctx).toContain("onConsumed");
140+
expect(ctx, "注释还在说清空责任在 App ⇒ 标签说谎(App 里没有该复位)").not.toContain("清空责任在 App");
141+
expect(ctx).not.toContain("本页无");
142+
});
143+
144+
it("K5 `App.tsx` ≤600(**镜像**行数口径;权威读数 = `node scripts/line-limits.mjs --full`)", () => {
145+
const text = readFileSync(join(HERE, "..", "App.tsx"), "utf8");
146+
const lines = text === "" ? 0 : text.split("\n").length - (text.endsWith("\n") ? 1 : 0);
147+
expect(lines, "App.tsx 越过 600 硬限(C6 逐字:不许登记豁免)").toBeLessThanOrEqual(600);
148+
expect(lines, "反空真:读到空文件/路径错时,上面那条会假绿").toBeGreaterThan(300);
149+
});
150+
});
151+
152+
describe("③ 接线(App + 三页;静态判据,运行期证据归真机)", () => {
153+
const APP = codeOf("App.tsx");
154+
const SESSIONS = codeOf("pages/SessionsPage.tsx");
155+
const KNOWLEDGE = codeOf("pages/KnowledgePage.tsx");
156+
const NOTES = codeOf("pages/NotesPage.tsx");
157+
158+
it("K2 接线:5 个粘滞字段在 App 侧各有一个复位(三组回调),三页都接上了消费点", () => {
159+
expect(APP, "focusSessionId 没有复位回调").toMatch(/onFocusSessionConsumed=\{\(\) => setFocusSessionId\(null\)\}/);
160+
expect(APP, "focusSystemId 没有复位回调").toMatch(/onFocusSystemConsumed=\{\(\) => setFocusSystemId\(null\)\}/);
161+
for (const f of ["focusNoteId", "focusNoteSearch", "focusGroupId"]) {
162+
const setter = `set${f[0].toUpperCase()}${f.slice(1)}`;
163+
expect(APP, `${f} 没有在 onFocusNoteConsumed 里复位`).toMatch(
164+
new RegExp(`onFocusNoteConsumed=\\{\\(\\) => \\{[^}]*${setter}\\(null\\)`),
165+
);
166+
}
167+
expect(SESSIONS, "SessionsPage 没有消费 focusSessionId").toMatch(/onFocusSessionConsumed\?\.\(\)/);
168+
expect(KNOWLEDGE, "KnowledgePage 没有消费 focusSystemId").toMatch(/onFocusSystemConsumed\?\.\(\)/);
169+
expect(NOTES, "NotesPage 没把三个字段的复位回调接到深链 hook").toMatch(/onConsumed: onFocusNoteConsumed/);
170+
});
171+
172+
it("反例自检:同一组正则对**改前**形态必须不命中(防判据空真)", () => {
173+
const before = [
174+
"<SessionsPage focusSessionId={focusSessionId}",
175+
"<NotesPage focusNoteId={focusNoteId} focusNoteSearch={focusNoteSearch} focusGroupId={focusGroupId}",
176+
"<KnowledgePage focusSystemId={focusSystemId}",
177+
"// 清空责任在 App(本页无 onFocus*Consumed 回调)",
178+
].join("\n");
179+
expect(/onFocusSessionConsumed=/.test(before)).toBe(false);
180+
expect(/onFocusNoteConsumed=/.test(before)).toBe(false);
181+
expect(/onFocusSystemConsumed=/.test(before)).toBe(false);
182+
});
183+
});

‎app/src/hooks/useNotesDeepLink.ts‎

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,13 @@
1515
* NoteListView 渲染——既有跨组件查询,勿改成 ref);③ 注入
1616
* readerSearch(App 侧 key 递增 ⇒ 同笔记可重触发);④ focusGroupId
1717
* 仅过滤**不展开**(三栏下列表常驻)。
18-
* @ai-context: 边界——清空责任在 App(本页无 onFocus*Consumed 回调):effect 只由
19-
* prop 值变化触发,同值重设不重跑;定时器登记入 ref 以便卸载统一清理
20-
* (cleanup 读 effect 建立时的**快照**,勿改成读 .current 最新值)。
18+
* @ai-context: 边界——消费后由本 hook 调用 `onConsumed`(App 侧清空 `focusNoteId`/
19+
* `focusNoteSearch`/`focusGroupId`,**批 5 C6 起本页有该回调**)⇒ 值不再粘滞:
20+
* 同目标**再次跳转**能重新触发(旧形态下 prop 无变化 ⇒ effect 不重跑);
21+
* effect 只由 prop 值变化触发,同值重设不重跑(`onConsumed` 身份也在 deps:
22+
* App 侧内联箭头 ⇒ 父重渲染会重跑一次,seq 序号使在飞的旧响应自动作废);
23+
* 定时器登记入 ref 以便卸载统一清理(cleanup 读 effect 建立时的**快照**,
24+
* 勿改成读 .current 最新值)。
2125
*/
2226
import { useEffect, useRef, useState } from "react";
2327
import { invoke } from "@tauri-apps/api/core";
@@ -37,10 +41,12 @@ interface Options {
3741
/** 中部视图切换("notes"=笔记列表;inbox=收件箱——深链一律切回 notes) */
3842
setView: (v: "notes" | "inbox") => void;
3943
setGroupFilter: (id: number | null) => void;
44+
/** 批 5 C6:消费完成回调(三个字段共用——App 侧清空 focusNoteId/focusNoteSearch/focusGroupId) */
45+
onConsumed?: () => void;
4046
}
4147

4248
export function useNotesDeepLink({
43-
focusNoteId, focusNoteSearch, focusGroupId, notesApi, setSelected, setEditing, setView, setGroupFilter,
49+
focusNoteId, focusNoteSearch, focusGroupId, notesApi, setSelected, setEditing, setView, setGroupFilter, onConsumed,
4450
}: Options) {
4551
// v0.19.1:阅读态命中词搜索请求(来自引用跳转;key 递增可重触发)
4652
const [readerSearch, setReaderSearch] = useState<{ noteId: number; search: string; key: number } | null>(null);
@@ -83,15 +89,20 @@ export function useNotesDeepLink({
8389
);
8490
}
8591
if (focusNoteSearch) setReaderSearch({ ...focusNoteSearch });
92+
// 批 5 C6:消费完成 ⇒ 回调 App 复位(目标找不到也算已消费——列表已按深链重载)
93+
onConsumed?.();
8694
} catch (e) {
8795
if (!disposed) {
8896
if (focusNoteSearch) setReaderSearch({ ...focusNoteSearch });
8997
notesApi.setStatus(`加载失败: ${e}`);
98+
// 批 5 C6:失败路径同样复位——否则陈旧值卡死同目标重试(同值 setState 无 prop 变化)
99+
onConsumed?.();
90100
}
91101
}
92102
})();
103+
// ⚠️ 丢弃分支(disposed / seq 过期)**故意不复位**:复位触发的父重渲染会让本 effect 的 cleanup 把正在跑的那一次取消掉(StrictMode 双调用下目标永不选中)。
93104
return () => { disposed = true; };
94-
}, [focusNoteId, focusNoteSearch]);
105+
}, [focusNoteId, focusNoteSearch, onConsumed]);
95106

96107
// v0.14 C2:图谱组节点直达——仅过滤(三栏下列表常驻;不触发展开)
97108
useEffect(() => {
@@ -100,7 +111,9 @@ export function useNotesDeepLink({
100111
setView("notes");
101112
notesApi.setKeyword("");
102113
notesApi.setTagFilter(null);
103-
}, [focusGroupId]);
114+
// 批 5 C6:三字段共用同一复位回调(App 侧清空 focusGroupId——组过滤态留在本页 state)
115+
onConsumed?.();
116+
}, [focusGroupId, onConsumed]);
104117

105118
return { readerSearch };
106119
}

‎app/src/pages/KnowledgePage.tsx‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ const MIDDLE_TABS: { key: MiddleView; label: string }[] = [
5050
interface Props {
5151
/** 跨页直达目标体系(v0.13.7:组行徽标/结算简报 → 体系页自动选中) */
5252
focusSystemId?: number | null;
53+
/** 批 5 C6:focusSystemId 消费完成回调(App 清空——复位后同体系再次跳转才会重新触发) */
54+
onFocusSystemConsumed?: () => void;
5355
/** 图谱双击笔记节点 → 笔记页定位(v0.14 C2) */
5456
onOpenNote?: (noteId: number) => void;
5557
/** 图谱双击组节点 → 笔记页过滤该组 */
@@ -58,7 +60,7 @@ interface Props {
5860
createSystemSignal?: number;
5961
}
6062

61-
export default function KnowledgePage({ focusSystemId, onOpenNote, onOpenGroup, createSystemSignal }: Props) {
63+
export default function KnowledgePage({ focusSystemId, onFocusSystemConsumed, onOpenNote, onOpenGroup, createSystemSignal }: Props) {
6264
// v0.15:左列可拖拽/记忆/窄窗折叠(默认 260=历史值);详情面板宽度由父层持有
6365
// 批 3 T8:两行规格来自 `columnRegistry`(体系列 autoFoldBelow 1100;详情列不折叠)
6466
const leftCol = useColumnLayout("knowledge-left", columnSpec("knowledge-left"));
@@ -144,10 +146,11 @@ export default function KnowledgePage({ focusSystemId, onOpenNote, onOpenGroup,
144146
}, [createSystemSignal]);
145147

146148
// v0.13.7:跨页直达目标体系(与 NotesPage focusNoteId 同模式——仅 focusSystemId
147-
// 变化时跟随;空态无体系时该值无意义,由既有选中/创建逻辑接管)
149+
// 变化时跟随;空态无体系时该值无意义,由既有选中/创建逻辑接管);
150+
// 批 5 C6:消费后回调 App 清空(复位后同体系再次跳转才会重新触发)
148151
useEffect(() => {
149-
if (focusSystemId != null) setSelectedSystemId(focusSystemId);
150-
}, [focusSystemId]);
152+
if (focusSystemId != null) { setSelectedSystemId(focusSystemId); onFocusSystemConsumed?.(); }
153+
}, [focusSystemId, onFocusSystemConsumed]);
151154

152155
useEffect(() => {
153156
if (selectedSystemId != null) void loadSystemDetail(selectedSystemId);

0 commit comments

Comments
 (0)