From 48cad29d31cdddb031efa779c15a4f6a35b01680 Mon Sep 17 00:00:00 2001 From: esengine <359807859@qq.com> Date: Fri, 6 Nov 2026 23:45:23 -0800 Subject: [PATCH] fix(studio): the storage move panel plans once the path stops changing and ignores answers for paths already typed past Every keystroke sent a plan request that walks the whole root. The panel now waits 400 ms after the last change and drops an answer whose path is no longer the one in the box. --- desktop/frontend-next/src/ui/Storage.test.tsx | 40 +++++++++++++++++++ desktop/frontend-next/src/ui/Storage.tsx | 15 ++++++- .../storage-plan-creates-nothing.md | 4 +- 3 files changed, 56 insertions(+), 3 deletions(-) diff --git a/desktop/frontend-next/src/ui/Storage.test.tsx b/desktop/frontend-next/src/ui/Storage.test.tsx index 527819b005..b31bc2572b 100644 --- a/desktop/frontend-next/src/ui/Storage.test.tsx +++ b/desktop/frontend-next/src/ui/Storage.test.tsx @@ -153,3 +153,43 @@ it("draws no list when nothing was skipped", async () => { await pickOld(); expect(screen.queryByText(/个会话没有导入/)).toBeNull(); }); + +function movable(plan: (root: string, dir: string) => Promise<{ ok: boolean; root: string; from: string; to: string; bytes: number; files: number; free: number; need: number; refusals: [] }>) { + const port = { + storage: vi.fn(async () => ({ roots: [root("state", { relocatable: true, bytes: 10, files: 1 })], editable: true })), + planStorageMove: vi.fn(plan), + } as unknown as AgentPort; + render( {}} />); + return port; +} + +const answered = async (_root: string, dir: string) => ({ ok: true, root: "state", from: "/a", to: dir, bytes: 10, files: 1, free: 1e9, need: 1e6, refusals: [] as [] }); + +it("asks the kernel to plan a move once the path stops changing, not once per keystroke", async () => { + const port = movable(answered); + await flush(); + fireEvent.click(screen.getByText("移动…")); + const input = screen.getByRole("textbox") as HTMLInputElement; + for (const typed of ["D", "D:", "D:\\", "D:\\t", "D:\\te"]) fireEvent.change(input, { target: { value: typed } }); + expect(port.planStorageMove).not.toHaveBeenCalled(); + await act(async () => { await vi.advanceTimersByTimeAsync(600); }); + expect(port.planStorageMove).toHaveBeenCalledTimes(1); + expect(port.planStorageMove).toHaveBeenCalledWith("state", "D:\\te"); +}); + +it("drops an answer for a path the person has already typed past", async () => { + let release: (v: Awaited>) => void = () => {}; + movable((r, d) => + d === "/slow" ? new Promise((res) => { release = res; }) : answered(r, d).then((p) => ({ ...p, ok: false })), + ); + await flush(); + fireEvent.click(screen.getByText("移动…")); + const input = screen.getByRole("textbox") as HTMLInputElement; + fireEvent.change(input, { target: { value: "/slow" } }); + await act(async () => { await vi.advanceTimersByTimeAsync(600); }); + fireEvent.change(input, { target: { value: "/fast" } }); + await act(async () => { await vi.advanceTimersByTimeAsync(600); }); + await act(async () => { release(await answered("state", "/slow")); }); + const start = screen.getByRole("button", { name: /开始迁移/ }) as HTMLButtonElement; + expect(start.disabled).toBe(true); +}); diff --git a/desktop/frontend-next/src/ui/Storage.tsx b/desktop/frontend-next/src/ui/Storage.tsx index 23839912db..2584bfdd87 100644 --- a/desktop/frontend-next/src/ui/Storage.tsx +++ b/desktop/frontend-next/src/ui/Storage.tsx @@ -43,6 +43,8 @@ function within(work: Promise, ms: number): Promise { }); } +const PLAN_DELAY_MS = 400; + export function Storage({ port, hub, workspace, onRecovered }: { port: AgentPort; hub: HubPort; workspace: string; onRecovered: () => void }) { const [state, setState] = useState(null); const [error, setError] = useState(""); @@ -117,11 +119,22 @@ export function Storage({ port, hub, workspace, onRecovered }: { port: AgentPort if (picking) input.current?.focus(); }, [picking]); + const planTimer = useRef | undefined>(undefined); + const planSeq = useRef(0); + useEffect(() => () => clearTimeout(planTimer.current), []); + const check = useCallback( (root: string, dir: string) => { + clearTimeout(planTimer.current); + const seq = ++planSeq.current; setPlan(null); if (!dir.trim()) return; - port.planStorageMove(root, dir).then(setPlan).catch(() => setPlan(null)); + planTimer.current = setTimeout(() => { + port + .planStorageMove(root, dir) + .then((answer) => seq === planSeq.current && setPlan(answer)) + .catch(() => seq === planSeq.current && setPlan(null)); + }, PLAN_DELAY_MS); }, [port], ); diff --git a/release-notes/studio/unreleased/storage-plan-creates-nothing.md b/release-notes/studio/unreleased/storage-plan-creates-nothing.md index 565d48f911..eb5aded7a5 100644 --- a/release-notes/studio/unreleased/storage-plan-creates-nothing.md +++ b/release-notes/studio/unreleased/storage-plan-creates-nothing.md @@ -1,10 +1,10 @@ ## 修复 -- 修复在「设置 → 数据 → 存储」里更改位置时,每敲一个字符就在磁盘上留下一个半截目录的问题(比如输入 `D:\test\reasonix` 会留下 `r`、`re`、`rea` 等 8 个空目录)。现在预检只读:它在目标最近的已存在上级目录里验证可写、读取剩余空间,目录要等你点「开始迁移」后才创建(#14998)。 +- 修复在「设置 → 数据 → 存储」里更改位置时,每敲一个字符就在磁盘上留下一个半截目录的问题(比如输入 `D:\test\reasonix` 会留下 `r`、`re`、`rea` 等 8 个空目录)。现在预检只读:它在目标最近的已存在上级目录里验证可写、读取剩余空间,目录要等你点「开始迁移」后才创建;输入路径时也不再每个字符都请求一次,而是停顿后再检查(#14998)。 --- ## Fixed -- Typing a new location under Settings > Data > Storage no longer leaves a half-typed folder on disk for every keystroke (typing `D:\test\reasonix` used to leave `r`, `re`, `rea` and so on as eight empty folders). The check is now read-only: it proves the nearest existing parent is writable and reads its free space, and the folder is only created when you start the move (#14998). +- Typing a new location under Settings > Data > Storage no longer leaves a half-typed folder on disk for every keystroke (typing `D:\test\reasonix` used to leave `r`, `re`, `rea` and so on as eight empty folders). The check is now read-only: it proves the nearest existing parent is writable and reads its free space, and the folder is only created when you start the move, and typing no longer asks for a check on every character, only once you pause (#14998).