dsh-workbuddy-files 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,36 @@
1
+ import type { DropBus } from '../lib/bus'
2
+
3
+ /** 组件只需 React 的这三个能力;由 app.ts 的 factory 通过 require('react') 注入 */
4
+ export interface ReactLike {
5
+ createElement(type: unknown, props?: Record<string, unknown> | null, ...children: unknown[]): unknown
6
+ useState<T>(initial: T | (() => T)): [T, (next: T | ((prev: T) => T)) => void]
7
+ useEffect(effect: () => void | (() => void), deps?: readonly unknown[]): void
8
+ }
9
+
10
+ /**
11
+ * 全屏拖拽遮罩 + toast(挂在 shell.overlay 列表槽)。
12
+ * 遮罩只在拖拽含非图片文件/文件夹时出现(纯图片拖放交给原生图片轨道);
13
+ * 层本身点击穿透,遮罩激活时开启 pointer-events 承接 drop。
14
+ */
15
+ export function createOverlayComponent(React: ReactLike, bus: DropBus) {
16
+ return function WorkbuddyOverlay() {
17
+ const [state, setState] = React.useState(bus.get())
18
+ React.useEffect(() => bus.subscribe(setState), [])
19
+
20
+ return React.createElement('div', { className: 'wbd-overlay' },
21
+ state.active
22
+ ? React.createElement('div', { className: 'wbd-shield' },
23
+ React.createElement('div', { className: 'wbd-shield-inner' },
24
+ React.createElement('div', { className: 'wbd-shield-icon' }, '📥'),
25
+ React.createElement('div', { className: 'wbd-shield-title' }, '松开以接收文件'),
26
+ React.createElement('div', { className: 'wbd-shield-sub' }, state.count + ' 项 · 将作为引用气泡插入输入框光标处'),
27
+ React.createElement('div', { className: 'wbd-shield-hint' }, '文件(含图片)将缓存至 ~/.dsh-drops 并引用绝对路径'),
28
+ ),
29
+ )
30
+ : null,
31
+ state.toast
32
+ ? React.createElement('div', { className: 'wbd-toast' + (state.toast.level === 'error' ? ' wbd-error' : '') }, state.toast.text)
33
+ : null,
34
+ )
35
+ }
36
+ }
@@ -0,0 +1,55 @@
1
+ import type { DropBus } from '../lib/bus'
2
+ import type { DropHandlers } from '../lib/drop'
3
+ import type { TreeFile } from '../types'
4
+ import type { ReactLike } from './overlay'
5
+
6
+ /**
7
+ * 📎 引用按钮(挂在 conversation.input.left 列表槽):
8
+ * 统一走 <input type=file>(多选 / webkitdirectory)→ 立即插入气泡 →
9
+ * 后台缓存(所有浏览器行为一致)。
10
+ */
11
+ export function createPickButtonComponent(React: ReactLike, bus: DropBus, handlers: Pick<DropHandlers, 'acceptTree'>) {
12
+ return function PickButton() {
13
+ const [open, setOpen] = React.useState(false)
14
+ const newBatch = () => 'drop-' + Date.now().toString(36)
15
+
16
+ const pickFolder = async () => {
17
+ setOpen(false)
18
+ const input = document.createElement('input')
19
+ input.type = 'file'
20
+ input.setAttribute('webkitdirectory', '')
21
+ input.onchange = () => {
22
+ const files: TreeFile[] = []
23
+ const tops: string[] = []
24
+ for (const f of Array.from(input.files ?? [])) {
25
+ files.push({ rel: f.webkitRelativePath || f.name, name: f.name, size: f.size, file: f })
26
+ const top = (f.webkitRelativePath || '').split('/')[0]
27
+ if (top !== '' && !tops.includes(top)) tops.push(top)
28
+ }
29
+ if (files.length > 0) void handlers.acceptTree(files, tops, newBatch())
30
+ }
31
+ input.click()
32
+ }
33
+
34
+ const pickFiles = async () => {
35
+ setOpen(false)
36
+ const input = document.createElement('input')
37
+ input.type = 'file'
38
+ input.multiple = true
39
+ input.onchange = () => {
40
+ const files: TreeFile[] = []
41
+ for (const f of Array.from(input.files ?? [])) files.push({ rel: f.name, name: f.name, size: f.size, file: f })
42
+ if (files.length > 0) void handlers.acceptTree(files, [], newBatch())
43
+ }
44
+ input.click()
45
+ }
46
+
47
+ return React.createElement('div', { className: 'wbd-pick' },
48
+ React.createElement('button', { type: 'button', className: 'wbd-pick-btn', title: '引用文件/文件夹(也可直接拖拽或粘贴)', onClick: () => setOpen(!open) }, '📎'),
49
+ open ? React.createElement('div', { className: 'wbd-pick-menu' },
50
+ React.createElement('button', { type: 'button', onClick: pickFiles }, '选择文件…'),
51
+ React.createElement('button', { type: 'button', onClick: pickFolder }, '选择文件夹…(保留目录树)'),
52
+ ) : null,
53
+ )
54
+ }
55
+ }
@@ -0,0 +1,32 @@
1
+ /** 包内样式(styles.insert 注入,随插件 Run 生命周期清理) */
2
+ export const CSS = [
3
+ '.wbd-overlay{position:fixed;inset:0;pointer-events:none;z-index:2147483000}',
4
+ '.wbd-shield{position:fixed;inset:0;pointer-events:auto;display:flex;align-items:center;justify-content:center;background:color-mix(in srgb,var(--dsw-color-bg,#0e1116) 72%,transparent);backdrop-filter:blur(2px)}',
5
+ '.wbd-shield-inner{min-width:340px;max-width:520px;padding:28px 36px;text-align:center;border:2px dashed var(--dsw-alias-border-l3,#4c9aff);border-radius:14px;background:var(--dsw-color-bg-elevated,#161b24);box-shadow:0 12px 48px rgba(0,0,0,.45)}',
6
+ '.wbd-shield-icon{font-size:34px;line-height:1}',
7
+ '.wbd-shield-title{margin-top:10px;font-size:17px;font-weight:600;color:var(--dsw-alias-label-primary,#f2f4f8)}',
8
+ '.wbd-shield-sub{margin-top:6px;font-size:13px;color:var(--dsw-alias-label-secondary,#aab2c0)}',
9
+ '.wbd-shield-hint{margin-top:10px;font-size:12px;color:var(--dsw-alias-label-tertiary,#6b7280)}',
10
+ '.wbd-toast{position:fixed;right:24px;bottom:132px;max-width:360px;padding:9px 14px;border-radius:10px;background:var(--dsw-color-bg-elevated,#1c222e);border:1px solid var(--dsw-alias-border-l3,#3a4252);color:var(--dsw-alias-label-primary,#f2f4f8);font-size:13px;box-shadow:0 8px 28px rgba(0,0,0,.35);pointer-events:auto}',
11
+ '.wbd-toast.wbd-error{border-color:#b3453f}',
12
+ '.wbd-cards{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:14px 0 4px;font-size:13px}',
13
+ '.wbd-cards-label{color:var(--dsw-alias-label-tertiary,#6b7280);margin-right:4px}',
14
+ '.wbd-card{display:inline-flex;align-items:center;gap:7px;max-width:340px;padding:4px 10px 4px 8px;border:1px solid var(--dsw-alias-border-l2,#2c3342);border-radius:8px;background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.04));color:var(--dsw-alias-label-primary,#f2f4f8);font:inherit;font-size:13px;cursor:pointer;text-align:left}',
15
+ '.wbd-card:hover{border-color:var(--dsw-alias-border-l3,#4c9aff)}',
16
+ '.wbd-card-icon{flex:none;font-size:15px}',
17
+ '.wbd-card-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
18
+ '.wbd-card-sub{flex:none;color:var(--dsw-alias-label-tertiary,#6b7280);font-size:12px}',
19
+ '.wbd-pick{position:relative;display:inline-flex}',
20
+ '.wbd-pick-btn{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border:none;border-radius:8px;background:transparent;color:var(--dsw-alias-label-secondary,#aab2c0);font-size:15px;cursor:pointer}',
21
+ '.wbd-pick-btn:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.06));color:var(--dsw-alias-label-primary,#f2f4f8)}',
22
+ '.wbd-pick-menu{position:absolute;left:0;bottom:calc(100% + 8px);display:flex;flex-direction:column;min-width:220px;padding:6px;border-radius:10px;border:1px solid var(--dsw-alias-border-l2,#2c3342);background:var(--dsw-color-bg-elevated,#1c222e);box-shadow:0 10px 32px rgba(0,0,0,.4);z-index:10}',
23
+ '.wbd-pick-menu button{display:block;width:100%;padding:8px 10px;border:none;border-radius:6px;background:transparent;color:var(--dsw-alias-label-primary,#f2f4f8);font:inherit;font-size:13px;text-align:left;cursor:pointer}',
24
+ '.wbd-pick-menu button:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.06))}',
25
+ '/* ---- 引用气泡换肤:主题色圆角矩形(原子删除由输入机原生保证) ---- */',
26
+ '/* 关键:padding 用等量负 margin 抵消、描边用 box-shadow(不占布局)—— */',
27
+ '/* 气泡外部宽度与 textarea 字符宽度完全一致,backdrop 与光标严格对齐 */',
28
+ '[data-decoration="chip"]{padding:0 8px !important;margin:0 -8px !important;border-radius:8px;background:color-mix(in srgb,var(--dsw-alias-brand-primary,#4c9aff) 14%,transparent) !important;box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dsw-alias-brand-primary,#4c9aff) 45%,transparent);color:var(--dsw-alias-brand-primary,#4c9aff) !important;font-weight:500}',
29
+ '[data-decoration="chip"]:hover{background:color-mix(in srgb,var(--dsw-alias-brand-primary,#4c9aff) 22%,transparent) !important}',
30
+ '[data-decoration="chip"][data-invalid="true"]{color:var(--dsw-alias-state-error-primary,#e56a64) !important;background:color-mix(in srgb,var(--dsw-alias-state-error-primary,#e56a64) 14%,transparent) !important;box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dsw-alias-state-error-primary,#e56a64) 45%,transparent) !important}',
31
+ '[data-decoration="chip"] [class*="chipTriggerGlyph"]{font-size:12px;opacity:.8}',
32
+ ].join('\n')
@@ -0,0 +1,83 @@
1
+ import { extractRefs } from './lib/transfer'
2
+
3
+ /**
4
+ * 会话事件定义(conversationEvents 注册表)—— 按轮次聚合用户消息中的
5
+ * 文件引用,发布为 Turn 级位置数据,供 turnTail 文件卡片的 selector
6
+ * 无快照、纯数据地判断「这一轮是否渲染卡片」。
7
+ *
8
+ * 关联规则:turn/start 时通过 reader.previous 取最近的前置用户消息上下文;
9
+ * 结合 turn 边界定义(workbuddy-turn-boundary)排除「无用户消息的轮次
10
+ * (如 goal 轮)」错误继承上一轮引用的情况。
11
+ */
12
+ export interface UserMessageEvent {
13
+ type: 'user/message'
14
+ seq: number
15
+ data: {
16
+ id: string
17
+ source: { kind: string }
18
+ content: ReadonlyArray<{ type: string; text?: string }>
19
+ }
20
+ }
21
+
22
+ export interface TurnBoundaryEvent {
23
+ type: 'turn/start' | 'turn/end'
24
+ seq: number
25
+ data: { turn: number }
26
+ }
27
+
28
+ export const boundaryDef = {
29
+ kind: 'workbuddy-turn-boundary',
30
+ match: (event: { type: string; data: { turn: number } }) =>
31
+ event.type === 'turn/start' ? { id: String(event.data.turn), role: 'start' as const }
32
+ : event.type === 'turn/end' ? { id: String(event.data.turn), role: 'update' as const }
33
+ : null,
34
+ start: (_context: unknown, match: { event: TurnBoundaryEvent }) => {
35
+ if (match.event.type !== 'turn/start') throw new Error('workbuddy-turn-boundary start requires turn/start')
36
+ return { turn: match.event.data.turn, startSeq: match.event.seq }
37
+ },
38
+ update: (context: { state: unknown }) => context.state,
39
+ }
40
+
41
+ export const fileRefsDef = {
42
+ kind: 'workbuddy-file-refs',
43
+ match: (event: { type: string; seq: number; data: { id?: string; turn?: number; source?: { kind: string }; content?: ReadonlyArray<{ type: string; text?: string }> } }) => {
44
+ if (event.type === 'user/message' && event.data?.source?.kind === 'user' && event.data.id !== undefined) {
45
+ return { id: String(event.data.id), role: 'start' as const }
46
+ }
47
+ if (event.type === 'turn/start' && event.data.turn !== undefined) {
48
+ return { id: 'turn-' + String(event.data.turn), role: 'start' as const }
49
+ }
50
+ return null
51
+ },
52
+ start: (context: { state: unknown }, match: { event: UserMessageEvent | TurnBoundaryEvent }, reader: { previous(kind: string): { startSeq: number; state: unknown } | undefined }) => {
53
+ if (match.event.type === 'turn/start') {
54
+ let refs: string[] = []
55
+ const prev = reader.previous('workbuddy-file-refs')
56
+ const prevBoundary = reader.previous('workbuddy-turn-boundary')
57
+ if (prev !== undefined && prev.state !== undefined && Array.isArray((prev.state as { refs?: unknown }).refs)) {
58
+ const afterBoundary = prevBoundary === undefined || prevBoundary.state === undefined || prev.startSeq > prevBoundary.startSeq
59
+ if (afterBoundary) refs = (prev.state as { refs: string[] }).refs
60
+ }
61
+ return { turn: match.event.data.turn, refs }
62
+ }
63
+ void context
64
+ const ev = match.event as UserMessageEvent
65
+ return { seq: ev.seq, refs: extractRefs(ev.data?.content ?? []) }
66
+ },
67
+ update: (context: { state: unknown }) => context.state,
68
+ buildLocationData: (context: { state: unknown }, scope: string) => {
69
+ if (scope !== 'turn') return null
70
+ const s = context.state as { turn?: number; refs?: string[] } | undefined
71
+ if (s === undefined || s.turn === undefined || !Array.isArray(s.refs) || s.refs.length === 0) return null
72
+ return { kind: 'turn', turn: s.turn, key: 'workbuddy-file-refs', value: { refs: s.refs } }
73
+ },
74
+ }
75
+
76
+ /** turnTail chain selector:仅当该轮次存在文件引用时返回 matched,避免抢占其他链条目 */
77
+ export function selectTurnFileRefs(owner: { turn?: { data: { get(key: string): { refs?: string[] } | undefined } } } | null | undefined): { refs: string[] } | null {
78
+ const t = owner !== null && owner !== undefined ? owner.turn : undefined
79
+ if (t === undefined || t.data === undefined || typeof t.data.get !== 'function') return null
80
+ const data = t.data.get('workbuddy-file-refs')
81
+ if (data === undefined || data === null || !Array.isArray(data.refs) || data.refs.length === 0) return null
82
+ return { refs: data.refs }
83
+ }
@@ -0,0 +1,15 @@
1
+ // Client 半侧 bundle 外壳:由 tsdown 构建为 lib/client.js。
2
+ // 必须是一个「普通副作用脚本」——加载时调用 window.__ModuleLoader__.load,
3
+ // 不能包含顶层 ESM export / import(react 由 factory 的 require 取得)。
4
+ import { makeFactory } from './app'
5
+
6
+ declare const window: {
7
+ __ModuleLoader__: {
8
+ load(info: { id: string; factory: (require: (m: string) => unknown) => unknown }): void
9
+ }
10
+ }
11
+
12
+ window.__ModuleLoader__.load({
13
+ id: 'dsh-workbuddy-files',
14
+ factory: makeFactory() as never,
15
+ })
@@ -0,0 +1,34 @@
1
+ import type { BusState } from '../types'
2
+
3
+ /**
4
+ * 拖拽遮罩 / toast 的轻量发布订阅总线。
5
+ * 动态(dynamic)插件里同一逻辑内联在 apply 中;这里抽成模块便于测试。
6
+ */
7
+ export interface DropBus {
8
+ get(): BusState
9
+ set(next: BusState): void
10
+ subscribe(fn: (s: BusState) => void): () => void
11
+ toast(text: string, level?: 'info' | 'error'): void
12
+ }
13
+
14
+ export function createDropBus(): DropBus {
15
+ let state: BusState = { active: false, count: 0, toast: null }
16
+ const subs = new Set<(s: BusState) => void>()
17
+ let timer: ReturnType<typeof setTimeout> | null = null
18
+
19
+ const get = () => state
20
+ const set = (next: BusState) => {
21
+ state = next
22
+ for (const fn of subs) fn(state)
23
+ }
24
+ const subscribe = (fn: (s: BusState) => void) => {
25
+ subs.add(fn)
26
+ return () => { subs.delete(fn) }
27
+ }
28
+ const toast = (text: string, level: 'info' | 'error' = 'info') => {
29
+ if (timer !== null) clearTimeout(timer)
30
+ set({ ...state, toast: { text: String(text), level } })
31
+ timer = setTimeout(() => { timer = null; set({ ...state, toast: null }) }, 4600)
32
+ }
33
+ return { get, set, subscribe, toast }
34
+ }
@@ -0,0 +1,235 @@
1
+ import type { InsertItem, TreeFile, UploadJob } from '../types'
2
+ import type { DropBus } from './bus'
3
+ import type { InsertPipeline } from './insert'
4
+ import { mentionFor, walkEntry } from './transfer'
5
+
6
+ /**
7
+ * 拖拽 / 粘贴处理(统一接管 + 拖入即插气泡):
8
+ *
9
+ * 1. 任何文件拖入/粘贴(含图片)都走本插件:全屏遮罩 → 解析引用项;
10
+ * 2. 最终缓存路径在拖入时即可确定(<dropsRoot>/<批次>/<相对路径>),
11
+ * 因此**立即**在输入框光标处插入气泡 —— 不等上传;
12
+ * 3. 文件落盘由 enqueueUpload 在后台异步进行(本地写盘,用户无感),
13
+ * 完成/失败以 toast 告知;
14
+ * 4. 接管时从 dragenter 起 stopPropagation(window capture 首站),
15
+ * DSH 原生图片拖放遮罩不会激活,也不会因收不到收尾事件而卡住页面。
16
+ */
17
+
18
+ export interface DropDeps {
19
+ bus: DropBus
20
+ insert: InsertPipeline
21
+ /** 缓存根目录(应用启动时预取,拖入时兜底拉取) */
22
+ ensureRoot(): Promise<string | null>
23
+ /** 后台上传入口(不阻塞输入;完成后自行 toast) */
24
+ enqueueUpload(jobs: UploadJob[], batch: string): void
25
+ }
26
+
27
+ /** 任何文件拖拽都接管;items 不可用(Firefox dragenter/dragover)时按 types 兜底 */
28
+ export function interceptable(dt: { items: DataTransferItemList | DataTransferItem[]; types?: readonly string[] } | null | undefined): boolean {
29
+ if (dt === null || dt === undefined) return false
30
+ const types = Array.from(dt.types ?? [])
31
+ if (types.includes('Files')) return true
32
+ if (dt.items) {
33
+ for (const it of Array.from(dt.items)) if (it.kind === 'file') return true
34
+ }
35
+ return false
36
+ }
37
+
38
+ export function countFiles(dt: { items: DataTransferItemList | DataTransferItem[] }): number {
39
+ let n = 0
40
+ for (const it of Array.from(dt.items)) if (it.kind === 'file') n += 1
41
+ return n
42
+ }
43
+
44
+ export interface DropHandlers {
45
+ /** 已同步收集的拖拽/粘贴条目 → 立即插气泡 + 后台缓存 */
46
+ acceptAndInsert(synced: SyncedItem[], batch: string): Promise<void>
47
+ /** 文件选择框(input.files)→ 立即插气泡 + 后台缓存 */
48
+ acceptTree(files: TreeFile[], dirTops: string[], batch: string): Promise<void>
49
+ installListeners(): () => void
50
+ }
51
+
52
+ /** drop/paste 事件内同步收集的结果(事件结束后 DataTransfer 即失效,不可再读) */
53
+ export interface SyncedItem {
54
+ entry: FileSystemEntry | null
55
+ file: File | null
56
+ }
57
+
58
+ /**
59
+ * 关键:DataTransfer 只在事件同步阶段有效 —— getAsFile / webkitGetAsEntry
60
+ * 必须在事件处理器内、任何 await 之前完成调用,否则浏览器清空 DataTransfer
61
+ * 后全部返回 null。本函数专门在同步阶段收集 File / Entry。
62
+ */
63
+ export function syncCollect(dt: { items: DataTransferItemList | DataTransferItem[] }): SyncedItem[] {
64
+ const synced: SyncedItem[] = []
65
+ for (const it of Array.from(dt.items)) {
66
+ if (it.kind !== 'file') continue
67
+ let entry: FileSystemEntry | null = null
68
+ try {
69
+ const getter = (it as unknown as { webkitGetAsEntry?: () => FileSystemEntry | null }).webkitGetAsEntry
70
+ ?? (it as unknown as { getAsEntry?: () => FileSystemEntry | null }).getAsEntry
71
+ entry = typeof getter === 'function' ? getter() : null
72
+ } catch { entry = null }
73
+ let file: File | null = null
74
+ try { file = typeof (it as DataTransferItem).getAsFile === 'function' ? (it as DataTransferItem).getAsFile() : null } catch { file = null }
75
+ if (entry === null && file === null) continue
76
+ synced.push({ entry, file })
77
+ }
78
+ return synced
79
+ }
80
+
81
+ export function createDropHandlers(deps: DropDeps): DropHandlers {
82
+ const { bus, insert } = deps
83
+
84
+ const submitRefs = async (refs: InsertItem[], jobs: UploadJob[], batch: string): Promise<void> => {
85
+ if (refs.length === 0) { bus.toast('无法读取拖入的内容', 'error'); return }
86
+ // 1) 立即插入气泡(不等上传)
87
+ const inserted = await insert(refs)
88
+ if (inserted > 0) bus.toast('已引用 ' + inserted + ' 项,文件正在后台缓存')
89
+ else bus.toast('未能插入引用(见上方提示)', 'error')
90
+ // 2) 后台缓存
91
+ deps.enqueueUpload(jobs, batch)
92
+ }
93
+
94
+ /** 文件选择框路径(已有 TreeFile 列表) */
95
+ const acceptTree = async (files: TreeFile[], dirTops: string[], batch: string): Promise<void> => {
96
+ const root = await deps.ensureRoot()
97
+ if (root === null) { bus.toast('无法获取缓存目录,请重试', 'error'); return }
98
+ const refs: InsertItem[] = []
99
+ for (const top of dirTops) {
100
+ const dirPath = root + '/' + batch + '/' + top
101
+ refs.push({ label: top, reference: { source: 'workbuddy', ref: mentionFor(dirPath, true), label: top, appearance: 'folder', clipboardText: mentionFor(dirPath, true) } })
102
+ }
103
+ for (const f of files) {
104
+ const path = root + '/' + batch + '/' + f.rel
105
+ refs.push({ label: f.name, reference: { source: 'workbuddy', ref: mentionFor(path, false), label: f.name, appearance: 'file', clipboardText: mentionFor(path, false) } })
106
+ }
107
+ await submitRefs(refs, files.map((f) => ({ kind: 'file', file: f.file, rel: f.rel, name: f.name })), batch)
108
+ }
109
+
110
+ /**
111
+ * 核心:先按预分配路径立即插入气泡,再交给后台缓存。
112
+ * synced 必须在事件内同步收集完毕(见 syncCollect)。
113
+ */
114
+ const acceptAndInsert = async (synced: SyncedItem[], batch: string): Promise<void> => {
115
+ const root = await deps.ensureRoot()
116
+ if (root === null) { bus.toast('无法获取缓存目录,请重试', 'error'); return }
117
+
118
+ const refs: InsertItem[] = []
119
+ const jobs: UploadJob[] = []
120
+
121
+ for (const s of synced) {
122
+ const { entry, file } = s
123
+
124
+ if (entry !== null && entry !== undefined && entry.isDirectory) {
125
+ const top = entry.name
126
+ const dirPath = root + '/' + batch + '/' + top
127
+ refs.push({ label: top, reference: { source: 'workbuddy', ref: mentionFor(dirPath, true), label: top, appearance: 'folder', clipboardText: mentionFor(dirPath, true) } })
128
+ jobs.push({ kind: 'dir', entry })
129
+ continue
130
+ }
131
+
132
+ if (file !== null && file !== undefined) {
133
+ const rel = file.name
134
+ const path = root + '/' + batch + '/' + rel
135
+ refs.push({ label: file.name, reference: { source: 'workbuddy', ref: mentionFor(path, false), label: file.name, appearance: 'file', clipboardText: mentionFor(path, false) } })
136
+ jobs.push({ kind: 'file', file, rel, name: file.name })
137
+ continue
138
+ }
139
+
140
+ // entry 是文件但 getAsFile 失败:尽力从 entry.file 取(事件外可能失效,尽力而为)
141
+ if (entry !== null && entry !== undefined && entry.isFile) {
142
+ const fe = entry as FileSystemFileEntry
143
+ const f = await new Promise<File | null>((resolve2) => fe.file((ff) => resolve2(ff), () => resolve2(null)))
144
+ if (f !== null && f !== undefined) {
145
+ const rel = f.name
146
+ const path = root + '/' + batch + '/' + rel
147
+ refs.push({ label: f.name, reference: { source: 'workbuddy', ref: mentionFor(path, false), label: f.name, appearance: 'file', clipboardText: mentionFor(path, false) } })
148
+ jobs.push({ kind: 'file', file: f, rel, name: f.name })
149
+ }
150
+ }
151
+ }
152
+
153
+ await submitRefs(refs, jobs, batch)
154
+ }
155
+
156
+ const installListeners = (): (() => void) => {
157
+ let dragDepth = 0
158
+ const onDragEnter = (e: DragEvent) => {
159
+ if (!interceptable(e.dataTransfer)) return
160
+ e.preventDefault()
161
+ // 关键:capture 首站即阻断传播。DSH 原生图片拖放轨道在 document(bubble)
162
+ // 上监听 dragenter 并无条件显示全屏遮罩;若它先激活、而我们的 drop 又
163
+ // stopPropagation,它会收不到收尾事件而永久卡住页面。
164
+ e.stopPropagation()
165
+ dragDepth += 1
166
+ bus.set({ ...bus.get(), active: true, count: countFiles(e.dataTransfer) })
167
+ }
168
+ const onDragOver = (e: DragEvent) => {
169
+ if (!interceptable(e.dataTransfer)) return
170
+ e.preventDefault()
171
+ e.stopPropagation()
172
+ if (!bus.get().active) { dragDepth = 1; bus.set({ ...bus.get(), active: true, count: countFiles(e.dataTransfer) }) }
173
+ }
174
+ const onDragLeave = (e: DragEvent) => {
175
+ if (!interceptable(e.dataTransfer)) return
176
+ e.preventDefault()
177
+ e.stopPropagation()
178
+ if (dragDepth > 0) dragDepth -= 1
179
+ if (dragDepth === 0) bus.set({ ...bus.get(), active: false, count: 0 })
180
+ }
181
+ const onDrop = (e: DragEvent) => {
182
+ const dt = e.dataTransfer
183
+ if (dt === null || dt === undefined) return
184
+ if (!interceptable(dt)) return
185
+ e.preventDefault()
186
+ e.stopPropagation()
187
+ dragDepth = 0
188
+ bus.set({ ...bus.get(), active: false, count: 0 })
189
+ // 事件内同步收集(await 之前),否则 DataTransfer 失效
190
+ const synced = syncCollect(dt)
191
+ if (synced.length === 0) return
192
+ acceptAndInsert(synced, 'drop-' + Date.now().toString(36)).catch((err) => {
193
+ console.error('[workbuddy] drop 处理失败:', err)
194
+ bus.toast('拖入处理失败:' + String((err as Error)?.message ?? err), 'error')
195
+ })
196
+ }
197
+ const onDragEnd = () => {
198
+ dragDepth = 0
199
+ bus.set({ ...bus.get(), active: false, count: 0 })
200
+ }
201
+ const onPaste = (e: ClipboardEvent) => {
202
+ const cd = e.clipboardData
203
+ if (cd === null || cd === undefined || !cd.items) return
204
+ let hasFile = false
205
+ for (const it of Array.from(cd.items)) if (it.kind === 'file') { hasFile = true; break }
206
+ if (!hasFile) return
207
+ e.preventDefault()
208
+ e.stopPropagation()
209
+ // 事件内同步收集(await 之前),否则 clipboardData 失效
210
+ const synced = syncCollect(cd)
211
+ if (synced.length === 0) return
212
+ acceptAndInsert(synced, 'drop-' + Date.now().toString(36)).catch((err) => {
213
+ console.error('[workbuddy] 粘贴处理失败:', err)
214
+ bus.toast('粘贴处理失败:' + String((err as Error)?.message ?? err), 'error')
215
+ })
216
+ }
217
+
218
+ window.addEventListener('dragenter', onDragEnter, true)
219
+ window.addEventListener('dragover', onDragOver, true)
220
+ window.addEventListener('dragleave', onDragLeave, true)
221
+ window.addEventListener('drop', onDrop, true)
222
+ window.addEventListener('dragend', onDragEnd, true)
223
+ window.addEventListener('paste', onPaste, true)
224
+ return () => {
225
+ window.removeEventListener('dragenter', onDragEnter, true)
226
+ window.removeEventListener('dragover', onDragOver, true)
227
+ window.removeEventListener('dragleave', onDragLeave, true)
228
+ window.removeEventListener('drop', onDrop, true)
229
+ window.removeEventListener('dragend', onDragEnd, true)
230
+ window.removeEventListener('paste', onPaste, true)
231
+ }
232
+ }
233
+
234
+ return { acceptAndInsert, acceptTree, installListeners }
235
+ }
@@ -0,0 +1,26 @@
1
+ /** 文件类型 → 图标/颜色映射(WorkBuddy 风格的类型标识) */
2
+
3
+ const ICONS: Record<string, string> = {
4
+ pdf: '📕', doc: '📘', docx: '📘', xls: '📊', xlsx: '📊', csv: '📊', ppt: '📙', pptx: '📙',
5
+ png: '🖼️', jpg: '🖼️', jpeg: '🖼️', gif: '🖼️', webp: '🖼️', svg: '🖼️', bmp: '🖼️',
6
+ zip: '🗜️', '7z': '🗜️', rar: '🗜️', tar: '🗜️', gz: '🗜️',
7
+ mp3: '🎵', wav: '🎵', flac: '🎵', mp4: '🎬', mov: '🎬', mkv: '🎬',
8
+ md: '📝', txt: '📄', log: '📄',
9
+ js: '💻', ts: '💻', jsx: '💻', tsx: '💻', py: '💻', go: '💻', rs: '💻', java: '💻',
10
+ c: '💻', h: '💻', cpp: '💻', cs: '💻', json: '💻', yaml: '💻', yml: '💻', toml: '💻',
11
+ sh: '💻', ps1: '💻', bat: '💻', css: '💻', html: '💻', vue: '💻', sql: '💻',
12
+ }
13
+
14
+ export function iconFor(name: string): string {
15
+ const dot = String(name).lastIndexOf('.')
16
+ const ext = dot >= 0 ? String(name).slice(dot + 1).toLowerCase() : ''
17
+ return ICONS[ext] ?? '📄'
18
+ }
19
+
20
+ export function formatSize(n: number | null | undefined): string {
21
+ if (n === null || n === undefined || !Number.isFinite(n)) return ''
22
+ if (n < 1024) return n + ' B'
23
+ if (n < 1048576) return (n / 1024).toFixed(1) + ' KB'
24
+ if (n < 1073741824) return (n / 1048576).toFixed(1) + ' MB'
25
+ return (n / 1073741824).toFixed(2) + ' GB'
26
+ }
@@ -0,0 +1,107 @@
1
+ import type { InsertItem } from '../types'
2
+
3
+ /**
4
+ * 气泡插入管线 —— 整个插件的核心:
5
+ *
6
+ * 通过对话包暴露的 `conversation.input`(InputHub,SessionInputResolver 面)
7
+ * 解析当前会话的输入 shell,读取实时 InputState(draft/draftRev),在
8
+ * 光标位置调用 `shell.insertReference(reference, span)` 铸造原生引用气泡
9
+ * (occurrence)。span 采用 draftRev CAS:若插入期间用户输入导致修订号
10
+ * 变化,重试读取新状态(最多 8 次);非 plain 阶段(提交中)插入被拒。
11
+ *
12
+ * 兜底 1:insertReference 反复失败 → setDraft 直接写草稿文本(保证输入框有内容);
13
+ * 兜底 2:无 facade → 聚焦 textarea 时 document.execCommand 纯文本插入。
14
+ */
15
+ export interface InsertDeps {
16
+ sessions: {
17
+ list: { getSnapshot(): { current: string | undefined } }
18
+ }
19
+ conversation: {
20
+ input: {
21
+ /**
22
+ * id 寻址服务面(InputHub.shell)—— 直接返回会话输入 shell,
23
+ * 无需 sessions.scope()(后者返回 cordis Context,动态门面拒绝暴露)。
24
+ */
25
+ shell(id: string): {
26
+ state: { getSnapshot(): { draft: string; draftRev: number } }
27
+ insertReference(reference: InsertItem['reference'], span: { start: number; end: number; draftRev: number }): boolean
28
+ setDraft(text: string): void
29
+ notify(level: 'info' | 'error', text: string): void
30
+ }
31
+ }
32
+ }
33
+ toast(text: string, level?: 'info' | 'error'): void
34
+ }
35
+
36
+ export type InsertPipeline = (items: InsertItem[]) => Promise<number>
37
+
38
+ const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms))
39
+
40
+ function readCaret(fallbackLen: number): number {
41
+ const el = document.activeElement
42
+ if (el !== null && el !== undefined && String(el.tagName).toUpperCase() === 'TEXTAREA' && typeof (el as HTMLTextAreaElement).selectionStart === 'number') {
43
+ return (el as HTMLTextAreaElement).selectionStart
44
+ }
45
+ return fallbackLen
46
+ }
47
+
48
+ export function createInsertPipeline(deps: InsertDeps): InsertPipeline {
49
+ return async function insertItems(items: InsertItem[]): Promise<number> {
50
+ const sessionId = deps.sessions.list.getSnapshot().current
51
+ if (sessionId === undefined) {
52
+ deps.toast('请先打开或新建一个会话,再拖入文件', 'error')
53
+ return 0
54
+ }
55
+ let shell: null | {
56
+ state: { getSnapshot(): { draft: string; draftRev: number } }
57
+ insertReference(reference: InsertItem['reference'], span: { start: number; end: number; draftRev: number }): boolean
58
+ setDraft(text: string): void
59
+ notify(level: 'info' | 'error', text: string): void
60
+ } = null
61
+ try { shell = deps.conversation.input.shell(sessionId) } catch { shell = null }
62
+
63
+ let inserted = 0
64
+ let firstCaret: number | null = null
65
+ for (const item of items) {
66
+ if (shell !== null && shell !== undefined) {
67
+ let ok = false
68
+ for (let attempt = 0; attempt < 8 && !ok; attempt += 1) {
69
+ try {
70
+ const st = shell.state.getSnapshot()
71
+ let caret = st.draft.length
72
+ if (firstCaret === null) {
73
+ firstCaret = readCaret(st.draft.length)
74
+ caret = firstCaret
75
+ }
76
+ const pos = Math.min(caret, st.draft.length)
77
+ ok = shell.insertReference(item.reference, { start: pos, end: pos, draftRev: st.draftRev })
78
+ } catch { ok = false }
79
+ if (!ok) await sleep(90)
80
+ }
81
+ if (ok) { inserted += 1; continue }
82
+ // 兜底 1:直接写草稿文本(无气泡,但输入框立刻有内容)
83
+ try {
84
+ const st = shell.state.getSnapshot()
85
+ const pos = Math.min(firstCaret !== null ? firstCaret : st.draft.length, st.draft.length)
86
+ const mention = typeof item.reference.ref === 'string' ? item.reference.ref : String(item.label)
87
+ const next = st.draft.slice(0, pos) + mention + ' ' + st.draft.slice(pos)
88
+ shell.setDraft(next)
89
+ inserted += 1
90
+ continue
91
+ } catch (err2) {
92
+ try { shell.notify('error', '未能插入引用「' + item.label + '」:' + String((err2 as Error)?.message ?? err2)) } catch { /* ignore */ }
93
+ }
94
+ } else {
95
+ // 兜底 2:无 facade —— 聚焦 textarea 时 execCommand 纯文本插入
96
+ const text = item.reference.ref
97
+ const el = document.activeElement
98
+ if (el !== null && el !== undefined && String(el.tagName).toUpperCase() === 'TEXTAREA') {
99
+ try { document.execCommand('insertText', false, text + ' '); inserted += 1 } catch { /* ignore */ }
100
+ } else {
101
+ deps.toast('输入区不可用,未能插入「' + item.label + '」', 'error')
102
+ }
103
+ }
104
+ }
105
+ return inserted
106
+ }
107
+ }