mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
fix(studio): reset category scrolling and wait for search targets
Problem: switching settings categories retains an unrelated scroll offset, while a searched setting that loads later never receives navigation feedback. Root cause: categories share one scrolling element and landing resolves the anchor once before asynchronous content arrives. Fix: key the panel by category, clear pending landing only when a guarded category change succeeds, and observe target arrival until its feedback lifetime begins. Keep existing categories, layouts, disclosures and unsaved-change guards. Verification: five regressions fail on the unmodified baseline; all 28 settings test files and 215 tests pass with the fix. TypeScript/Vite and repository checks pass. Browser verification confirms a tools panel at scrollTop 5747 switches to network at 0 and search opens the existing advanced contrast disclosure.
This commit is contained in:
1 parent
4377886f88
commit
d11ca0472c
3 files changed
+177
-28
No files matched your search
@@ -8,6 +8,7 @@ import { listenAction } from "./listen";
|
||||
import { useRuntimeReload } from "./RuntimeReload";
|
||||
import type { CapabilityScope, McpEntry, ModelEntry, PluginPackage, SkillEntry } from "../port/port";
|
||||
import { useSettingsSearch } from "./useSettingsSearch";
|
||||
import { useSettingLanding } from "./useSettingLanding";
|
||||
import { bytes, tokens as fmtTokens } from "../i18n/format";
|
||||
import { SECTION_NAME } from "./prefsnav";
|
||||
import { SettingsNav } from "./SettingsNav";
|
||||
@@ -316,32 +317,7 @@ export function Settings({ hub, onError, port, networkPort, localWorkspacePort,
|
||||
if (section === "ext") setExtTab(anchor === "market" ? "market" : "installed");
|
||||
});
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!landed) return;
|
||||
const el = root.current?.querySelector<HTMLElement>(`#set-${CSS.escape(landed)}`);
|
||||
// 折叠起来的块在 DOM 里,但不在屏幕上 —— 滚过去会停在一片空白上。要开哪一
|
||||
// 个由结构回答:不需要再维护一张「哪些设置是高级」的表,也不需要把落点穿到
|
||||
// 每个页面里去。
|
||||
for (let d = el?.closest("details"); d; d = d.parentElement?.closest("details") ?? null) d.open = true;
|
||||
const behavior = matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth";
|
||||
const aim = () => el?.scrollIntoView({ block: "start", behavior });
|
||||
aim();
|
||||
// The page is still growing under the scroll: theme packs and the font list
|
||||
// arrive late and push the anchor below a target already locked in. A jump
|
||||
// owns its landing until it lapses, so re-aim for as long as it is owed.
|
||||
const grew = new ResizeObserver(aim);
|
||||
const col = root.current?.querySelector(".prefs-col");
|
||||
if (col) grew.observe(col);
|
||||
// 「就是这里」是一次导航反馈,不是这个设置变了 —— 所以它只是元素上短暂的
|
||||
// 一个标记,不进 React 状态,也不用往每个块穿一个纯表现的 prop。
|
||||
el?.setAttribute("data-landed", "");
|
||||
const done = setTimeout(() => setLanded(""), 900);
|
||||
return () => {
|
||||
clearTimeout(done);
|
||||
grew.disconnect();
|
||||
el?.removeAttribute("data-landed");
|
||||
};
|
||||
}, [landed, at]);
|
||||
useSettingLanding(root, landed, at, setLanded);
|
||||
|
||||
return (
|
||||
<SettingsLeaveContext.Provider value={navigation.register}><div
|
||||
@@ -420,9 +396,9 @@ export function Settings({ hub, onError, port, networkPort, localWorkspacePort,
|
||||
</div>
|
||||
<ConfigTrouble port={port} onRepaired={onChanged} />
|
||||
<div className="prefs-body">
|
||||
<SettingsNav at={at} values={nav} shown={shown} danger={danger} disabled={updatingPkg.applying} onSelect={(section) => { if (section !== at) navigation.leave(() => setAt(section)); }} />
|
||||
<SettingsNav at={at} values={nav} shown={shown} danger={danger} disabled={updatingPkg.applying} onSelect={(section) => { if (section !== at) navigation.leave(() => { setLanded(""); setAt(section); }); }} />
|
||||
|
||||
<div className="prefs-main" role="tabpanel" aria-labelledby={`prefs-${at}`} data-sec={at}>
|
||||
<div className="prefs-main" key={at} role="tabpanel" aria-labelledby={`prefs-${at}`} data-sec={at}>
|
||||
<Boundary
|
||||
fallback={
|
||||
<div className="find" data-lvl="err" role="alert">
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "./testkit";
|
||||
import { Settings } from "./Settings";
|
||||
import { MockPort } from "../port/mock";
|
||||
import { MockHub } from "../port/mock_hub";
|
||||
|
||||
afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.useRealTimers(); });
|
||||
|
||||
function draw(at: string, port = new MockPort()) {
|
||||
render(<Settings
|
||||
hub={new MockHub()} port={port} status={null}
|
||||
theme="light" onTheme={() => {}} contrast="" onContrast={() => {}}
|
||||
weight="" onWeight={() => {}} look={{}} onLook={() => {}}
|
||||
reloadThemes={() => {}} onClose={() => {}} onChanged={() => {}}
|
||||
onError={() => {}} at={at} account={null} accountUnread="" reloadAccount={() => {}}
|
||||
/>);
|
||||
}
|
||||
|
||||
it.each([
|
||||
["appearance", "版本"],
|
||||
["tools", "会话"],
|
||||
])("starts the next category at the top after scrolling %s", async (from, to) => {
|
||||
draw(from);
|
||||
const panel = screen.getByRole("tabpanel");
|
||||
panel.scrollTop = 1800;
|
||||
panel.scrollLeft = 120;
|
||||
await userEvent.click(screen.getByRole("tab", { name: new RegExp(`^${to}(?!间消息)`) }));
|
||||
expect(screen.getByRole("heading", { name: to, level: 2 })).toBeTruthy();
|
||||
expect(screen.getByRole("tabpanel").scrollTop).toBe(0);
|
||||
expect(screen.getByRole("tabpanel").scrollLeft).toBe(0);
|
||||
});
|
||||
|
||||
it("starts a keyboard-selected category at the top and keeps focus on its tab", async () => {
|
||||
draw("tools");
|
||||
const current = screen.getByRole("tab", { name: /^工具与权限/ });
|
||||
current.focus();
|
||||
screen.getByRole("tabpanel").scrollTop = 1600;
|
||||
await userEvent.keyboard("{ArrowDown}");
|
||||
const next = screen.getByRole("tab", { name: "会话间消息" });
|
||||
expect(next.getAttribute("aria-selected")).toBe("true");
|
||||
expect(document.activeElement).toBe(next);
|
||||
expect(screen.getByRole("tabpanel").scrollTop).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps the current category's scroll and unsaved input when it is selected again", async () => {
|
||||
draw("persona:persona");
|
||||
const prefix = await screen.findByRole<HTMLTextAreaElement>("textbox", { name: "系统提示词开头" });
|
||||
await waitFor(() => expect(prefix.disabled).toBe(false));
|
||||
await userEvent.type(prefix, "Unsaved draft");
|
||||
const panel = screen.getByRole("tabpanel");
|
||||
panel.scrollTop = 650;
|
||||
await userEvent.click(screen.getByRole("tab", { name: "人设" }));
|
||||
await userEvent.type(screen.getByRole("textbox", { name: "搜索设置" }), "字体");
|
||||
expect(screen.getByRole("tabpanel").scrollTop).toBe(650);
|
||||
expect(screen.getByRole<HTMLTextAreaElement>("textbox", { name: "系统提示词开头" }).value).toBe("Unsaved draft");
|
||||
});
|
||||
|
||||
function anchorScrolling() {
|
||||
return vi.spyOn(Element.prototype, "scrollIntoView").mockImplementation(function (this: Element) {
|
||||
const panel = this.closest<HTMLElement>('[role="tabpanel"]');
|
||||
if (panel) panel.scrollTop = 420;
|
||||
});
|
||||
}
|
||||
|
||||
it("keeps a search result's anchor after switching away from a scrolled category", async () => {
|
||||
const scroll = anchorScrolling();
|
||||
draw("session");
|
||||
screen.getByRole("tabpanel").scrollTop = 1500;
|
||||
await userEvent.type(screen.getByRole("textbox", { name: "搜索设置" }), "柔和");
|
||||
await userEvent.click(await screen.findByRole("option", { name: /文字对比度/ }));
|
||||
const target = document.querySelector<HTMLElement>('[data-setting="contrast"]')!;
|
||||
expect(target.closest<HTMLDetailsElement>("details")?.open).toBe(true);
|
||||
expect(scroll.mock.contexts).toContain(target);
|
||||
expect(screen.getByRole("tabpanel").scrollTop).toBe(420);
|
||||
});
|
||||
|
||||
it("opens an initial anchor inside its disclosure", async () => {
|
||||
const scroll = anchorScrolling();
|
||||
draw("appearance:contrast");
|
||||
await screen.findByRole("heading", { name: "外观", level: 2 });
|
||||
const target = document.querySelector<HTMLElement>('[data-setting="contrast"]')!;
|
||||
expect(target.closest<HTMLDetailsElement>("details")?.open).toBe(true);
|
||||
expect(scroll.mock.contexts).toContain(target);
|
||||
expect(screen.getByRole("tabpanel").scrollTop).toBe(420);
|
||||
});
|
||||
|
||||
it("waits for a delayed setting before starting the landing feedback lifetime", async () => {
|
||||
const port = new MockPort();
|
||||
const saved = await port.observabilitySettings();
|
||||
let finish!: (value: typeof saved) => void;
|
||||
vi.spyOn(port, "observabilitySettings").mockReturnValue(new Promise((resolve) => { finish = resolve; }));
|
||||
vi.useFakeTimers();
|
||||
const scroll = anchorScrolling();
|
||||
draw("network:trace-export", port);
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(1200); });
|
||||
expect(document.querySelector('[data-setting="trace-export"]')).toBeNull();
|
||||
await act(async () => { finish(saved); });
|
||||
const target = document.querySelector<HTMLElement>('[data-setting="trace-export"]')!;
|
||||
expect(target.getAttribute("data-landed")).toBe("");
|
||||
expect(scroll.mock.contexts).toContain(target);
|
||||
});
|
||||
|
||||
it("lands on a searched setting that arrives after navigation", async () => {
|
||||
const port = new MockPort();
|
||||
const saved = await port.observabilitySettings();
|
||||
let finish!: (value: typeof saved) => void;
|
||||
vi.spyOn(port, "observabilitySettings").mockReturnValue(new Promise((resolve) => { finish = resolve; }));
|
||||
const scroll = anchorScrolling();
|
||||
draw("session", port);
|
||||
await userEvent.type(screen.getByRole("textbox", { name: "搜索设置" }), "追踪导出");
|
||||
await userEvent.click(await screen.findByRole("option", { name: /追踪导出/ }));
|
||||
await act(async () => { finish(saved); });
|
||||
const target = document.querySelector<HTMLElement>('[data-setting="trace-export"]')!;
|
||||
expect(target.getAttribute("data-landed")).toBe("");
|
||||
expect(scroll.mock.contexts).toContain(target);
|
||||
});
|
||||
|
||||
it("cancels a pending landing when the user chooses another category", async () => {
|
||||
const port = new MockPort();
|
||||
const saved = await port.observabilitySettings();
|
||||
let finish!: (value: typeof saved) => void;
|
||||
vi.spyOn(port, "observabilitySettings").mockReturnValue(new Promise((resolve) => { finish = resolve; }));
|
||||
draw("network:trace-export", port);
|
||||
await userEvent.click(screen.getByRole("tab", { name: /^版本/ }));
|
||||
await act(async () => { finish(saved); });
|
||||
await userEvent.click(screen.getByRole("tab", { name: /^网络/ }));
|
||||
const target = await waitFor(() => {
|
||||
const found = document.querySelector<HTMLElement>('[data-setting="trace-export"]');
|
||||
expect(found).toBeTruthy();
|
||||
return found!;
|
||||
});
|
||||
expect(target.hasAttribute("data-landed")).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useEffect, type Dispatch, type RefObject, type SetStateAction } from "react";
|
||||
|
||||
export function useSettingLanding(root: RefObject<HTMLDivElement | null>, anchor: string, section: string, setAnchor: Dispatch<SetStateAction<string>>) {
|
||||
useEffect(() => {
|
||||
if (!anchor) return;
|
||||
const col = root.current?.querySelector(".prefs-col");
|
||||
if (!col) return;
|
||||
let target: HTMLElement | null = null;
|
||||
let done: ReturnType<typeof setTimeout> | undefined;
|
||||
const behavior = matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth";
|
||||
const aim = () => {
|
||||
const found = col.querySelector<HTMLElement>(`#set-${CSS.escape(anchor)}`);
|
||||
if (!found) return;
|
||||
for (let parent = found.closest("details"); parent; parent = parent.parentElement?.closest("details") ?? null)
|
||||
parent.open = true;
|
||||
if (target !== found) {
|
||||
target?.removeAttribute("data-landed");
|
||||
target = found;
|
||||
target.setAttribute("data-landed", "");
|
||||
clearTimeout(done);
|
||||
done = setTimeout(() => setAnchor(""), 900);
|
||||
}
|
||||
found.scrollIntoView({ block: "start", behavior });
|
||||
};
|
||||
const grew = new ResizeObserver(aim);
|
||||
const arrived = new MutationObserver(aim);
|
||||
grew.observe(col);
|
||||
arrived.observe(col, { childList: true, subtree: true });
|
||||
aim();
|
||||
return () => {
|
||||
clearTimeout(done);
|
||||
grew.disconnect();
|
||||
arrived.disconnect();
|
||||
target?.removeAttribute("data-landed");
|
||||
};
|
||||
}, [root, anchor, section, setAnchor]);
|
||||
}
|
||||
Reference in new issue
Block a user