From 170b29aa90d73b61ae74c742fcf218333f8606b0 Mon Sep 17 00:00:00 2001 From: qer Date: Wed, 1 Jul 2026 12:02:08 +0800 Subject: [PATCH] feat(vis): support importing debug zips via drag and drop (#1251) * feat(vis): support importing debug zips via drag and drop Add a window-level drop target so a /export-debug-zip bundle can be imported by dragging it anywhere into the vis UI, alongside the existing file-picker button. A full-screen overlay gives feedback during the drag and while the upload is in flight, and non-zip files are rejected with a hint. * fix(vis): gate drop handler to file drags Match the other drag handlers by checking dataTransfer.types before calling preventDefault, so non-file drops (selected text or a URL into the search input) keep their native behavior instead of being swallowed by the window-level listener. --- .../web/src/components/layout/AppShell.tsx | 2 + .../src/components/shared/ZipDropOverlay.tsx | 112 ++++++++++++++++++ apps/vis/web/test/zip-drop.test.ts | 21 ++++ plan/vis-drag-drop-zip.md | 63 ++++++++++ 4 files changed, 198 insertions(+) create mode 100644 apps/vis/web/src/components/shared/ZipDropOverlay.tsx create mode 100644 apps/vis/web/test/zip-drop.test.ts create mode 100644 plan/vis-drag-drop-zip.md diff --git a/apps/vis/web/src/components/layout/AppShell.tsx b/apps/vis/web/src/components/layout/AppShell.tsx index 51c9bbb02..4f0aa01a0 100644 --- a/apps/vis/web/src/components/layout/AppShell.tsx +++ b/apps/vis/web/src/components/layout/AppShell.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { Link } from 'react-router-dom'; import { SessionRail } from '../sessions/SessionRail'; +import { ZipDropOverlay } from '../shared/ZipDropOverlay'; import { useTheme, type ThemeChoice, type ResolvedTheme } from '../../hooks/useTheme'; interface AppShellProps { @@ -46,6 +47,7 @@ export function AppShell({ children }: AppShellProps) { out horizontally instead of wrapping. */}
{children}
+ ); } diff --git a/apps/vis/web/src/components/shared/ZipDropOverlay.tsx b/apps/vis/web/src/components/shared/ZipDropOverlay.tsx new file mode 100644 index 000000000..cc7bbbc09 --- /dev/null +++ b/apps/vis/web/src/components/shared/ZipDropOverlay.tsx @@ -0,0 +1,112 @@ +import { useEffect, useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import { useImportZip } from '../../hooks/useSession'; + +/** True for the file-like drag payloads we care about; filters out text / + * link drags so the overlay doesn't hijack ordinary in-page drags. */ +function isFileDrag(dt: DataTransfer | null): boolean { + return dt !== null && Array.from(dt.types).includes('Files'); +} + +/** Cheap client-side zip check for immediate feedback. The server still + * validates the bytes and bundle shape; this only gates the drop. */ +export function isZipFile(file: { name: string; type: string }): boolean { + const name = file.name.toLowerCase(); + return ( + name.endsWith('.zip') || + file.type === 'application/zip' || + file.type === 'application/x-zip-compressed' + ); +} + +/** + * Window-level drop target for importing a `/export-debug-zip` bundle. + * + * Renders nothing until a file is dragged over the window, then shows a + * full-screen overlay. Dropping a `.zip` posts it to the import endpoint and + * navigates to the imported session; non-zip files get an alert and are + * ignored. The pointer-events are disabled so the overlay itself never swallows + * the drop — the window listener owns the interaction. + */ +export function ZipDropOverlay() { + const navigate = useNavigate(); + const { mutateAsync: importZip, isPending: importing } = useImportZip(); + const [dragging, setDragging] = useState(false); + // Count nested enter/leave pairs so dragging over a child element doesn't + // briefly drop the counter to zero and flicker the overlay. + const depth = useRef(0); + + useEffect(() => { + async function importFile(file: File) { + try { + const result = await importZip(file); + void navigate(`/sessions/${result.sessionId}`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + window.alert(`Import failed: ${message}`); + } + } + + function onDragEnter(e: DragEvent) { + if (!isFileDrag(e.dataTransfer)) return; + e.preventDefault(); + depth.current += 1; + setDragging(true); + } + function onDragOver(e: DragEvent) { + if (!isFileDrag(e.dataTransfer)) return; + // Required — without preventDefault the browser cancels the drag and + // never fires `drop`. + e.preventDefault(); + if (e.dataTransfer !== null) e.dataTransfer.dropEffect = 'copy'; + } + function onDragLeave(e: DragEvent) { + if (!isFileDrag(e.dataTransfer)) return; + e.preventDefault(); + depth.current = Math.max(0, depth.current - 1); + if (depth.current === 0) setDragging(false); + } + function onDrop(e: DragEvent) { + // Gate on file drags first so non-file drops (e.g. selected text or a + // URL into the search input) keep their native behavior. + if (!isFileDrag(e.dataTransfer)) return; + e.preventDefault(); + depth.current = 0; + setDragging(false); + const file = e.dataTransfer?.files[0]; + if (file === undefined) return; + if (!isZipFile(file)) { + window.alert('Please drop a .zip bundle exported from kimi-code (/export-debug-zip).'); + return; + } + void importFile(file); + } + + window.addEventListener('dragenter', onDragEnter); + window.addEventListener('dragover', onDragOver); + window.addEventListener('dragleave', onDragLeave); + window.addEventListener('drop', onDrop); + return () => { + window.removeEventListener('dragenter', onDragEnter); + window.removeEventListener('dragover', onDragOver); + window.removeEventListener('dragleave', onDragLeave); + window.removeEventListener('drop', onDrop); + }; + }, [importZip, navigate]); + + if (!dragging && !importing) return null; + + return ( +
+
+
+ {importing ? 'importing debug zip…' : 'drop debug zip to import'} +
+
+ from kimi-code /export-debug-zip +
+
+
+ ); +} diff --git a/apps/vis/web/test/zip-drop.test.ts b/apps/vis/web/test/zip-drop.test.ts new file mode 100644 index 000000000..503de4fd5 --- /dev/null +++ b/apps/vis/web/test/zip-drop.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; + +import { isZipFile } from '../src/components/shared/ZipDropOverlay'; + +describe('isZipFile', () => { + it('accepts a .zip extension regardless of declared type', () => { + expect(isZipFile({ name: 'session.zip', type: '' })).toBe(true); + expect(isZipFile({ name: 'SESSION.ZIP', type: 'application/octet-stream' })).toBe(true); + }); + + it('accepts zip MIME types even without a .zip name', () => { + expect(isZipFile({ name: 'bundle', type: 'application/zip' })).toBe(true); + expect(isZipFile({ name: 'bundle', type: 'application/x-zip-compressed' })).toBe(true); + }); + + it('rejects non-zip files', () => { + expect(isZipFile({ name: 'notes.txt', type: 'text/plain' })).toBe(false); + expect(isZipFile({ name: 'image.png', type: 'image/png' })).toBe(false); + expect(isZipFile({ name: 'archive.tar.gz', type: 'application/gzip' })).toBe(false); + }); +}); diff --git a/plan/vis-drag-drop-zip.md b/plan/vis-drag-drop-zip.md new file mode 100644 index 000000000..6f96d5d7f --- /dev/null +++ b/plan/vis-drag-drop-zip.md @@ -0,0 +1,63 @@ +# vis 支持拖入 zip 调试包 + +## 1. 背景与目标 + +`apps/vis` 是 kimi-code 会话的可视化调试工具。服务端已经支持导入他人通过 +`/export-debug-zip` 导出的调试 zip(`POST /api/imports`,解压到 +`/imported//` 后按普通 session 展示)。Web 端目前只在 session 列表 +左上角提供了一个「⬆ import debug zip」按钮,点击后弹出系统文件选择器。 + +目标:让 vis 的 Web UI 支持**直接拖入** zip 调试包,省去点按钮、找文件的步骤。 + +## 2. 现状关键事实 + +- **服务端已就绪**:`apps/vis/server/src/routes/imports.ts` 的 `POST /` + 接收原始 zip 字节流,调用 `importSessionZip` 解压并校验(必须含 + `agents/main/wire.jsonl`),返回 `{ sessionId, importMeta }`。 +- **前端 API 已就绪**:`apps/vis/web/src/api.ts` 的 `api.importZip(file: File)` + 把文件作为 body POST 到 `/api/imports`;`useSession.ts` 的 `useImportZip()` + 在导入成功后 `invalidateQueries(['sessions'])` 刷新列表。 +- **唯一缺口**:交互入口只有按钮 + 文件选择器,没有拖放。 +- **现有导入入口**:`SessionRail` 的 `handleImport`(`apps/vis/web/src/components/sessions/SessionRail.tsx`) + 负责调用 `useImportZip` + `navigate` 到导入后的 session,失败时 `window.alert`。 + `SessionFilter` 的隐藏 `` 复用这个 handler。 + +## 3. 设计决策 + +| 决策点 | 结论 | +|---|---| +| 落区范围 | 整个窗口(`window` 级监听),任何位置拖入都可导入 | +| 拖入反馈 | 拖入文件时显示全屏 overlay:「drop debug zip to import」;上传中显示「importing…」 | +| 文件校验 | 前端按扩展名 / MIME 判断 `.zip`,非 zip 给 alert 提示;服务端仍做完整校验 | +| 成功后行为 | 复用现有逻辑:刷新列表并 `navigate` 到导入后的 session | +| 与按钮的关系 | 并存。拖放与文件选择器各用独立的 `useImportZip()`,互不阻塞 | + +## 4. 实现方案 + +新增 `apps/vis/web/src/components/shared/ZipDropOverlay.tsx`: + +- `useEffect` 在 `window` 上注册 `dragenter` / `dragover` / `dragleave` / `drop`。 +- 用 `depth` 计数器处理子元素 enter/leave,避免 overlay 闪烁。 +- 只在 `dataTransfer.types` 含 `Files` 时响应,避免拦截文本拖拽。 +- `dragover` 调 `preventDefault()` 并设 `dropEffect = 'copy'`,否则浏览器不会触发 `drop`。 +- `drop` 时取 `dataTransfer.files[0]`,非 zip 给 alert;否则调用 + `useImportZip().mutateAsync`,成功后 `navigate`,失败 alert。 +- 通过 `isPending` 在 overlay 上区分「拖入中」与「上传中」。 + +纯函数 `isZipFile({ name, type })` 单独导出,便于在 node 环境下做单元测试。 + +在 `AppShell` 末尾渲染 ``(`position: fixed`,不挤占布局)。 + +## 5. 验证 + +- `pnpm --filter @moonshot-ai/vis-web run typecheck` 通过。 +- `pnpm --filter @moonshot-ai/vis-web run test` 通过(含新增 `isZipFile` 单测)。 +- `pnpm --filter @moonshot-ai/vis-web run build` 通过。 +- 手动:`pnpm run vis` 后,把 `/export-debug-zip` 产物拖进窗口,出现 overlay、 + 松开后导入并跳到对应 session;拖入非 zip 文件出现提示且不导入。 + +## 6. 非目标 + +- 多文件批量导入(一次只处理第一个文件)。 +- 上传进度条(服务端一次性缓冲 zip 字节,没有分块进度)。 +- 把按钮与拖放的 `isPending` 状态合并(两者互斥触发,各自维护即可)。