feat(vis): support importing debug zips via drag and drop (#1251)
Some checks are pending
CI / build (push) Waiting to run
CI / test (push) Waiting to run
CI / test-windows (push) Waiting to run
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Release / Desktop release artifact (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions

* 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.
This commit is contained in:
qer 2026-07-01 12:02:08 +08:00 committed by GitHub
parent c2fd9f0494
commit 170b29aa90
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 198 additions and 0 deletions

View file

@ -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. */}
<main className="flex min-h-0 min-w-0 flex-1 flex-col">{children}</main>
</div>
<ZipDropOverlay />
</div>
);
}

View file

@ -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 (
<div className="pointer-events-none fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="border-2 border-dashed border-border-strong bg-surface-1 px-10 py-8 text-center">
<div className="font-mono text-[13px] text-fg-0">
{importing ? 'importing debug zip…' : 'drop debug zip to import'}
</div>
<div className="mt-2 font-mono text-[11px] text-fg-3">
from kimi-code /export-debug-zip
</div>
</div>
</div>
);
}

View file

@ -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);
});
});

63
plan/vis-drag-drop-zip.md Normal file
View file

@ -0,0 +1,63 @@
# vis 支持拖入 zip 调试包
## 1. 背景与目标
`apps/vis` 是 kimi-code 会话的可视化调试工具。服务端已经支持导入他人通过
`/export-debug-zip` 导出的调试 zip`POST /api/imports`,解压到
`<home>/imported/<id>/` 后按普通 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` 的隐藏 `<input type="file">` 复用这个 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` 末尾渲染 `<ZipDropOverlay />``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` 状态合并(两者互斥触发,各自维护即可)。