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.
This commit is contained in:
esengine committed 2026-11-06 23:50:40 -08:00
1 parent 0f26eda9f4
commit 48cad29d31
3 files changed
+56 -3

No files matched your search

@@ -153,3 +153,43 @@ it("draws no list when nothing was skipped", async () => {
await pickOld(); await pickOld();
expect(screen.queryByText(/个会话没有导入/)).toBeNull(); 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(<Storage port={port} hub={{} as HubPort} workspace="" onRecovered={() => {}} />);
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<ReturnType<typeof answered>>) => 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);
});
+14 -1
View File
@@ -43,6 +43,8 @@ function within<T>(work: Promise<T>, ms: number): Promise<T> {
}); });
} }
const PLAN_DELAY_MS = 400;
export function Storage({ port, hub, workspace, onRecovered }: { port: AgentPort; hub: HubPort; workspace: string; onRecovered: () => void }) { export function Storage({ port, hub, workspace, onRecovered }: { port: AgentPort; hub: HubPort; workspace: string; onRecovered: () => void }) {
const [state, setState] = useState<StorageState | null>(null); const [state, setState] = useState<StorageState | null>(null);
const [error, setError] = useState(""); const [error, setError] = useState("");
@@ -117,11 +119,22 @@ export function Storage({ port, hub, workspace, onRecovered }: { port: AgentPort
if (picking) input.current?.focus(); if (picking) input.current?.focus();
}, [picking]); }, [picking]);
const planTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const planSeq = useRef(0);
useEffect(() => () => clearTimeout(planTimer.current), []);
const check = useCallback( const check = useCallback(
(root: string, dir: string) => { (root: string, dir: string) => {
clearTimeout(planTimer.current);
const seq = ++planSeq.current;
setPlan(null); setPlan(null);
if (!dir.trim()) return; 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], [port],
); );
@@ -1,10 +1,10 @@
## 修复 ## 修复
- 修复在「设置 → 数据 → 存储」里更改位置时,每敲一个字符就在磁盘上留下一个半截目录的问题(比如输入 `D:\test\reasonix` 会留下 `r`、`re`、`rea` 等 8 个空目录)。现在预检只读:它在目标最近的已存在上级目录里验证可写、读取剩余空间,目录要等你点「开始迁移」后才创建(#14998)。 - 修复在「设置 → 数据 → 存储」里更改位置时,每敲一个字符就在磁盘上留下一个半截目录的问题(比如输入 `D:\test\reasonix` 会留下 `r`、`re`、`rea` 等 8 个空目录)。现在预检只读:它在目标最近的已存在上级目录里验证可写、读取剩余空间,目录要等你点「开始迁移」后才创建;输入路径时也不再每个字符都请求一次,而是停顿后再检查(#14998)。
--- ---
<!-- lang:en --> <!-- lang:en -->
## Fixed ## 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).