mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
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
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:
parent
c2fd9f0494
commit
170b29aa90
4 changed files with 198 additions and 0 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
112
apps/vis/web/src/components/shared/ZipDropOverlay.tsx
Normal file
112
apps/vis/web/src/components/shared/ZipDropOverlay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
apps/vis/web/test/zip-drop.test.ts
Normal file
21
apps/vis/web/test/zip-drop.test.ts
Normal 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
63
plan/vis-drag-drop-zip.md
Normal 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` 状态合并(两者互斥触发,各自维护即可)。
|
||||
Loading…
Add table
Add a link
Reference in a new issue