kanban-lite 1.0.4
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/.editorconfig +9 -0
- package/.github/workflows/ci.yml +59 -0
- package/.github/workflows/release.yml +75 -0
- package/.prettierignore +6 -0
- package/.prettierrc.yaml +4 -0
- package/.vscode/extensions.json +3 -0
- package/.vscode/launch.json +17 -0
- package/.vscode/settings.json +21 -0
- package/.vscode/tasks.json +22 -0
- package/.vscodeignore +11 -0
- package/CHANGELOG.md +184 -0
- package/CLAUDE.md +58 -0
- package/CONTRIBUTING.md +114 -0
- package/LICENSE +22 -0
- package/README.md +482 -0
- package/SKILL.md +237 -0
- package/dist/cli.js +8716 -0
- package/dist/extension.js +8463 -0
- package/dist/mcp-server.js +1327 -0
- package/dist/standalone-webview/icons-Dx9MGYqN.js +180 -0
- package/dist/standalone-webview/icons-Dx9MGYqN.js.map +1 -0
- package/dist/standalone-webview/index.js +85 -0
- package/dist/standalone-webview/index.js.map +1 -0
- package/dist/standalone-webview/react-vendor-DkYdDBET.js +25 -0
- package/dist/standalone-webview/react-vendor-DkYdDBET.js.map +1 -0
- package/dist/standalone-webview/style.css +1 -0
- package/dist/standalone.js +7513 -0
- package/dist/webview/icons-Dx9MGYqN.js +180 -0
- package/dist/webview/icons-Dx9MGYqN.js.map +1 -0
- package/dist/webview/index.js +85 -0
- package/dist/webview/index.js.map +1 -0
- package/dist/webview/react-vendor-DkYdDBET.js +25 -0
- package/dist/webview/react-vendor-DkYdDBET.js.map +1 -0
- package/dist/webview/style.css +1 -0
- package/docs/images/board-overview.png +0 -0
- package/docs/images/editor-view.png +0 -0
- package/docs/plans/2026-02-20-kanban-json-config-design.md +74 -0
- package/docs/plans/2026-02-20-kanban-json-config.md +690 -0
- package/eslint.config.mjs +31 -0
- package/package.json +161 -0
- package/postcss.config.js +6 -0
- package/resources/icon-light.png +0 -0
- package/resources/icon-light.svg +105 -0
- package/resources/icon.png +0 -0
- package/resources/icon.svg +105 -0
- package/resources/kanban-dark.svg +21 -0
- package/resources/kanban-light.svg +21 -0
- package/resources/kanban.svg +21 -0
- package/src/cli/index.ts +846 -0
- package/src/extension/FeatureHeaderProvider.ts +370 -0
- package/src/extension/KanbanPanel.ts +973 -0
- package/src/extension/SidebarViewProvider.ts +507 -0
- package/src/extension/featureFileUtils.ts +82 -0
- package/src/extension/index.ts +234 -0
- package/src/mcp-server/index.ts +632 -0
- package/src/sdk/KanbanSDK.ts +349 -0
- package/src/sdk/__tests__/KanbanSDK.test.ts +468 -0
- package/src/sdk/__tests__/parser.test.ts +170 -0
- package/src/sdk/fileUtils.ts +76 -0
- package/src/sdk/index.ts +6 -0
- package/src/sdk/parser.ts +70 -0
- package/src/sdk/types.ts +15 -0
- package/src/shared/config.ts +113 -0
- package/src/shared/editorTypes.ts +14 -0
- package/src/shared/types.ts +120 -0
- package/src/standalone/__tests__/server.integration.test.ts +1916 -0
- package/src/standalone/__tests__/webhooks.test.ts +357 -0
- package/src/standalone/fileUtils.ts +70 -0
- package/src/standalone/index.ts +71 -0
- package/src/standalone/server.ts +1046 -0
- package/src/standalone/webhooks.ts +135 -0
- package/src/webview/App.tsx +469 -0
- package/src/webview/assets/main.css +329 -0
- package/src/webview/assets/standalone-theme.css +130 -0
- package/src/webview/components/ColumnDialog.tsx +119 -0
- package/src/webview/components/CreateFeatureDialog.tsx +524 -0
- package/src/webview/components/DatePicker.tsx +185 -0
- package/src/webview/components/FeatureCard.tsx +186 -0
- package/src/webview/components/FeatureEditor.tsx +623 -0
- package/src/webview/components/KanbanBoard.tsx +144 -0
- package/src/webview/components/KanbanColumn.tsx +159 -0
- package/src/webview/components/MarkdownEditor.tsx +291 -0
- package/src/webview/components/PrioritySelect.tsx +39 -0
- package/src/webview/components/QuickAddInput.tsx +72 -0
- package/src/webview/components/SettingsPanel.tsx +284 -0
- package/src/webview/components/Toolbar.tsx +175 -0
- package/src/webview/components/UndoToast.tsx +70 -0
- package/src/webview/index.html +12 -0
- package/src/webview/lib/utils.ts +6 -0
- package/src/webview/main.tsx +11 -0
- package/src/webview/standalone-main.tsx +13 -0
- package/src/webview/standalone-shim.ts +132 -0
- package/src/webview/standalone.html +12 -0
- package/src/webview/store/index.ts +241 -0
- package/tailwind.config.js +53 -0
- package/tsconfig.json +22 -0
- package/vite.config.ts +36 -0
- package/vite.standalone.config.ts +62 -0
- package/vitest.config.ts +15 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import * as fs from 'fs'
|
|
2
|
+
import * as path from 'path'
|
|
3
|
+
import * as http from 'http'
|
|
4
|
+
import * as https from 'https'
|
|
5
|
+
import * as crypto from 'crypto'
|
|
6
|
+
|
|
7
|
+
export interface Webhook {
|
|
8
|
+
id: string
|
|
9
|
+
url: string
|
|
10
|
+
events: string[] // e.g. ["task.created", "task.moved"] or ["*"] for all
|
|
11
|
+
secret?: string // optional HMAC-SHA256 signing key
|
|
12
|
+
active: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type WebhookEvent =
|
|
16
|
+
| 'task.created'
|
|
17
|
+
| 'task.updated'
|
|
18
|
+
| 'task.moved'
|
|
19
|
+
| 'task.deleted'
|
|
20
|
+
| 'column.created'
|
|
21
|
+
| 'column.updated'
|
|
22
|
+
| 'column.deleted'
|
|
23
|
+
|
|
24
|
+
const WEBHOOKS_FILENAME = '.kanban-webhooks.json'
|
|
25
|
+
|
|
26
|
+
function webhooksPath(workspaceRoot: string): string {
|
|
27
|
+
return path.join(workspaceRoot, WEBHOOKS_FILENAME)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function loadWebhooks(workspaceRoot: string): Webhook[] {
|
|
31
|
+
try {
|
|
32
|
+
const raw = fs.readFileSync(webhooksPath(workspaceRoot), 'utf-8')
|
|
33
|
+
const data = JSON.parse(raw)
|
|
34
|
+
return Array.isArray(data) ? data : []
|
|
35
|
+
} catch {
|
|
36
|
+
return []
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function saveWebhooks(workspaceRoot: string, webhooks: Webhook[]): void {
|
|
41
|
+
fs.writeFileSync(webhooksPath(workspaceRoot), JSON.stringify(webhooks, null, 2) + '\n', 'utf-8')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function createWebhook(
|
|
45
|
+
workspaceRoot: string,
|
|
46
|
+
config: { url: string; events: string[]; secret?: string }
|
|
47
|
+
): Webhook {
|
|
48
|
+
const webhooks = loadWebhooks(workspaceRoot)
|
|
49
|
+
const webhook: Webhook = {
|
|
50
|
+
id: 'wh_' + crypto.randomBytes(8).toString('hex'),
|
|
51
|
+
url: config.url,
|
|
52
|
+
events: config.events,
|
|
53
|
+
secret: config.secret,
|
|
54
|
+
active: true
|
|
55
|
+
}
|
|
56
|
+
webhooks.push(webhook)
|
|
57
|
+
saveWebhooks(workspaceRoot, webhooks)
|
|
58
|
+
return webhook
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function deleteWebhook(workspaceRoot: string, id: string): boolean {
|
|
62
|
+
const webhooks = loadWebhooks(workspaceRoot)
|
|
63
|
+
const filtered = webhooks.filter(w => w.id !== id)
|
|
64
|
+
if (filtered.length === webhooks.length) return false
|
|
65
|
+
saveWebhooks(workspaceRoot, filtered)
|
|
66
|
+
return true
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function fireWebhooks(workspaceRoot: string, event: WebhookEvent, data: unknown): void {
|
|
70
|
+
const webhooks = loadWebhooks(workspaceRoot)
|
|
71
|
+
const matching = webhooks.filter(
|
|
72
|
+
w => w.active && (w.events.includes('*') || w.events.includes(event))
|
|
73
|
+
)
|
|
74
|
+
if (matching.length === 0) return
|
|
75
|
+
|
|
76
|
+
const payload = JSON.stringify({
|
|
77
|
+
event,
|
|
78
|
+
timestamp: new Date().toISOString(),
|
|
79
|
+
data
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
for (const webhook of matching) {
|
|
83
|
+
deliverWebhook(webhook, event, payload).catch(err => {
|
|
84
|
+
console.error(`Webhook delivery failed for ${webhook.id} (${webhook.url}):`, err.message || err)
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function deliverWebhook(webhook: Webhook, event: string, payload: string): Promise<void> {
|
|
90
|
+
const url = new URL(webhook.url)
|
|
91
|
+
const isHttps = url.protocol === 'https:'
|
|
92
|
+
const transport = isHttps ? https : http
|
|
93
|
+
|
|
94
|
+
const headers: Record<string, string> = {
|
|
95
|
+
'Content-Type': 'application/json',
|
|
96
|
+
'Content-Length': Buffer.byteLength(payload).toString(),
|
|
97
|
+
'X-Webhook-Event': event
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (webhook.secret) {
|
|
101
|
+
const signature = crypto
|
|
102
|
+
.createHmac('sha256', webhook.secret)
|
|
103
|
+
.update(payload)
|
|
104
|
+
.digest('hex')
|
|
105
|
+
headers['X-Webhook-Signature'] = `sha256=${signature}`
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return new Promise((resolve, reject) => {
|
|
109
|
+
const req = transport.request(
|
|
110
|
+
{
|
|
111
|
+
hostname: url.hostname,
|
|
112
|
+
port: url.port || (isHttps ? 443 : 80),
|
|
113
|
+
path: url.pathname + url.search,
|
|
114
|
+
method: 'POST',
|
|
115
|
+
headers,
|
|
116
|
+
timeout: 10000
|
|
117
|
+
},
|
|
118
|
+
(res) => {
|
|
119
|
+
res.resume() // drain response
|
|
120
|
+
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
|
|
121
|
+
resolve()
|
|
122
|
+
} else {
|
|
123
|
+
reject(new Error(`HTTP ${res.statusCode}`))
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
)
|
|
127
|
+
req.on('error', reject)
|
|
128
|
+
req.on('timeout', () => {
|
|
129
|
+
req.destroy()
|
|
130
|
+
reject(new Error('Timeout'))
|
|
131
|
+
})
|
|
132
|
+
req.write(payload)
|
|
133
|
+
req.end()
|
|
134
|
+
})
|
|
135
|
+
}
|
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
import { useEffect, useState, useRef, useCallback } from 'react'
|
|
2
|
+
import { generateKeyBetween } from 'fractional-indexing'
|
|
3
|
+
import { useStore } from './store'
|
|
4
|
+
import { KanbanBoard } from './components/KanbanBoard'
|
|
5
|
+
import { CreateFeatureDialog } from './components/CreateFeatureDialog'
|
|
6
|
+
import { FeatureEditor } from './components/FeatureEditor'
|
|
7
|
+
import { Toolbar } from './components/Toolbar'
|
|
8
|
+
import { UndoToast } from './components/UndoToast'
|
|
9
|
+
import { SettingsPanel } from './components/SettingsPanel'
|
|
10
|
+
import { ColumnDialog } from './components/ColumnDialog'
|
|
11
|
+
import type { Feature, FeatureStatus, KanbanColumn, Priority, ExtensionMessage, FeatureFrontmatter, CardDisplaySettings } from '../shared/types'
|
|
12
|
+
import { getTitleFromContent } from '../shared/types'
|
|
13
|
+
|
|
14
|
+
// Declare vscode API type
|
|
15
|
+
declare const acquireVsCodeApi: () => {
|
|
16
|
+
postMessage: (message: unknown) => void
|
|
17
|
+
getState: () => unknown
|
|
18
|
+
setState: (state: unknown) => void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const vscode = acquireVsCodeApi()
|
|
22
|
+
|
|
23
|
+
function App(): React.JSX.Element {
|
|
24
|
+
|
|
25
|
+
const {
|
|
26
|
+
columns,
|
|
27
|
+
cardSettings,
|
|
28
|
+
settingsOpen,
|
|
29
|
+
setFeatures,
|
|
30
|
+
setColumns,
|
|
31
|
+
setIsDarkMode,
|
|
32
|
+
setCardSettings,
|
|
33
|
+
setSettingsOpen
|
|
34
|
+
} = useStore()
|
|
35
|
+
|
|
36
|
+
const [createFeatureOpen, setCreateFeatureOpen] = useState(false)
|
|
37
|
+
const [createFeatureStatus, setCreateFeatureStatus] = useState<FeatureStatus>('backlog')
|
|
38
|
+
|
|
39
|
+
// Column dialog state
|
|
40
|
+
const [columnDialogOpen, setColumnDialogOpen] = useState(false)
|
|
41
|
+
const [editingColumn, setEditingColumn] = useState<KanbanColumn | null>(null)
|
|
42
|
+
|
|
43
|
+
// Editor state
|
|
44
|
+
const contentVersionRef = useRef(0)
|
|
45
|
+
const [editingFeature, setEditingFeature] = useState<{
|
|
46
|
+
id: string
|
|
47
|
+
content: string
|
|
48
|
+
frontmatter: FeatureFrontmatter
|
|
49
|
+
contentVersion: number
|
|
50
|
+
} | null>(null)
|
|
51
|
+
|
|
52
|
+
// Undo delete stack
|
|
53
|
+
const [pendingDeletes, setPendingDeletes] = useState<{ id: string; feature: Feature }[]>([])
|
|
54
|
+
const pendingDeletesRef = useRef(pendingDeletes)
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
pendingDeletesRef.current = pendingDeletes
|
|
57
|
+
}, [pendingDeletes])
|
|
58
|
+
|
|
59
|
+
const nextIdRef = useRef(0)
|
|
60
|
+
|
|
61
|
+
const handleDeleteFeatureFromCard = useCallback((featureId: string) => {
|
|
62
|
+
const { features } = useStore.getState()
|
|
63
|
+
const feature = features.find(f => f.id === featureId)
|
|
64
|
+
if (!feature) return
|
|
65
|
+
|
|
66
|
+
// Optimistically remove from local state
|
|
67
|
+
setFeatures(features.filter(f => f.id !== featureId))
|
|
68
|
+
|
|
69
|
+
// Close editor if this feature is open
|
|
70
|
+
if (editingFeature?.id === featureId) {
|
|
71
|
+
setEditingFeature(null)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Push onto the undo stack
|
|
75
|
+
const id = String(nextIdRef.current++)
|
|
76
|
+
setPendingDeletes(prev => [...prev, { id, feature }])
|
|
77
|
+
}, [editingFeature, setFeatures])
|
|
78
|
+
|
|
79
|
+
const commitDelete = useCallback((entryId: string) => {
|
|
80
|
+
const entry = pendingDeletesRef.current.find(d => d.id === entryId)
|
|
81
|
+
if (!entry) return
|
|
82
|
+
vscode.postMessage({ type: 'deleteFeature', featureId: entry.feature.id })
|
|
83
|
+
setPendingDeletes(prev => prev.filter(d => d.id !== entryId))
|
|
84
|
+
}, [])
|
|
85
|
+
|
|
86
|
+
const handleUndoDelete = useCallback((entryId: string) => {
|
|
87
|
+
const entry = pendingDeletesRef.current.find(d => d.id === entryId)
|
|
88
|
+
if (!entry) return
|
|
89
|
+
// Restore the feature
|
|
90
|
+
const { features } = useStore.getState()
|
|
91
|
+
setFeatures([...features, entry.feature])
|
|
92
|
+
setPendingDeletes(prev => prev.filter(d => d.id !== entryId))
|
|
93
|
+
}, [setFeatures])
|
|
94
|
+
|
|
95
|
+
const handleUndoLatest = useCallback(() => {
|
|
96
|
+
const stack = pendingDeletesRef.current
|
|
97
|
+
if (stack.length === 0) return
|
|
98
|
+
handleUndoDelete(stack[stack.length - 1].id)
|
|
99
|
+
}, [handleUndoDelete])
|
|
100
|
+
|
|
101
|
+
// Keyboard shortcuts
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
let altPressedAlone = false
|
|
104
|
+
let altDownTimer: ReturnType<typeof setTimeout> | null = null
|
|
105
|
+
|
|
106
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
107
|
+
// Track bare ALT press to forward to VS Code menu bar
|
|
108
|
+
if (e.key === 'Alt') {
|
|
109
|
+
altPressedAlone = true
|
|
110
|
+
// If ALT is held >1s it's likely a modifier hold or window drag, not a menu toggle
|
|
111
|
+
if (altDownTimer) clearTimeout(altDownTimer)
|
|
112
|
+
altDownTimer = setTimeout(() => { altPressedAlone = false }, 1000)
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
if (e.altKey) {
|
|
116
|
+
altPressedAlone = false
|
|
117
|
+
if (altDownTimer) { clearTimeout(altDownTimer); altDownTimer = null }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Ctrl/Cmd+Z to undo delete (works even in inputs)
|
|
121
|
+
if (e.key === 'z' && (e.ctrlKey || e.metaKey) && !e.shiftKey && pendingDeletesRef.current.length > 0) {
|
|
122
|
+
e.preventDefault()
|
|
123
|
+
handleUndoLatest()
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Ignore if user is typing in an input or contentEditable (e.g. TipTap editor)
|
|
128
|
+
if (
|
|
129
|
+
e.target instanceof HTMLInputElement ||
|
|
130
|
+
e.target instanceof HTMLTextAreaElement ||
|
|
131
|
+
e.target instanceof HTMLSelectElement ||
|
|
132
|
+
(e.target instanceof HTMLElement && e.target.isContentEditable)
|
|
133
|
+
) {
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
switch (e.key) {
|
|
138
|
+
case 'n':
|
|
139
|
+
if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) {
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
e.preventDefault()
|
|
143
|
+
setCreateFeatureStatus('backlog')
|
|
144
|
+
setCreateFeatureOpen(true)
|
|
145
|
+
break
|
|
146
|
+
case 'Escape':
|
|
147
|
+
if (createFeatureOpen) {
|
|
148
|
+
setCreateFeatureOpen(false)
|
|
149
|
+
}
|
|
150
|
+
break
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const handleKeyUp = (e: KeyboardEvent) => {
|
|
155
|
+
if (e.key === 'Alt' && altPressedAlone) {
|
|
156
|
+
altPressedAlone = false
|
|
157
|
+
if (altDownTimer) { clearTimeout(altDownTimer); altDownTimer = null }
|
|
158
|
+
vscode.postMessage({ type: 'focusMenuBar' })
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Cancel ALT-alone if mouse is clicked while ALT is held (e.g. ALT+click window drag on Linux)
|
|
163
|
+
const handleMouseDown = () => { altPressedAlone = false }
|
|
164
|
+
|
|
165
|
+
window.addEventListener('keydown', handleKeyDown)
|
|
166
|
+
window.addEventListener('keyup', handleKeyUp)
|
|
167
|
+
window.addEventListener('mousedown', handleMouseDown)
|
|
168
|
+
return () => {
|
|
169
|
+
window.removeEventListener('keydown', handleKeyDown)
|
|
170
|
+
window.removeEventListener('keyup', handleKeyUp)
|
|
171
|
+
window.removeEventListener('mousedown', handleMouseDown)
|
|
172
|
+
if (altDownTimer) clearTimeout(altDownTimer)
|
|
173
|
+
}
|
|
174
|
+
}, [createFeatureOpen, handleUndoLatest])
|
|
175
|
+
|
|
176
|
+
// Listen for VSCode theme changes
|
|
177
|
+
useEffect(() => {
|
|
178
|
+
const updateTheme = () => {
|
|
179
|
+
const isDark = document.body.classList.contains('vscode-dark') ||
|
|
180
|
+
document.body.classList.contains('vscode-high-contrast')
|
|
181
|
+
setIsDarkMode(isDark)
|
|
182
|
+
if (isDark) {
|
|
183
|
+
document.documentElement.classList.add('dark')
|
|
184
|
+
} else {
|
|
185
|
+
document.documentElement.classList.remove('dark')
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
updateTheme()
|
|
190
|
+
|
|
191
|
+
// Watch for class changes on body
|
|
192
|
+
const observer = new MutationObserver(updateTheme)
|
|
193
|
+
observer.observe(document.body, { attributes: true, attributeFilter: ['class'] })
|
|
194
|
+
|
|
195
|
+
return () => observer.disconnect()
|
|
196
|
+
}, [setIsDarkMode])
|
|
197
|
+
|
|
198
|
+
// Listen for messages from extension
|
|
199
|
+
useEffect(() => {
|
|
200
|
+
const handleMessage = (event: MessageEvent<ExtensionMessage>) => {
|
|
201
|
+
const message = event.data
|
|
202
|
+
if (!message || typeof message.type !== 'string') return
|
|
203
|
+
|
|
204
|
+
switch (message.type) {
|
|
205
|
+
case 'init':
|
|
206
|
+
setFeatures(message.features)
|
|
207
|
+
setColumns(message.columns)
|
|
208
|
+
if (message.settings) {
|
|
209
|
+
if (message.settings.markdownEditorMode && editingFeature) {
|
|
210
|
+
setEditingFeature(null)
|
|
211
|
+
}
|
|
212
|
+
setCardSettings(message.settings)
|
|
213
|
+
}
|
|
214
|
+
break
|
|
215
|
+
case 'featuresUpdated':
|
|
216
|
+
setFeatures(message.features)
|
|
217
|
+
break
|
|
218
|
+
case 'triggerCreateDialog':
|
|
219
|
+
setCreateFeatureStatus('backlog')
|
|
220
|
+
setCreateFeatureOpen(true)
|
|
221
|
+
break
|
|
222
|
+
case 'showSettings':
|
|
223
|
+
setCardSettings(message.settings)
|
|
224
|
+
setSettingsOpen(true)
|
|
225
|
+
break
|
|
226
|
+
case 'featureContent': {
|
|
227
|
+
const { cardSettings } = useStore.getState()
|
|
228
|
+
if (cardSettings.markdownEditorMode) break
|
|
229
|
+
contentVersionRef.current += 1
|
|
230
|
+
setEditingFeature({
|
|
231
|
+
id: message.featureId,
|
|
232
|
+
content: message.content,
|
|
233
|
+
frontmatter: message.frontmatter,
|
|
234
|
+
contentVersion: contentVersionRef.current
|
|
235
|
+
})
|
|
236
|
+
break
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
window.addEventListener('message', handleMessage)
|
|
242
|
+
|
|
243
|
+
// Tell extension we're ready
|
|
244
|
+
vscode.postMessage({ type: 'ready' })
|
|
245
|
+
|
|
246
|
+
return () => window.removeEventListener('message', handleMessage)
|
|
247
|
+
}, [setFeatures, setColumns, setCardSettings, setSettingsOpen])
|
|
248
|
+
|
|
249
|
+
const handleFeatureClick = (feature: Feature): void => {
|
|
250
|
+
// Request feature content for inline editing
|
|
251
|
+
vscode.postMessage({
|
|
252
|
+
type: 'openFeature',
|
|
253
|
+
featureId: feature.id
|
|
254
|
+
})
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const handleSaveFeature = (content: string, frontmatter: FeatureFrontmatter): void => {
|
|
258
|
+
if (!editingFeature) return
|
|
259
|
+
vscode.postMessage({
|
|
260
|
+
type: 'saveFeatureContent',
|
|
261
|
+
featureId: editingFeature.id,
|
|
262
|
+
content,
|
|
263
|
+
frontmatter
|
|
264
|
+
})
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const handleCloseEditor = (): void => {
|
|
268
|
+
setEditingFeature(null)
|
|
269
|
+
vscode.postMessage({ type: 'closeFeature' })
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const handleDeleteFeature = (): void => {
|
|
273
|
+
if (!editingFeature) return
|
|
274
|
+
handleDeleteFeatureFromCard(editingFeature.id)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const handleOpenFile = (): void => {
|
|
278
|
+
if (!editingFeature) return
|
|
279
|
+
vscode.postMessage({ type: 'openFile', featureId: editingFeature.id })
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const handleStartWithAI = (agent: 'claude' | 'codex' | 'opencode', permissionMode: 'default' | 'plan' | 'acceptEdits' | 'bypassPermissions'): void => {
|
|
283
|
+
vscode.postMessage({ type: 'startWithAI', agent, permissionMode })
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const handleAddAttachment = (): void => {
|
|
287
|
+
if (!editingFeature) return
|
|
288
|
+
vscode.postMessage({ type: 'addAttachment', featureId: editingFeature.id })
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const handleOpenAttachment = (attachment: string): void => {
|
|
292
|
+
if (!editingFeature) return
|
|
293
|
+
vscode.postMessage({ type: 'openAttachment', featureId: editingFeature.id, attachment })
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const handleRemoveAttachment = (attachment: string): void => {
|
|
297
|
+
if (!editingFeature) return
|
|
298
|
+
vscode.postMessage({ type: 'removeAttachment', featureId: editingFeature.id, attachment })
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const handleSaveSettings = (settings: CardDisplaySettings): void => {
|
|
302
|
+
vscode.postMessage({ type: 'saveSettings', settings })
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const handleAddColumn = (): void => {
|
|
306
|
+
setEditingColumn(null)
|
|
307
|
+
setColumnDialogOpen(true)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const handleEditColumn = (columnId: string): void => {
|
|
311
|
+
const col = columns.find(c => c.id === columnId)
|
|
312
|
+
if (col) {
|
|
313
|
+
setEditingColumn(col)
|
|
314
|
+
setColumnDialogOpen(true)
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const handleRemoveColumn = (columnId: string): void => {
|
|
319
|
+
const col = columns.find(c => c.id === columnId)
|
|
320
|
+
if (!col) return
|
|
321
|
+
const featuresInColumn = useStore.getState().features.filter(f => f.status === columnId)
|
|
322
|
+
if (featuresInColumn.length > 0) {
|
|
323
|
+
// Don't remove columns that still have features
|
|
324
|
+
return
|
|
325
|
+
}
|
|
326
|
+
vscode.postMessage({ type: 'removeColumn', columnId })
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const handleSaveColumn = (data: { name: string; color: string }): void => {
|
|
330
|
+
if (editingColumn) {
|
|
331
|
+
vscode.postMessage({ type: 'editColumn', columnId: editingColumn.id, updates: data })
|
|
332
|
+
} else {
|
|
333
|
+
vscode.postMessage({ type: 'addColumn', column: data })
|
|
334
|
+
}
|
|
335
|
+
setColumnDialogOpen(false)
|
|
336
|
+
setEditingColumn(null)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const handleAddFeatureInColumn = (status: string): void => {
|
|
340
|
+
setCreateFeatureStatus(status as FeatureStatus)
|
|
341
|
+
setCreateFeatureOpen(true)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const handleCreateFeature = (data: {
|
|
345
|
+
status: FeatureStatus
|
|
346
|
+
priority: Priority
|
|
347
|
+
content: string
|
|
348
|
+
}): void => {
|
|
349
|
+
vscode.postMessage({
|
|
350
|
+
type: 'createFeature',
|
|
351
|
+
data
|
|
352
|
+
})
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const handleMoveFeature = (
|
|
356
|
+
featureId: string,
|
|
357
|
+
newStatus: string,
|
|
358
|
+
newOrder: number
|
|
359
|
+
): void => {
|
|
360
|
+
// Optimistic update: compute fractional index locally before server confirms
|
|
361
|
+
const { features } = useStore.getState()
|
|
362
|
+
const feature = features.find(f => f.id === featureId)
|
|
363
|
+
if (feature) {
|
|
364
|
+
// Get sorted target column features (excluding the moved feature)
|
|
365
|
+
const targetColumn = features
|
|
366
|
+
.filter(f => f.status === newStatus && f.id !== featureId)
|
|
367
|
+
.sort((a, b) => (a.order < b.order ? -1 : a.order > b.order ? 1 : 0))
|
|
368
|
+
|
|
369
|
+
const clampedOrder = Math.max(0, Math.min(newOrder, targetColumn.length))
|
|
370
|
+
const before = clampedOrder > 0 ? targetColumn[clampedOrder - 1].order : null
|
|
371
|
+
const after = clampedOrder < targetColumn.length ? targetColumn[clampedOrder].order : null
|
|
372
|
+
const newOrderKey = generateKeyBetween(before, after)
|
|
373
|
+
|
|
374
|
+
const updated = features.map(f =>
|
|
375
|
+
f.id === featureId
|
|
376
|
+
? { ...f, status: newStatus as FeatureStatus, order: newOrderKey }
|
|
377
|
+
: f
|
|
378
|
+
)
|
|
379
|
+
setFeatures(updated)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Tell extension to persist
|
|
383
|
+
vscode.postMessage({
|
|
384
|
+
type: 'moveFeature',
|
|
385
|
+
featureId,
|
|
386
|
+
newStatus,
|
|
387
|
+
newOrder
|
|
388
|
+
})
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Show loading if no columns yet
|
|
392
|
+
if (columns.length === 0) {
|
|
393
|
+
return (
|
|
394
|
+
<div className="h-full w-full flex items-center justify-center bg-[var(--vscode-editor-background)]">
|
|
395
|
+
<div className="text-[var(--vscode-foreground)] opacity-60">Loading...</div>
|
|
396
|
+
</div>
|
|
397
|
+
)
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return (
|
|
401
|
+
<div className="h-full w-full flex flex-col bg-[var(--vscode-editor-background)]">
|
|
402
|
+
<Toolbar onOpenSettings={() => vscode.postMessage({ type: 'openSettings' })} onAddColumn={handleAddColumn} />
|
|
403
|
+
<div className="flex-1 flex overflow-hidden">
|
|
404
|
+
<div className={editingFeature ? 'w-1/2' : 'w-full'}>
|
|
405
|
+
<KanbanBoard
|
|
406
|
+
onFeatureClick={handleFeatureClick}
|
|
407
|
+
onAddFeature={handleAddFeatureInColumn}
|
|
408
|
+
onMoveFeature={handleMoveFeature}
|
|
409
|
+
onEditColumn={handleEditColumn}
|
|
410
|
+
onRemoveColumn={handleRemoveColumn}
|
|
411
|
+
/>
|
|
412
|
+
</div>
|
|
413
|
+
{editingFeature && (
|
|
414
|
+
<div className="w-1/2">
|
|
415
|
+
<FeatureEditor
|
|
416
|
+
featureId={editingFeature.id}
|
|
417
|
+
content={editingFeature.content}
|
|
418
|
+
frontmatter={editingFeature.frontmatter}
|
|
419
|
+
contentVersion={editingFeature.contentVersion}
|
|
420
|
+
onSave={handleSaveFeature}
|
|
421
|
+
onClose={handleCloseEditor}
|
|
422
|
+
onDelete={handleDeleteFeature}
|
|
423
|
+
onOpenFile={handleOpenFile}
|
|
424
|
+
onStartWithAI={handleStartWithAI}
|
|
425
|
+
onAddAttachment={handleAddAttachment}
|
|
426
|
+
onOpenAttachment={handleOpenAttachment}
|
|
427
|
+
onRemoveAttachment={handleRemoveAttachment}
|
|
428
|
+
/>
|
|
429
|
+
</div>
|
|
430
|
+
)}
|
|
431
|
+
</div>
|
|
432
|
+
|
|
433
|
+
<CreateFeatureDialog
|
|
434
|
+
isOpen={createFeatureOpen}
|
|
435
|
+
onClose={() => setCreateFeatureOpen(false)}
|
|
436
|
+
onCreate={handleCreateFeature}
|
|
437
|
+
initialStatus={createFeatureStatus}
|
|
438
|
+
/>
|
|
439
|
+
|
|
440
|
+
<SettingsPanel
|
|
441
|
+
isOpen={settingsOpen}
|
|
442
|
+
settings={cardSettings}
|
|
443
|
+
onClose={() => setSettingsOpen(false)}
|
|
444
|
+
onSave={handleSaveSettings}
|
|
445
|
+
/>
|
|
446
|
+
|
|
447
|
+
<ColumnDialog
|
|
448
|
+
isOpen={columnDialogOpen}
|
|
449
|
+
onClose={() => { setColumnDialogOpen(false); setEditingColumn(null) }}
|
|
450
|
+
onSave={handleSaveColumn}
|
|
451
|
+
initial={editingColumn ? { name: editingColumn.name, color: editingColumn.color } : undefined}
|
|
452
|
+
title={editingColumn ? 'Edit List' : 'Add List'}
|
|
453
|
+
/>
|
|
454
|
+
|
|
455
|
+
{pendingDeletes.map((entry, i) => (
|
|
456
|
+
<UndoToast
|
|
457
|
+
key={entry.id}
|
|
458
|
+
message={`Deleted "${getTitleFromContent(entry.feature.content)}"`}
|
|
459
|
+
onUndo={() => handleUndoDelete(entry.id)}
|
|
460
|
+
onExpire={() => commitDelete(entry.id)}
|
|
461
|
+
duration={5000}
|
|
462
|
+
index={i}
|
|
463
|
+
/>
|
|
464
|
+
))}
|
|
465
|
+
</div>
|
|
466
|
+
)
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
export default App
|