dsh-macos-quicklook
naitang1207/dsh-macos-quicklook
Adds a space-key Quick Look preview to DSH deliverable cards. Hovering a card and pressing Space opens the native macOS QLPreviewPanel for the file, which covers docx, xlsx, pptx, pdf, images and media that the sidebar preview cannot render. The panel zooms out from the card's position, returns focus to the composer when closed, and exposes hover delay, focus-return and double-click behaviour in Settings > General.
インストール
dsh plugin --profile web add github:naitang1207/dsh-macos-quicklookREADME
dsh-macos-quicklook
给 DSH 的交付卡片加一个「鼠标停上去按空格,弹 macOS 原生 Quick Look」的入口。
演示片

上面这段是「键盘 → 推近空格键 → 按下 → 预览面板升起」的原始节奏(4.7 秒)。
完整 22 秒全片:在浏览器里播放 · 下载 mp4(3 MB)。 全片是纯代码渲染,每帧由 HTML 截图生成,不是录屏。
为什么需要它
DSH 右侧栏自带的文件预览只有 pdfjs + shiki(PDF 和代码高亮),没有 docx / xlsx / pptx 的渲染器,
Office 文件在 DSH 里根本看不了。这个插件不自己解析文件,而是直接叫系统面板
QLPreviewPanel —— 就是 Finder 里按空格用的那一套,所以能预览的范围等于 macOS 支持的范围:
docx、xlsx、pptx、pages/numbers/keynote、pdf、图片、音视频、markdown、纯文本、代码。
用法
鼠标停在交付卡片上,按空格。 整张卡片都是热区,不用对准按钮。
卡片右上角「打开 / 在访达中显示」旁边多一个空格键图标(扁矩形描边,不是 ␣ 字符——U+2423 在很多 UI 字体里会渲染成接近正方形、看着像复选框),鼠标进入卡片时会亮起来提示此处可按空格;
点它效果相同,给不用键盘的时候。
| 操作 | 效果 |
|---|---|
| 空格(鼠标在卡片上) | 弹出预览;开着同一个文件时再按一次收起 |
| 鼠标停到另一张卡片按空格 | 直接换成那个文件,不重播进场动画 |
| Esc | 收起(只在鼠标停在卡片上时由插件处理,且不阻断 DSH 自己的 Esc) |
面板就是系统 Quick Look 面板本身,所以面板内的交互跟 Finder 里按空格是同一套实现, 不是我们另画的。
鼠标不在卡片上时,空格还是普通空格(对话输入、页面滚动都不受影响);焦点在输入框里时插件不拦空格。
鼠标在卡片上停稳 0.5 秒,键盘焦点会自动落到那张卡片上(窗口本身不在前台时会先把它激活), 所以从别的应用切回来时不用先点一下页面。这个时长可调,见下面的「设置」。
设置
插件在 DSH 的 设置 → 常规 里注册了一行「快速预览」(位置在「链接打开方式」下面、 「输入框回车」上面),三项都即时生效、不用重载插件:
| 设置 | 默认 | 说明 |
|---|---|---|
| 悬停接管焦点 | 500 ms | 鼠标在卡片上停多久把键盘焦点移过去;0 表示立即,上限 3000 |
| 关闭后聚焦输入框 | 开 | 预览关掉后把光标送回聊天输入框 |
| 双击卡片直接预览 | 关 | 打开后双击整张卡片即可预览,不必按空格 |
值存在 ~/.dsh/quicklook.json,读写走 GET/POST /api/quicklook.settings。没有用 cordis 的
Config,是因为那种配置写在 cordis.patch.yml 里、改完必须重载插件才生效,调一个数值要等重载。
这一行是插件自绘的:宿主对 settings.general.item 不传任何 props、也不投影 label
(文档原话「copy, current value, and the write path are all yours」),标题、当前值和写回都在
client.js 的 QuickLookSettings 里。
面板行为
大部分由系统决定,这也是改用官方面板的原因:
-
打开是从卡片位置放大:面板通过
previewPanel(_:sourceFrameOnScreenFor:)拿到卡片的屏幕矩形, 缩放动画(zoom effect)由系统自己做 —— 不是我们算贝塞尔曲线。实测系统在为一次打开动画会回调这个 方法约 10 次。 -
位置和尺寸也由系统定:1728×1117 的屏上实测为
816×849居中,比自绘时贴窗口右侧更接近 Finder 的观感,也不用再自己维护一套比例常量。 -
收起是 0.1s 快速淡出,实测约 50ms 内面板就不可见。
这里有个做不到的事:系统自己触发的关闭(点面板外面、Esc)会把面板缩回卡片位置,而我们 主动
orderOut只能原地淡出。截图差分的质心可以量出来——系统关闭质心贴卡片 (111,159 vs 卡片 91,116),主动关闭停在屏幕中心 (211,150)。resignKey()、激活别的 app 让 helper 失活、合成 Esc 键事件、panel.animator().setFrame(卡片矩形)全试过,都不走 zoom back,面板 frame 也不受 我们控制。这是QLPreviewPanel公开 API 的边界,所以选了「短淡出」这个观感最接近的替代。 -
第一次打开快:面板首次
makeKeyAndOrderFront要 ~72ms(建窗口 + 唤醒 quicklookd)。 helper 启动 0.6s 后会在屏幕外真显示一次再收起,把首次降到 ~22ms,同文件再开约 5ms。 -
换文件不播动画:同一面板
reloadData换内容,位置不动(按你的要求「干脆不做动画」)。 -
收起后焦点自动回 DSH:面板需要 key window(否则收不到 Esc 和触控板手势),所以打开期间 helper 会短暂成为前台;关闭后用它记住的原前台应用
activate()把焦点还回去——等价于在 Dock 上点一下 DSH,零轮询零开销。若关闭期间用户主动切换到其他应用,则取消还焦点,避免抢回窗口。如果「关闭后聚焦输入框」开着,光标会进一步落到聊天输入框上 (取页面里getBoundingClientRect().bottom最大的那个可编辑元素,不依赖宿主的 class 名)。
实现
一个 bundle 两个半边 + 一个常驻 helper:
| 部分 | 文件 | 作用 |
|---|---|---|
| 浏览器 | client.js | 往 deliverables.file.actions 槽挂空格键图标按钮(13×6 扁矩形 + 描边,随主题色);document 层用 data-presented-file 找鼠标所在卡片;捕获阶段拦空格(Esc 只在卡片上时处理);POST 到 api/quicklook.preview |
| Host | index.js | 把授权坐标还原成绝对路径,把命令写进常驻 helper 的 stdin;helper 由 ctx.effect 管生命周期 |
| helper | helper/main.swift | 官方 QLPreviewPanel + PreviewController(NSApplicationDelegate、QLPreviewPanelDataSource、QLPreviewPanelDelegate);透明 1×1 宿主窗口只 orderFrontRegardless() 用来让 app 可被激活 |
开关状态只由 helper 判定:浏览器半不缓存「面板开着吗」,只发 action: 'toggle'。
原因是用户按 Esc 关闭时面板是系统关的、页面收不到事件,前端缓存必然过期,过期就会发出无效的
hide,表现为「按了没反应、得按两下」。
helper 的关键设置(都是踩过坑才有的):
panel.hidesOnDeactivate = false // NSPanel 默认 true;后台进程不 active 会"显示但看不见"
panel.level = .floating
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
host.orderFrontRegardless() // 宿主窗口不能 makeKey,会把 key 从面板抢走导致面板自关
panel.makeKeyAndOrderFront(nil) // key 必须留在面板上
坐标 → 路径
走 DSH 自己的授权校验,不另开捷径:
sessionQuery.readEvent({ sessionId, seq, before: 0, after: 0 })读回该 seq 的deliverables/presented事件data.files[index]过isPresentedFile校验(path非空字符串、description可选字符串)workspaceFiles.stat({ sessionId, workspaceRoot: session.cwd ?? sandboxPolicy.workspaceRoot }, file.path)得到absolutePath
路由挂在 connection.fetch 上(POST /api/quicklook.preview),自带 Host/Origin 与登录 token
的信任栅栏,浏览器之外的调用会被 401 拦掉。未知 action 返回 400,helper 不可用返回 503,
请求被取消返回 499。
文件结构
package.json bundle 声明(dsh.bundle.patch / dsh.client.inject)
cordis.patch.yml 插入 id=quicklook 的 loader 条目
index.js Host 半
client.js 浏览器半
helper/main.swift 面板源码(QLPreviewPanel)
helper/qlhelper 编译好的二进制(随包发布)
locale/{zh,en}.json 插件名与描述
icon.svg 64×64 图标
运行时还会在 ~/.dsh/quicklook.json 写设置(不在这个包目录里,删掉即恢复默认)。
编译与热重载
仓库里已经带了编译好的 helper/qlhelper(universal binary,arm64 + x86_64,macOS 12+),装完就能用,不需要本机装 Swift 工具链。
只有改了 helper/main.swift 才需要重新编译(需要 Xcode 或 Swift 6):
cd <这个仓库>/helper
swiftc -O -target arm64-apple-macos12.0 main.swift -o /tmp/qlhelper-arm64
swiftc -O -target x86_64-apple-macos12.0 main.swift -o /tmp/qlhelper-x86_64
lipo -create /tmp/qlhelper-arm64 /tmp/qlhelper-x86_64 -output qlhelper
codesign -s - --force qlhelper
更新已有安装后,以插件管理器返回的 application 和 warnings 为准:applied 表示已应用运行时变更;若返回 restart-required,再完整退出并重新启动 DSH。仅切换启用状态或确认文件已更新,不能证明新代码的实际交互已经通过验收。
set_plugin 的 target 必须使用插件管理器列出的精确 entryId,传包名会报 unknown-plugin。
安装 / 卸载
dsh plugin install link:<这个仓库的绝对路径> # 或 plugin_manager install_bundle target=link:<目录>
dsh plugin remove @naitang1207/dsh-macos-quicklook
调试
两份日志,权限 0600:
~/.dsh/logs/quicklook-host.log—— 每次请求的 body、返回 status、耗时~/.dsh/logs/quicklook-helper.log—— 每次 show/swap/hide 的visible / key / appActive / hostVisible / hostKey,以及 1.2s 后的最终状态、sourceFrame与cardRect的矩形
排查「没反应」时先看 host 日志有没有收到请求:只有一条说明卡在前端,两条且第一条没弹出说明 卡在 helper。手动跑 helper 调试时记得先把插件 disable,否则两个进程会抢同一个系统面板互相关闭。
已知限制
- 主动关闭做不到缩回卡片(见上,
QLPreviewPanelAPI 边界)。 - 多屏坐标没支持:
screenRect的翻转基准固定取主屏frame.maxY,DSH 窗口在副屏上时 zoom 起点会错位。要支持得按窗口矩形与各屏求相交面积选屏。 - 悬停抢焦点有副作用:如果你正在别的应用里打字、鼠标恰好停在交付卡片上超过设定时长, 焦点会被抢过来。觉得太主动就把「悬停接管焦点」的毫秒数调大(比如 2000)。
- 失败时界面没有反馈:只剩
console.warn与日志。宿主没有公开的 toast/通知 service 契约, 不想为此去依赖别的插件内部结构。 - 文本类没有分流到 DSH 右侧栏:.md / 代码原本可以走侧栏高亮,那需要调侧栏内部视图接口, 没有公开契约,所以统一走原生预览。
走过的弯路(别重复走)
qlmanage -p:只会开一个固定尺寸、无动画、抢焦点的独立窗口,位置尺寸都不受控。- 自绘
NSPanel+QLPreviewView:动画 / 键盘 / 手势 / 缩放全要自己复刻,返工四轮仍不达标 (回弹、不丝滑、触控板手势收不到)。QLPreviewView只是可嵌入视图,不带动画与手势。 - 读官方头文件
$(xcrun --sdk macosx --show-sdk-path)/System/Library/Frameworks/QuickLookUI.framework/Headers/QLPreviewPanel.h才发现QLPreviewPanel本来就提供 zoom effect 和键盘处理——做 macOS 原生 API 先读 SDK 头文件。