@aicupa/plugin-todo-dependency 1.0.1

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.
package/inject.js ADDED
@@ -0,0 +1,347 @@
1
+ ;(function () {
2
+ if (window.__TODO_DEP_INJECTED__) return
3
+ window.__TODO_DEP_INJECTED__ = true
4
+
5
+ const PLUGIN_NAME = '@aicupa/plugin-todo-dependency'
6
+ const isZh = (navigator.language || '').startsWith('zh')
7
+ const T = isZh
8
+ ? {
9
+ modalTitle: '设置依赖', searchPlaceholder: '搜索 Todo...',
10
+ cancel: '取消', save: '保存', done: '已完成',
11
+ selCount: n => `已选 ${n} 个依赖`, noMatch: '无匹配结果',
12
+ depLabel: '依赖',
13
+ }
14
+ : {
15
+ modalTitle: 'Set Dependencies', searchPlaceholder: 'Search Todos...',
16
+ cancel: 'Cancel', save: 'Save', done: 'Done',
17
+ selCount: n => `${n} selected`, noMatch: 'No matching results',
18
+ depLabel: 'Depends on',
19
+ }
20
+
21
+ // ── Service call via CustomEvent ──
22
+ let callSeq = 0
23
+ function callPluginService(method, params) {
24
+ return new Promise((resolve, reject) => {
25
+ const cbEvent = 'tdep-cb-' + (++callSeq)
26
+ const timer = setTimeout(() => { window.removeEventListener(cbEvent, handler); reject(new Error('timeout')) }, 5000)
27
+ const handler = (e) => {
28
+ clearTimeout(timer)
29
+ window.removeEventListener(cbEvent, handler)
30
+ const d = e.detail
31
+ if (d?.ok === false) reject(new Error(d.error || 'failed'))
32
+ else resolve(d)
33
+ }
34
+ window.addEventListener(cbEvent, handler)
35
+ window.dispatchEvent(new CustomEvent('plugin-call-service', {
36
+ detail: { pluginName: PLUGIN_NAME, method, params, callbackEvent: cbEvent },
37
+ }))
38
+ })
39
+ }
40
+
41
+ function unwrap(res) {
42
+ const inner = res?.result || res
43
+ return inner?.result !== undefined ? inner.result : inner
44
+ }
45
+
46
+ // ── Context menu result listener ──
47
+ let currentFilePath = ''
48
+
49
+ window.addEventListener('plugin-command-done', e => {
50
+ const d = e.detail
51
+ if (!d || d.command !== 'setDependency') return
52
+ const r = d.result?.result || d.result
53
+ if (!r?.target) return
54
+ currentFilePath = r.target.filePath || currentFilePath
55
+ allTodos = r.allTodos || []
56
+ showModal(r.target)
57
+ })
58
+
59
+ // ── Tree update listener — refresh dep data ──
60
+ window.addEventListener('plugin-tree-updated', () => {
61
+ if (currentFilePath) refreshDepData()
62
+ })
63
+
64
+ async function refreshDepData() {
65
+ try {
66
+ const res = await callPluginService('scanAllDeps', { filePath: currentFilePath })
67
+ const data = unwrap(res)
68
+ if (!data) return
69
+ depData = buildDepDisplay(data.todos, data.edges)
70
+ try { localStorage.setItem(DEP_STORAGE_KEY, JSON.stringify(depData)) } catch {}
71
+ } catch {}
72
+ }
73
+
74
+ function buildDepDisplay(todos, edges) {
75
+ const display = {}
76
+ for (const [depId, todoId] of edges) {
77
+ if (!display[todoId]) display[todoId] = { deps: [] }
78
+ const dep = todos[depId]
79
+ display[todoId].deps.push({ id: depId, content: dep ? dep.content : '#' + depId })
80
+ }
81
+ return display
82
+ }
83
+
84
+ // ── Styles ──
85
+ function injectStyles() {
86
+ if (document.getElementById('tdep-styles')) return
87
+ const s = document.createElement('style')
88
+ s.id = 'tdep-styles'
89
+ s.textContent = `
90
+ .tdep-tag {
91
+ font-size: 10px; color: #999; margin-left: 6px; padding: 1px 6px;
92
+ background: rgba(128,128,128,0.1); border-radius: 4px;
93
+ line-height: 1.4; overflow: hidden; text-overflow: ellipsis;
94
+ white-space: nowrap; pointer-events: none; max-width: 300px;
95
+ display: inline-block; vertical-align: middle;
96
+ animation: tdepFadeIn 0.15s ease-out;
97
+ }
98
+ @keyframes tdepFadeIn { from { opacity: 0; } }
99
+
100
+ .tdep-backdrop {
101
+ position: fixed; inset: 0; background: rgba(0,0,0,0.35);
102
+ display: flex; align-items: center; justify-content: center; z-index: 2147483646;
103
+ }
104
+ .tdep-card {
105
+ background: #fff; border-radius: 12px; width: 440px; max-height: 75vh;
106
+ display: flex; flex-direction: column;
107
+ box-shadow: 0 8px 40px rgba(0,0,0,0.18);
108
+ animation: tdepIn 0.15s ease-out;
109
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
110
+ color: #333;
111
+ }
112
+ @keyframes tdepIn { from { opacity: 0; transform: scale(0.95) translateY(8px); } }
113
+
114
+ .tdep-header { padding: 16px 20px 12px; }
115
+ .tdep-title { font-size: 15px; font-weight: 600; }
116
+ .tdep-subtitle {
117
+ font-size: 12px; color: #888; margin-top: 4px;
118
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
119
+ }
120
+
121
+ .tdep-search { padding: 0 20px 10px; }
122
+ .tdep-search input {
123
+ width: 100%; padding: 7px 12px; border: 1px solid #e0e0e0; border-radius: 6px;
124
+ font-size: 13px; outline: none; background: #fafafa; color: inherit;
125
+ font-family: inherit;
126
+ }
127
+ .tdep-search input:focus { border-color: #1890ff; }
128
+
129
+ .tdep-body {
130
+ flex: 1; overflow-y: auto; max-height: 50vh;
131
+ border-top: 1px solid #f0f0f0; border-bottom: 1px solid #f0f0f0;
132
+ }
133
+
134
+ .tdep-item {
135
+ display: flex; align-items: center; padding: 8px 20px; cursor: pointer;
136
+ gap: 10px; font-size: 13px;
137
+ }
138
+ .tdep-item:hover { background: #f5f7fa; }
139
+ .tdep-item.checked { background: #e6f7ff; }
140
+
141
+ .tdep-item input[type="checkbox"] {
142
+ width: 16px; height: 16px; flex-shrink: 0; accent-color: #1890ff; cursor: pointer;
143
+ }
144
+ .tdep-item-id { font-size: 11px; color: #999; flex-shrink: 0; min-width: 48px; }
145
+ .tdep-item-content {
146
+ flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
147
+ }
148
+ .tdep-item-done { font-size: 10px; color: #52c41a; flex-shrink: 0; }
149
+
150
+ .tdep-empty { padding: 32px 20px; text-align: center; color: #bbb; font-size: 13px; }
151
+
152
+ .tdep-footer {
153
+ padding: 12px 20px; display: flex; justify-content: space-between; align-items: center;
154
+ }
155
+ .tdep-count { font-size: 12px; color: #999; }
156
+ .tdep-actions { display: flex; gap: 8px; }
157
+
158
+ .tdep-btn {
159
+ padding: 6px 16px; border: none; border-radius: 6px;
160
+ font-size: 12px; cursor: pointer; background: #f0f0f0; color: #666;
161
+ font-family: inherit;
162
+ }
163
+ .tdep-btn:hover { background: #e0e0e0; }
164
+ .tdep-btn:active { transform: scale(0.97); }
165
+ .tdep-btn-primary { background: #1890ff; color: #fff; }
166
+ .tdep-btn-primary:hover { background: #40a9ff; }
167
+ `
168
+ document.head.appendChild(s)
169
+ }
170
+
171
+ // ── Dep data (hover-based lazy display) ──
172
+ const DEP_STORAGE_KEY = 'todo_dep_display'
173
+ let depData = {}
174
+
175
+ function loadDepData() {
176
+ try { depData = JSON.parse(localStorage.getItem(DEP_STORAGE_KEY)) || {} } catch { depData = {} }
177
+ }
178
+
179
+ let hoverWrap = null
180
+
181
+ function showDepTag(wrapEl) {
182
+ const todoId = wrapEl.getAttribute('data-todowrapid')
183
+ const info = depData[todoId]
184
+ if (!info?.deps?.length) return
185
+
186
+ const textEl = wrapEl.querySelector('[data-todoid]')
187
+ if (!textEl || textEl.querySelector('.tdep-tag')) return
188
+
189
+ const tag = document.createElement('span')
190
+ tag.className = 'tdep-tag'
191
+ tag.textContent = T.depLabel + ': ' + info.deps.map(d => d.content).join(' | ')
192
+ tag.title = info.deps.map(d => '#' + d.id + ' ' + d.content).join('\n')
193
+ textEl.appendChild(tag)
194
+ }
195
+
196
+ function removeDepTag(wrapEl) {
197
+ const textEl = wrapEl.querySelector('[data-todoid]')
198
+ if (!textEl) return
199
+ const tag = textEl.querySelector('.tdep-tag')
200
+ if (tag) tag.remove()
201
+ }
202
+
203
+ function initHover() {
204
+ document.addEventListener('mouseover', e => {
205
+ const el = e.target.closest?.('[data-todowrapid]')
206
+ if (el === hoverWrap) return
207
+
208
+ if (hoverWrap) { removeDepTag(hoverWrap); hoverWrap = null }
209
+ if (!el) return
210
+
211
+ hoverWrap = el
212
+ showDepTag(el)
213
+ })
214
+
215
+ document.addEventListener('mouseout', e => {
216
+ if (!hoverWrap) return
217
+ const related = e.relatedTarget
218
+ if (related && hoverWrap.contains(related)) return
219
+ if (related && related.closest?.('[data-todowrapid]') === hoverWrap) return
220
+
221
+ removeDepTag(hoverWrap)
222
+ hoverWrap = null
223
+ })
224
+ }
225
+
226
+ // ── Modal ──
227
+ let modalEl = null
228
+ let modalTarget = null
229
+ let allTodos = []
230
+ let selectedIds = new Set()
231
+
232
+ function escHtml(s) {
233
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
234
+ }
235
+
236
+ function ensureModal() {
237
+ if (modalEl) return modalEl
238
+ const el = document.createElement('div')
239
+ el.id = 'tdep-modal'
240
+ el.className = 'tdep-backdrop'
241
+ el.style.display = 'none'
242
+ el.innerHTML = `
243
+ <div class="tdep-card">
244
+ <div class="tdep-header">
245
+ <div class="tdep-title">${escHtml(T.modalTitle)}</div>
246
+ <div class="tdep-subtitle" id="tdep-subtitle"></div>
247
+ </div>
248
+ <div class="tdep-search">
249
+ <input type="text" id="tdep-search" placeholder="${escHtml(T.searchPlaceholder)}" spellcheck="false">
250
+ </div>
251
+ <div class="tdep-body" id="tdep-list"></div>
252
+ <div class="tdep-footer">
253
+ <span class="tdep-count" id="tdep-count"></span>
254
+ <div class="tdep-actions">
255
+ <button class="tdep-btn" id="tdep-cancel">${escHtml(T.cancel)}</button>
256
+ <button class="tdep-btn tdep-btn-primary" id="tdep-save">${escHtml(T.save)}</button>
257
+ </div>
258
+ </div>
259
+ </div>
260
+ `
261
+ document.body.appendChild(el)
262
+
263
+ el.addEventListener('click', e => { if (e.target === el) hideModal() })
264
+ el.querySelector('#tdep-cancel').addEventListener('click', hideModal)
265
+ el.querySelector('#tdep-save').addEventListener('click', handleSave)
266
+ el.querySelector('#tdep-search').addEventListener('input', e => renderList(e.target.value))
267
+ document.addEventListener('keydown', e => {
268
+ if (e.key === 'Escape' && modalEl?.style.display !== 'none') hideModal()
269
+ })
270
+
271
+ modalEl = el
272
+ return el
273
+ }
274
+
275
+ function showModal(target) {
276
+ modalTarget = target
277
+ selectedIds = new Set((target.depIds || []).map(Number))
278
+ ensureModal()
279
+
280
+ document.getElementById('tdep-subtitle').textContent = '#' + target.id + ' ' + target.content
281
+ document.getElementById('tdep-search').value = ''
282
+ renderList('')
283
+ updateCount()
284
+ modalEl.style.display = 'flex'
285
+ document.getElementById('tdep-search').focus()
286
+ }
287
+
288
+ function hideModal() {
289
+ if (modalEl) modalEl.style.display = 'none'
290
+ modalTarget = null
291
+ }
292
+
293
+ function renderList(query) {
294
+ const list = document.getElementById('tdep-list')
295
+ const q = query.toLowerCase()
296
+ const filtered = q
297
+ ? allTodos.filter(i => i.content.toLowerCase().includes(q) || String(i.id).includes(q))
298
+ : allTodos
299
+
300
+ if (!filtered.length) {
301
+ list.innerHTML = '<div class="tdep-empty">' + escHtml(T.noMatch) + '</div>'
302
+ return
303
+ }
304
+
305
+ list.innerHTML = filtered.map(i => {
306
+ const chk = selectedIds.has(i.id)
307
+ return '<label class="tdep-item' + (chk ? ' checked' : '') + '" data-id="' + i.id + '">'
308
+ + '<input type="checkbox"' + (chk ? ' checked' : '') + '>'
309
+ + '<span class="tdep-item-id">#' + i.id + '</span>'
310
+ + '<span class="tdep-item-content">' + escHtml(i.content) + '</span>'
311
+ + (i.done ? '<span class="tdep-item-done">' + escHtml(T.done) + '</span>' : '')
312
+ + '</label>'
313
+ }).join('')
314
+
315
+ list.querySelectorAll('.tdep-item').forEach(el => {
316
+ el.addEventListener('change', () => {
317
+ const id = Number(el.dataset.id)
318
+ const cb = el.querySelector('input')
319
+ if (cb.checked) { selectedIds.add(id); el.classList.add('checked') }
320
+ else { selectedIds.delete(id); el.classList.remove('checked') }
321
+ updateCount()
322
+ })
323
+ })
324
+ }
325
+
326
+ function updateCount() {
327
+ const el = document.getElementById('tdep-count')
328
+ if (el) el.textContent = T.selCount(selectedIds.size)
329
+ }
330
+
331
+ async function handleSave() {
332
+ if (!modalTarget) return
333
+ try {
334
+ await callPluginService('saveDeps', {
335
+ todoId: modalTarget.id,
336
+ depIds: [...selectedIds],
337
+ filePath: modalTarget.filePath || currentFilePath,
338
+ })
339
+ } catch (e) {}
340
+ hideModal()
341
+ }
342
+
343
+ // ── Init ──
344
+ injectStyles()
345
+ loadDepData()
346
+ initHover()
347
+ })()
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@aicupa/plugin-todo-dependency",
3
+ "version": "1.0.1",
4
+ "description": "Set and visualize dependencies between todo items",
5
+ "description_zh": "设置并可视化待办事项之间的依赖关系",
6
+ "main": "./service",
7
+ "view": "./view",
8
+ "viewjs": "./inject.js",
9
+ "viewSize": {
10
+ "width": 900,
11
+ "height": 620
12
+ },
13
+ "pluginContributes": {
14
+ "contextMenus": [
15
+ {
16
+ "title": "Set Dependencies",
17
+ "title_zh": "设置依赖",
18
+ "command": "setDependency"
19
+ }
20
+ ]
21
+ },
22
+ "publishConfig": {
23
+ "registry": "https://registry.npmjs.org/",
24
+ "access": "public"
25
+ },
26
+ "license": "MIT",
27
+ "devDependencies": {
28
+ "@aicupa/api": "^1.0.4"
29
+ }
30
+ }
package/service.js ADDED
@@ -0,0 +1,113 @@
1
+ /**
2
+ * @param {import('@aicupa/api').PluginApi} api
3
+ * @returns {import('@aicupa/api').Plugin}
4
+ */
5
+ module.exports = (api) => {
6
+ function flattenTodos(nodes, result) {
7
+ if (!Array.isArray(nodes)) return
8
+ for (const node of nodes) {
9
+ if (node.todo) {
10
+ result.push({
11
+ id: node.todo.id,
12
+ content: node.todo.content,
13
+ done: node.todo.done,
14
+ level: node.todo.level,
15
+ depIds: node.todo.depIds || [],
16
+ })
17
+ }
18
+ if (node.children?.length) flattenTodos(node.children, result)
19
+ }
20
+ }
21
+
22
+ function getTreeNodes(data) {
23
+ if (Array.isArray(data)) return data
24
+ if (data?.todotree?.tree) return data.todotree.tree
25
+ if (data?.tree) return data.tree
26
+ return []
27
+ }
28
+
29
+ return {
30
+ async setDependency({ node, filePath }) {
31
+ try {
32
+ const data = await api.getTree(filePath)
33
+ const allTodos = []
34
+ flattenTodos(getTreeNodes(data), allTodos)
35
+ const targetInTree = allTodos.find(t => t.id === node.todo.id)
36
+ return {
37
+ ok: true,
38
+ target: {
39
+ id: node.todo.id,
40
+ content: targetInTree?.content || node.todo.content,
41
+ depIds: targetInTree?.depIds || [],
42
+ filePath,
43
+ },
44
+ allTodos: allTodos.filter(t => t.id !== node.todo.id),
45
+ }
46
+ } catch (e) {
47
+ return { ok: false, error: e.message }
48
+ }
49
+ },
50
+
51
+ async saveDeps({ todoId, depIds, filePath }) {
52
+ try {
53
+ const content = await api.readFile(filePath)
54
+ const data = JSON.parse(content)
55
+ const todotree = data.todotree
56
+
57
+ function findAndUpdate(nodes) {
58
+ for (const node of nodes) {
59
+ if (node.todo && node.todo.id === todoId) {
60
+ if (depIds.length > 0) {
61
+ node.todo.depIds = depIds
62
+ } else {
63
+ delete node.todo.depIds
64
+ }
65
+ return true
66
+ }
67
+ if (node.children?.length && findAndUpdate(node.children)) return true
68
+ }
69
+ return false
70
+ }
71
+
72
+ findAndUpdate(todotree.tree)
73
+ await api.store('todotree', todotree, filePath)
74
+ await api.reload(filePath)
75
+ return { ok: true }
76
+ } catch (e) {
77
+ return { ok: false, error: e.message }
78
+ }
79
+ },
80
+
81
+ async scanAllDeps({ filePath }) {
82
+ try {
83
+ const data = await api.getTree(filePath)
84
+ const todos = []
85
+ flattenTodos(getTreeNodes(data), todos)
86
+
87
+ const edges = []
88
+ const involvedIds = new Set()
89
+ const todoMap = {}
90
+
91
+ for (const t of todos) {
92
+ todoMap[t.id] = t
93
+ if (t.depIds?.length) {
94
+ involvedIds.add(t.id)
95
+ for (const depId of t.depIds) {
96
+ involvedIds.add(depId)
97
+ edges.push([depId, t.id])
98
+ }
99
+ }
100
+ }
101
+
102
+ const involved = {}
103
+ for (const id of involvedIds) {
104
+ if (todoMap[id]) involved[id] = todoMap[id]
105
+ }
106
+
107
+ return { ok: true, result: { todos: involved, edges } }
108
+ } catch (e) {
109
+ return { ok: false, error: e.message }
110
+ }
111
+ },
112
+ }
113
+ }
@@ -0,0 +1,359 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>Todo 依赖图</title>
8
+ <style>
9
+ * { margin: 0; padding: 0; box-sizing: border-box; }
10
+ html, body { height: 100vh; overflow: hidden; }
11
+
12
+ #graphUI {
13
+ display: flex; height: 100vh;
14
+ flex-direction: column; overflow: hidden;
15
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
16
+ color: #333;
17
+ }
18
+
19
+ body.dark #graphUI { color: #e0e0e0; }
20
+
21
+ .toolbar {
22
+ padding: 10px 16px; background: #fff; border-bottom: 1px solid #e8e8e8;
23
+ display: flex; align-items: center; gap: 10px; flex-shrink: 0;
24
+ }
25
+ .dark .toolbar { background: #16213e; border-bottom-color: #2a2a4a; }
26
+ .toolbar-title { font-size: 14px; font-weight: 600; flex: 1; }
27
+
28
+ .btn {
29
+ padding: 6px 16px; border: none; border-radius: 6px;
30
+ font-size: 12px; cursor: pointer; transition: background 0.15s;
31
+ }
32
+ .btn:active { transform: scale(0.97); }
33
+ .btn-ghost { background: transparent; color: #666; }
34
+ .btn-ghost:hover { background: #f0f0f0; }
35
+ .dark .btn-ghost { color: #aaa; }
36
+ .dark .btn-ghost:hover { background: #2a2a4a; }
37
+
38
+ .warning {
39
+ background: #fffbe6; border-bottom: 1px solid #ffe58f; color: #ad6800;
40
+ padding: 6px 16px; font-size: 12px; flex-shrink: 0;
41
+ }
42
+ .dark .warning { background: #332b00; border-bottom-color: #554400; color: #ffc53d; }
43
+
44
+ #graphArea { flex: 1; overflow: auto; position: relative; background: #f7f8fa; }
45
+ .dark #graphArea { background: #1a1a2e; }
46
+ #graphContainer { position: relative; min-width: 100%; min-height: 100%; }
47
+
48
+ .node {
49
+ position: absolute; width: 200px; background: #fff;
50
+ border: 2px solid #d9d9d9; border-radius: 10px;
51
+ padding: 10px 12px; cursor: default;
52
+ box-shadow: 0 1px 4px rgba(0,0,0,0.06);
53
+ transition: box-shadow 0.2s, transform 0.15s;
54
+ }
55
+ .node:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.1); transform: translateY(-1px); }
56
+ .dark .node { background: #16213e; box-shadow: 0 1px 4px rgba(0,0,0,0.3); }
57
+ .node.done { opacity: 0.55; }
58
+ .node.done .node-content { text-decoration: line-through; }
59
+ .node.not-found { border-style: dashed; opacity: 0.65; }
60
+
61
+ .node-header { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; }
62
+ .node-id {
63
+ font-size: 10px; font-weight: 700; color: #fff;
64
+ background: #1890ff; padding: 1px 7px; border-radius: 10px;
65
+ flex-shrink: 0; letter-spacing: 0.3px;
66
+ }
67
+ .node-badge { font-size: 10px; padding: 1px 6px; border-radius: 10px; flex-shrink: 0; }
68
+ .badge-pending { background: #fff7e6; color: #d46b08; }
69
+ .badge-done { background: #f6ffed; color: #389e0d; }
70
+ .dark .badge-pending { background: #332b00; color: #ffc53d; }
71
+ .dark .badge-done { background: #0a2e0a; color: #73d13d; }
72
+
73
+ .node-content {
74
+ font-size: 12px; line-height: 1.5; word-break: break-word;
75
+ display: -webkit-box; -webkit-line-clamp: 2; line-clamp: 2;
76
+ -webkit-box-orient: vertical; overflow: hidden;
77
+ }
78
+
79
+ .level-default { border-color: #1890ff; }
80
+ .level-secondary { border-color: #bbb; }
81
+ .level-success { border-color: #52c41a; }
82
+ .level-warning { border-color: #faad14; }
83
+ .level-danger { border-color: #ff4d4f; }
84
+ .level-default .node-id { background: #1890ff; }
85
+ .level-secondary .node-id { background: #999; }
86
+ .level-success .node-id { background: #52c41a; }
87
+ .level-warning .node-id { background: #e8a100; }
88
+ .level-danger .node-id { background: #ff4d4f; }
89
+
90
+ #edgeSvg { position: absolute; top: 0; left: 0; pointer-events: none; }
91
+
92
+ .empty-state {
93
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
94
+ height: 100%; color: #aaa; padding: 40px 24px; text-align: center; user-select: none;
95
+ }
96
+ .empty-icon { width: 64px; height: 64px; margin-bottom: 16px; opacity: 0.35; }
97
+ .empty-title { font-size: 15px; font-weight: 500; margin-bottom: 10px; color: #888; }
98
+ .dark .empty-title { color: #777; }
99
+ .empty-hint { font-size: 12px; line-height: 2; color: #aaa; }
100
+ </style>
101
+ </head>
102
+
103
+ <body>
104
+ <div id="graphUI">
105
+ <div class="toolbar">
106
+ <span class="toolbar-title" data-i18n="title"></span>
107
+ <button class="btn btn-ghost" id="refreshBtn" data-i18n="refresh"></button>
108
+ </div>
109
+ <div id="warnings"></div>
110
+ <div id="graphArea">
111
+ <div id="graphContainer">
112
+ <div class="empty-state" id="emptyState">
113
+ <svg class="empty-icon" viewBox="0 0 80 80" fill="none" stroke="currentColor" stroke-width="2">
114
+ <rect x="8" y="10" width="24" height="16" rx="4"/>
115
+ <rect x="48" y="10" width="24" height="16" rx="4"/>
116
+ <rect x="28" y="50" width="24" height="16" rx="4"/>
117
+ <line x1="26" y1="26" x2="36" y2="50" stroke-dasharray="3,2"/>
118
+ <line x1="54" y1="26" x2="44" y2="50" stroke-dasharray="3,2"/>
119
+ <polygon points="36,50 33,45 39,45" fill="currentColor" stroke="none"/>
120
+ <polygon points="44,50 41,45 47,45" fill="currentColor" stroke="none"/>
121
+ </svg>
122
+ <div class="empty-title" data-i18n="emptyTitle"></div>
123
+ <div class="empty-hint" data-i18n="emptyHint"></div>
124
+ </div>
125
+ </div>
126
+ </div>
127
+ </div>
128
+
129
+ <script>
130
+ // ── i18n ──
131
+ const I18N = {
132
+ zh: {
133
+ title: '依赖图谱', refresh: '刷新',
134
+ emptyTitle: '暂无依赖关系',
135
+ emptyHint: '右键 Todo 节点,选择「设置依赖」<br>即可建立待办之间的依赖关系',
136
+ done: '已完成', pending: '待完成', notFound: '未找到该 Todo',
137
+ cycleWarning: '检测到循环依赖,部分节点的层级可能不准确',
138
+ },
139
+ en: {
140
+ title: 'Dependency Graph', refresh: 'Refresh',
141
+ emptyTitle: 'No dependencies yet',
142
+ emptyHint: 'Right-click a Todo and select "Set Dependencies"<br>to create dependency relationships',
143
+ done: 'Done', pending: 'Pending', notFound: 'Todo not found',
144
+ cycleWarning: 'Cycle detected — some nodes may be positioned incorrectly',
145
+ },
146
+ }
147
+ let t = I18N.zh
148
+ function detectLang(raw) {
149
+ t = (raw || navigator.language || 'en').toLowerCase().startsWith('zh') ? I18N.zh : I18N.en
150
+ }
151
+ function applyI18n() {
152
+ document.querySelectorAll('[data-i18n]').forEach(el => {
153
+ const v = t[el.dataset.i18n]; if (v) el.innerHTML = v
154
+ })
155
+ }
156
+
157
+ // ── Plugin communication ──
158
+ let callId = 0
159
+ const pendingCalls = {}
160
+ let currentFilePath = ''
161
+ let isDark = false
162
+
163
+ function callPlugin(method, params) {
164
+ return new Promise((resolve, reject) => {
165
+ const id = ++callId
166
+ const timer = setTimeout(() => { delete pendingCalls[id]; reject(new Error('timeout')) }, 5000)
167
+ pendingCalls[id] = {
168
+ resolve: v => { clearTimeout(timer); resolve(v) },
169
+ reject: e => { clearTimeout(timer); reject(e) },
170
+ }
171
+ window.parent.postMessage({ type: 'plugin-call', id, method, params }, '*')
172
+ })
173
+ }
174
+
175
+ function unwrap(res) {
176
+ const inner = res?.result || res
177
+ return inner?.result !== undefined ? inner.result : inner
178
+ }
179
+
180
+ window.addEventListener('message', event => {
181
+ const msg = event.data
182
+ if (!msg) return
183
+
184
+ if (msg.type === 'plugin-init') {
185
+ currentFilePath = msg.filePath || ''
186
+ detectLang(msg.lang); applyI18n()
187
+ if (msg.theme) {
188
+ document.body.style.color = msg.theme.color
189
+ document.body.style.backgroundColor = msg.theme.backgroundColor
190
+ const bg = msg.theme.backgroundColor
191
+ isDark = typeof bg === 'string' && (
192
+ bg.includes('rgb')
193
+ ? parseInt(bg.split(',')[0].replace(/\D/g, '')) < 128
194
+ : bg.startsWith('#') && parseInt(bg.slice(1, 3), 16) < 128
195
+ )
196
+ if (isDark) document.body.classList.add('dark')
197
+ }
198
+ autoScan()
199
+ }
200
+
201
+ if (msg.type === 'plugin-result' && pendingCalls[msg.id]) {
202
+ const { resolve, reject } = pendingCalls[msg.id]
203
+ delete pendingCalls[msg.id]
204
+ msg.error ? reject(new Error(msg.error)) : resolve(msg.result)
205
+ }
206
+
207
+ if (msg.type === 'plugin-tree-update') {
208
+ autoScan()
209
+ }
210
+ })
211
+
212
+ // ── Graph ──
213
+ document.getElementById('refreshBtn').addEventListener('click', autoScan)
214
+
215
+ async function autoScan() {
216
+ if (!currentFilePath) return
217
+ document.getElementById('warnings').innerHTML = ''
218
+ let data
219
+ try {
220
+ const res = await callPlugin('scanAllDeps', { filePath: currentFilePath })
221
+ data = unwrap(res)
222
+ } catch { return }
223
+ if (!data) return
224
+
225
+ const { todos, edges } = data
226
+ const nodeIds = Object.keys(todos).map(Number)
227
+ if (!nodeIds.length) { showEmpty(); return }
228
+
229
+ const { layers, hasCycle } = assignLayers(nodeIds, edges)
230
+ if (hasCycle) {
231
+ document.getElementById('warnings').innerHTML = '<div class="warning">' + t.cycleWarning + '</div>'
232
+ }
233
+ const layout = layoutGraph(nodeIds, layers)
234
+ renderGraph(nodeIds, edges, layout.positions, todos, layout)
235
+ }
236
+
237
+ function showEmpty() {
238
+ const c = document.getElementById('graphContainer')
239
+ const e = document.getElementById('emptyState')
240
+ c.innerHTML = ''; c.style.width = ''; c.style.height = ''
241
+ c.appendChild(e); e.style.display = 'flex'
242
+ }
243
+
244
+ // ── Layout algorithm ──
245
+ function assignLayers(nodeIds, edges) {
246
+ const adj = {}, inDeg = {}
247
+ for (const id of nodeIds) { adj[id] = []; inDeg[id] = 0 }
248
+ for (const [f, tt] of edges) {
249
+ if (adj[f] && inDeg[tt] !== undefined) { adj[f].push(tt); inDeg[tt]++ }
250
+ }
251
+ const layers = {}, queue = []
252
+ for (const id of nodeIds) { if (inDeg[id] === 0) { queue.push(id); layers[id] = 0 } }
253
+ let head = 0
254
+ while (head < queue.length) {
255
+ const cur = queue[head++]
256
+ for (const next of adj[cur]) {
257
+ const nl = layers[cur] + 1
258
+ if (layers[next] === undefined || nl > layers[next]) layers[next] = nl
259
+ if (--inDeg[next] === 0) queue.push(next)
260
+ }
261
+ }
262
+ const hasCycle = nodeIds.some(id => layers[id] === undefined)
263
+ if (hasCycle) {
264
+ const maxL = Math.max(0, ...Object.values(layers).filter(v => v !== undefined))
265
+ for (const id of nodeIds) { if (layers[id] === undefined) layers[id] = maxL + 1 }
266
+ }
267
+ return { layers, hasCycle }
268
+ }
269
+
270
+ const NODE_W = 200, NODE_H = 72, GAP_X = 44, GAP_Y = 70, PAD = 36
271
+ function layoutGraph(nodeIds, layers) {
272
+ if (!nodeIds.length) return { positions: {}, width: 0, height: 0 }
273
+ const maxLayer = Math.max(...Object.values(layers))
274
+ const groups = Array.from({ length: maxLayer + 1 }, () => [])
275
+ for (const id of nodeIds) groups[layers[id]].push(id)
276
+ const maxCount = Math.max(...groups.map(g => g.length))
277
+ const totalW = Math.max(maxCount * NODE_W + (maxCount - 1) * GAP_X + PAD * 2, 400)
278
+ const totalH = PAD * 2 + (maxLayer + 1) * NODE_H + maxLayer * GAP_Y
279
+ const positions = {}
280
+ for (let l = 0; l <= maxLayer; l++) {
281
+ const g = groups[l]
282
+ const gw = g.length * NODE_W + (g.length - 1) * GAP_X
283
+ const sx = (totalW - gw) / 2
284
+ for (let i = 0; i < g.length; i++) {
285
+ positions[g[i]] = { x: sx + i * (NODE_W + GAP_X), y: PAD + l * (NODE_H + GAP_Y) }
286
+ }
287
+ }
288
+ return { positions, width: totalW, height: totalH }
289
+ }
290
+
291
+ function svgEl(tag, attrs) {
292
+ const el = document.createElementNS('http://www.w3.org/2000/svg', tag)
293
+ for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v)
294
+ return el
295
+ }
296
+
297
+ function renderGraph(nodeIds, edges, positions, todoData, layout) {
298
+ const container = document.getElementById('graphContainer')
299
+ container.innerHTML = ''
300
+ container.style.width = layout.width + 'px'
301
+ container.style.height = layout.height + 'px'
302
+
303
+ const edgeColor = isDark ? '#4a4a6a' : '#c8c8d0'
304
+ const arrowColor = isDark ? '#6a6a8a' : '#aaa'
305
+ const svg = svgEl('svg', { id: 'edgeSvg', width: layout.width, height: layout.height })
306
+ const defs = svgEl('defs', {})
307
+ const marker = svgEl('marker', {
308
+ id: 'arrow', markerWidth: 10, markerHeight: 7,
309
+ refX: 9, refY: 3.5, orient: 'auto', markerUnits: 'strokeWidth',
310
+ })
311
+ marker.appendChild(svgEl('polygon', { points: '0 0.5,9 3.5,0 6.5', fill: arrowColor }))
312
+ defs.appendChild(marker); svg.appendChild(defs)
313
+
314
+ for (const [from, to] of edges) {
315
+ const p1 = positions[from], p2 = positions[to]
316
+ if (!p1 || !p2) continue
317
+ const x1 = p1.x + NODE_W / 2, y1 = p1.y + NODE_H
318
+ const x2 = p2.x + NODE_W / 2, y2 = p2.y
319
+ const cp = Math.max(Math.abs(y2 - y1) * 0.35, 20)
320
+ svg.appendChild(svgEl('path', {
321
+ d: `M${x1},${y1} C${x1},${y1 + cp} ${x2},${y2 - cp} ${x2},${y2}`,
322
+ fill: 'none', stroke: edgeColor, 'stroke-width': 2, 'marker-end': 'url(#arrow)',
323
+ }))
324
+ }
325
+ container.appendChild(svg)
326
+
327
+ for (const id of nodeIds) {
328
+ const pos = positions[id]; if (!pos) continue
329
+ const todo = todoData[id]
330
+ const el = document.createElement('div'); el.className = 'node'
331
+ if (todo) {
332
+ el.classList.add('level-' + (todo.level || 'default'))
333
+ if (todo.done) el.classList.add('done')
334
+ } else el.classList.add('not-found')
335
+ el.style.cssText = `left:${pos.x}px;top:${pos.y}px;width:${NODE_W}px;`
336
+
337
+ const header = document.createElement('div'); header.className = 'node-header'
338
+ const badge = document.createElement('span'); badge.className = 'node-id'
339
+ badge.textContent = '#' + id; header.appendChild(badge)
340
+ if (todo) {
341
+ const sb = document.createElement('span')
342
+ sb.className = 'node-badge ' + (todo.done ? 'badge-done' : 'badge-pending')
343
+ sb.textContent = todo.done ? t.done : t.pending
344
+ header.appendChild(sb)
345
+ }
346
+ el.appendChild(header)
347
+ const content = document.createElement('div'); content.className = 'node-content'
348
+ if (todo) content.textContent = todo.content
349
+ else { content.textContent = t.notFound; content.style.color = '#ff4d4f'; content.style.fontStyle = 'italic' }
350
+ el.appendChild(content); container.appendChild(el)
351
+ }
352
+ }
353
+
354
+ // ── Init ──
355
+ detectLang(); applyI18n()
356
+ </script>
357
+ </body>
358
+
359
+ </html>