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,623 @@
|
|
|
1
|
+
import { useEffect, useCallback, useState, useRef, useMemo } from 'react'
|
|
2
|
+
import { X, User, ChevronDown, Wand2, Tag, Plus, Check, CircleDot, Signal, Calendar, Trash2, FileText, Paperclip } from 'lucide-react'
|
|
3
|
+
import type { FeatureFrontmatter, Priority, FeatureStatus } from '../../shared/types'
|
|
4
|
+
import { cn } from '../lib/utils'
|
|
5
|
+
import { useStore } from '../store'
|
|
6
|
+
import { MarkdownEditor } from './MarkdownEditor'
|
|
7
|
+
|
|
8
|
+
type AIAgent = 'claude' | 'codex' | 'opencode'
|
|
9
|
+
type PermissionMode = 'default' | 'plan' | 'acceptEdits' | 'bypassPermissions'
|
|
10
|
+
|
|
11
|
+
interface FeatureEditorProps {
|
|
12
|
+
featureId: string
|
|
13
|
+
content: string
|
|
14
|
+
frontmatter: FeatureFrontmatter
|
|
15
|
+
contentVersion?: number
|
|
16
|
+
onSave: (content: string, frontmatter: FeatureFrontmatter) => void
|
|
17
|
+
onClose: () => void
|
|
18
|
+
onDelete: () => void
|
|
19
|
+
onOpenFile: () => void
|
|
20
|
+
onStartWithAI: (agent: AIAgent, permissionMode: PermissionMode) => void
|
|
21
|
+
onAddAttachment: () => void
|
|
22
|
+
onOpenAttachment: (attachment: string) => void
|
|
23
|
+
onRemoveAttachment: (attachment: string) => void
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const priorityLabels: Record<Priority, string> = {
|
|
27
|
+
critical: 'Critical',
|
|
28
|
+
high: 'High',
|
|
29
|
+
medium: 'Medium',
|
|
30
|
+
low: 'Low'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const statusLabels: Record<FeatureStatus, string> = {
|
|
34
|
+
backlog: 'Backlog',
|
|
35
|
+
todo: 'To Do',
|
|
36
|
+
'in-progress': 'In Progress',
|
|
37
|
+
review: 'Review',
|
|
38
|
+
done: 'Done'
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const priorities: Priority[] = ['critical', 'high', 'medium', 'low']
|
|
42
|
+
const statuses: FeatureStatus[] = ['backlog', 'todo', 'in-progress', 'review', 'done']
|
|
43
|
+
|
|
44
|
+
const priorityDots: Record<Priority, string> = {
|
|
45
|
+
critical: 'bg-red-500',
|
|
46
|
+
high: 'bg-orange-500',
|
|
47
|
+
medium: 'bg-yellow-500',
|
|
48
|
+
low: 'bg-green-500',
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const statusDots: Record<FeatureStatus, string> = {
|
|
52
|
+
backlog: 'bg-zinc-400',
|
|
53
|
+
todo: 'bg-blue-400',
|
|
54
|
+
'in-progress': 'bg-amber-400',
|
|
55
|
+
review: 'bg-purple-400',
|
|
56
|
+
done: 'bg-emerald-400',
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const aiAgentTabs: { agent: AIAgent; label: string; color: string; activeColor: string }[] = [
|
|
60
|
+
{ agent: 'claude', label: 'Claude', color: 'hover:bg-amber-100 dark:hover:bg-amber-900/30', activeColor: 'bg-amber-700 text-white' },
|
|
61
|
+
{ agent: 'codex', label: 'Codex', color: 'hover:bg-emerald-100 dark:hover:bg-emerald-900/30', activeColor: 'bg-emerald-500 text-white' },
|
|
62
|
+
{ agent: 'opencode', label: 'OpenCode', color: 'hover:bg-slate-100 dark:hover:bg-slate-700/30', activeColor: 'bg-slate-500 text-white' },
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
const agentButtonColors: Record<AIAgent, { bg: string; hover: string; shadow: string; border: string }> = {
|
|
66
|
+
claude: {
|
|
67
|
+
bg: 'bg-amber-700',
|
|
68
|
+
hover: 'hover:bg-amber-800',
|
|
69
|
+
shadow: 'shadow-sm',
|
|
70
|
+
border: 'border border-amber-800/50'
|
|
71
|
+
},
|
|
72
|
+
codex: {
|
|
73
|
+
bg: 'bg-emerald-600',
|
|
74
|
+
hover: 'hover:bg-emerald-700',
|
|
75
|
+
shadow: 'shadow-sm',
|
|
76
|
+
border: 'border border-emerald-700/50'
|
|
77
|
+
},
|
|
78
|
+
opencode: {
|
|
79
|
+
bg: 'bg-slate-600',
|
|
80
|
+
hover: 'hover:bg-slate-700',
|
|
81
|
+
shadow: 'shadow-sm',
|
|
82
|
+
border: 'border border-slate-700/50'
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const aiModesByAgent: Record<AIAgent, { permissionMode: PermissionMode; label: string; description: string }[]> = {
|
|
87
|
+
claude: [
|
|
88
|
+
{ permissionMode: 'default', label: 'Default', description: 'With confirmations' },
|
|
89
|
+
{ permissionMode: 'plan', label: 'Plan', description: 'Creates a plan first' },
|
|
90
|
+
{ permissionMode: 'acceptEdits', label: 'Auto-edit', description: 'Auto-accepts file edits' },
|
|
91
|
+
{ permissionMode: 'bypassPermissions', label: 'Full Auto', description: 'Bypasses all prompts' },
|
|
92
|
+
],
|
|
93
|
+
codex: [
|
|
94
|
+
{ permissionMode: 'default', label: 'Suggest', description: 'Suggests changes' },
|
|
95
|
+
{ permissionMode: 'acceptEdits', label: 'Auto-edit', description: 'Auto-accepts edits' },
|
|
96
|
+
{ permissionMode: 'bypassPermissions', label: 'Full Auto', description: 'Full automation' },
|
|
97
|
+
],
|
|
98
|
+
opencode: [
|
|
99
|
+
{ permissionMode: 'default', label: 'Default', description: 'Standard mode' },
|
|
100
|
+
],
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
interface DropdownProps {
|
|
104
|
+
value: string
|
|
105
|
+
options: { value: string; label: string; dot?: string }[]
|
|
106
|
+
onChange: (value: string) => void
|
|
107
|
+
className?: string
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function Dropdown({ value, options, onChange, className }: DropdownProps) {
|
|
111
|
+
const [isOpen, setIsOpen] = useState(false)
|
|
112
|
+
const current = options.find(o => o.value === value)
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<div className={cn('relative', className)}>
|
|
116
|
+
<button
|
|
117
|
+
onClick={() => setIsOpen(!isOpen)}
|
|
118
|
+
className="flex items-center gap-2 px-2 py-1 text-xs font-medium rounded transition-colors vscode-hover-bg"
|
|
119
|
+
style={{ color: 'var(--vscode-foreground)' }}
|
|
120
|
+
>
|
|
121
|
+
{current?.dot && <span className={cn('w-2 h-2 rounded-full shrink-0', current.dot)} />}
|
|
122
|
+
<span>{current?.label}</span>
|
|
123
|
+
<ChevronDown size={12} style={{ color: 'var(--vscode-descriptionForeground)' }} className="ml-0.5" />
|
|
124
|
+
</button>
|
|
125
|
+
{isOpen && (
|
|
126
|
+
<>
|
|
127
|
+
<div className="fixed inset-0 z-10" onClick={() => setIsOpen(false)} />
|
|
128
|
+
<div
|
|
129
|
+
className="absolute top-full left-0 mt-1 z-20 rounded-lg shadow-lg py-1 min-w-[140px]"
|
|
130
|
+
style={{
|
|
131
|
+
background: 'var(--vscode-dropdown-background)',
|
|
132
|
+
border: '1px solid var(--vscode-dropdown-border, var(--vscode-panel-border))',
|
|
133
|
+
}}
|
|
134
|
+
>
|
|
135
|
+
{options.map(option => (
|
|
136
|
+
<button
|
|
137
|
+
key={option.value}
|
|
138
|
+
onClick={() => {
|
|
139
|
+
onChange(option.value)
|
|
140
|
+
setIsOpen(false)
|
|
141
|
+
}}
|
|
142
|
+
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs transition-colors"
|
|
143
|
+
style={{
|
|
144
|
+
color: 'var(--vscode-dropdown-foreground)',
|
|
145
|
+
background: option.value === value ? 'var(--vscode-list-activeSelectionBackground)' : undefined,
|
|
146
|
+
}}
|
|
147
|
+
onMouseEnter={e => {
|
|
148
|
+
if (option.value !== value) e.currentTarget.style.background = 'var(--vscode-list-hoverBackground)'
|
|
149
|
+
}}
|
|
150
|
+
onMouseLeave={e => {
|
|
151
|
+
if (option.value !== value) e.currentTarget.style.background = 'transparent'
|
|
152
|
+
}}
|
|
153
|
+
>
|
|
154
|
+
{option.dot && <span className={cn('w-2 h-2 rounded-full shrink-0', option.dot)} />}
|
|
155
|
+
<span className="flex-1 text-left">{option.label}</span>
|
|
156
|
+
{option.value === value && <Check size={12} style={{ color: 'var(--vscode-focusBorder)' }} className="shrink-0" />}
|
|
157
|
+
</button>
|
|
158
|
+
))}
|
|
159
|
+
</div>
|
|
160
|
+
</>
|
|
161
|
+
)}
|
|
162
|
+
</div>
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function PropertyRow({ label, icon, children }: { label: string; icon: React.ReactNode; children: React.ReactNode }) {
|
|
167
|
+
return (
|
|
168
|
+
<div
|
|
169
|
+
className="flex items-center gap-3 px-4 py-[5px] transition-colors vscode-hover-bg"
|
|
170
|
+
>
|
|
171
|
+
<div className="flex items-center gap-2 w-[90px] shrink-0">
|
|
172
|
+
<span style={{ color: 'var(--vscode-descriptionForeground)' }}>{icon}</span>
|
|
173
|
+
<span className="text-[11px]" style={{ color: 'var(--vscode-descriptionForeground)' }}>{label}</span>
|
|
174
|
+
</div>
|
|
175
|
+
<div className="flex-1 min-w-0">
|
|
176
|
+
{children}
|
|
177
|
+
</div>
|
|
178
|
+
</div>
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
interface AIDropdownProps {
|
|
183
|
+
onSelect: (agent: AIAgent, permissionMode: PermissionMode) => void
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function AIDropdown({ onSelect }: AIDropdownProps) {
|
|
187
|
+
const [isOpen, setIsOpen] = useState(false)
|
|
188
|
+
const [selectedTab, setSelectedTab] = useState<AIAgent>('claude')
|
|
189
|
+
|
|
190
|
+
const modes = aiModesByAgent[selectedTab]
|
|
191
|
+
const buttonColors = agentButtonColors[selectedTab]
|
|
192
|
+
|
|
193
|
+
return (
|
|
194
|
+
<div className="relative">
|
|
195
|
+
<button
|
|
196
|
+
onClick={() => setIsOpen(!isOpen)}
|
|
197
|
+
className={cn(
|
|
198
|
+
'flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-white rounded-md transition-colors',
|
|
199
|
+
buttonColors.bg,
|
|
200
|
+
buttonColors.hover,
|
|
201
|
+
buttonColors.shadow,
|
|
202
|
+
buttonColors.border
|
|
203
|
+
)}
|
|
204
|
+
>
|
|
205
|
+
<Wand2 size={13} />
|
|
206
|
+
<span>Build with AI</span>
|
|
207
|
+
<kbd className="ml-0.5 text-[9px] opacity-60 font-mono">⌘B</kbd>
|
|
208
|
+
<ChevronDown size={11} className={cn('ml-0.5 opacity-60 transition-transform', isOpen && 'rotate-180')} />
|
|
209
|
+
</button>
|
|
210
|
+
{isOpen && (
|
|
211
|
+
<>
|
|
212
|
+
<div className="fixed inset-0 z-10" onClick={() => setIsOpen(false)} />
|
|
213
|
+
<div className="absolute top-full right-0 mt-1 z-20 bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg shadow-xl min-w-[260px] overflow-hidden">
|
|
214
|
+
{/* Tabs */}
|
|
215
|
+
<div className="flex">
|
|
216
|
+
{aiAgentTabs.map((tab) => (
|
|
217
|
+
<button
|
|
218
|
+
key={tab.agent}
|
|
219
|
+
onClick={() => setSelectedTab(tab.agent)}
|
|
220
|
+
className={cn(
|
|
221
|
+
'flex-1 px-3 py-2.5 text-xs font-medium transition-all',
|
|
222
|
+
selectedTab === tab.agent
|
|
223
|
+
? tab.activeColor
|
|
224
|
+
: cn('text-zinc-600 dark:text-zinc-400', tab.color)
|
|
225
|
+
)}
|
|
226
|
+
>
|
|
227
|
+
{tab.label}
|
|
228
|
+
</button>
|
|
229
|
+
))}
|
|
230
|
+
</div>
|
|
231
|
+
{/* Options */}
|
|
232
|
+
<div className="p-2 space-y-1">
|
|
233
|
+
{modes.map((mode) => (
|
|
234
|
+
<button
|
|
235
|
+
key={mode.permissionMode}
|
|
236
|
+
onClick={() => {
|
|
237
|
+
onSelect(selectedTab, mode.permissionMode)
|
|
238
|
+
setIsOpen(false)
|
|
239
|
+
}}
|
|
240
|
+
className="w-full text-left px-3 py-2.5 rounded-md hover:bg-zinc-100 dark:hover:bg-zinc-700/50 transition-colors"
|
|
241
|
+
>
|
|
242
|
+
<div className="text-xs font-medium text-zinc-900 dark:text-zinc-100">{mode.label}</div>
|
|
243
|
+
<div className="text-[10px] text-zinc-500 dark:text-zinc-400 mt-0.5">{mode.description}</div>
|
|
244
|
+
</button>
|
|
245
|
+
))}
|
|
246
|
+
</div>
|
|
247
|
+
</div>
|
|
248
|
+
</>
|
|
249
|
+
)}
|
|
250
|
+
</div>
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function LabelEditor({ labels, onChange }: { labels: string[]; onChange: (labels: string[]) => void }) {
|
|
255
|
+
const [newLabel, setNewLabel] = useState('')
|
|
256
|
+
const [isFocused, setIsFocused] = useState(false)
|
|
257
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
258
|
+
const features = useStore(s => s.features)
|
|
259
|
+
|
|
260
|
+
const existingLabels = useMemo(() => {
|
|
261
|
+
const labelSet = new Set<string>()
|
|
262
|
+
features.forEach(f => f.labels.forEach(l => labelSet.add(l)))
|
|
263
|
+
return Array.from(labelSet).sort()
|
|
264
|
+
}, [features])
|
|
265
|
+
|
|
266
|
+
const suggestions = useMemo(() => {
|
|
267
|
+
const available = existingLabels.filter(l => !labels.includes(l))
|
|
268
|
+
if (!newLabel.trim()) return available
|
|
269
|
+
return available.filter(l => l.toLowerCase().includes(newLabel.toLowerCase()))
|
|
270
|
+
}, [newLabel, existingLabels, labels])
|
|
271
|
+
|
|
272
|
+
const showSuggestions = isFocused && suggestions.length > 0
|
|
273
|
+
|
|
274
|
+
const addLabel = (label?: string) => {
|
|
275
|
+
const l = (label || newLabel).trim()
|
|
276
|
+
if (l && !labels.includes(l)) {
|
|
277
|
+
onChange([...labels, l])
|
|
278
|
+
}
|
|
279
|
+
setNewLabel('')
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const removeLabel = (label: string) => {
|
|
283
|
+
onChange(labels.filter(l => l !== label))
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return (
|
|
287
|
+
<div className="relative flex items-center gap-1.5 flex-wrap">
|
|
288
|
+
{labels.map(label => (
|
|
289
|
+
<span
|
|
290
|
+
key={label}
|
|
291
|
+
className="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded"
|
|
292
|
+
style={{
|
|
293
|
+
background: 'var(--vscode-badge-background)',
|
|
294
|
+
color: 'var(--vscode-badge-foreground)',
|
|
295
|
+
}}
|
|
296
|
+
>
|
|
297
|
+
{label}
|
|
298
|
+
<button
|
|
299
|
+
onClick={() => removeLabel(label)}
|
|
300
|
+
className="hover:text-red-500 transition-colors"
|
|
301
|
+
>
|
|
302
|
+
<X size={9} />
|
|
303
|
+
</button>
|
|
304
|
+
</span>
|
|
305
|
+
))}
|
|
306
|
+
<button
|
|
307
|
+
onClick={() => { setIsFocused(true); setTimeout(() => inputRef.current?.focus(), 0) }}
|
|
308
|
+
className="inline-flex items-center gap-0.5 px-1 py-0.5 text-[10px] rounded transition-colors vscode-hover-bg"
|
|
309
|
+
style={{ color: 'var(--vscode-descriptionForeground)' }}
|
|
310
|
+
>
|
|
311
|
+
<Plus size={10} />
|
|
312
|
+
</button>
|
|
313
|
+
<input
|
|
314
|
+
ref={inputRef}
|
|
315
|
+
type="text"
|
|
316
|
+
value={newLabel}
|
|
317
|
+
onChange={(e) => setNewLabel(e.target.value)}
|
|
318
|
+
onFocus={() => setIsFocused(true)}
|
|
319
|
+
onBlur={() => setTimeout(() => setIsFocused(false), 150)}
|
|
320
|
+
onKeyDown={(e) => {
|
|
321
|
+
if (e.key === 'Enter') { e.preventDefault(); addLabel() }
|
|
322
|
+
if (e.key === 'Backspace' && !newLabel && labels.length > 0) {
|
|
323
|
+
onChange(labels.slice(0, -1))
|
|
324
|
+
}
|
|
325
|
+
if (e.key === 'Escape') { setNewLabel(''); inputRef.current?.blur() }
|
|
326
|
+
}}
|
|
327
|
+
placeholder={labels.length === 0 ? 'Add labels...' : ''}
|
|
328
|
+
className="flex-1 min-w-[60px] bg-transparent border-none outline-none text-xs"
|
|
329
|
+
style={{ color: 'var(--vscode-foreground)', display: isFocused || newLabel ? 'block' : 'none' }}
|
|
330
|
+
/>
|
|
331
|
+
{showSuggestions && (
|
|
332
|
+
<div
|
|
333
|
+
className="absolute top-full left-0 mt-1 z-20 rounded-lg shadow-lg py-1 max-h-[160px] overflow-auto min-w-[180px]"
|
|
334
|
+
style={{
|
|
335
|
+
background: 'var(--vscode-dropdown-background)',
|
|
336
|
+
border: '1px solid var(--vscode-dropdown-border, var(--vscode-panel-border))',
|
|
337
|
+
}}
|
|
338
|
+
>
|
|
339
|
+
{suggestions.map(label => (
|
|
340
|
+
<button
|
|
341
|
+
key={label}
|
|
342
|
+
type="button"
|
|
343
|
+
onMouseDown={(e) => { e.preventDefault(); addLabel(label) }}
|
|
344
|
+
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs transition-colors"
|
|
345
|
+
style={{ color: 'var(--vscode-dropdown-foreground)' }}
|
|
346
|
+
onMouseEnter={e => e.currentTarget.style.background = 'var(--vscode-list-hoverBackground)'}
|
|
347
|
+
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
|
348
|
+
>
|
|
349
|
+
<span
|
|
350
|
+
className="inline-block px-1.5 py-0.5 text-[10px] font-medium rounded"
|
|
351
|
+
style={{
|
|
352
|
+
background: 'var(--vscode-badge-background)',
|
|
353
|
+
color: 'var(--vscode-badge-foreground)',
|
|
354
|
+
}}
|
|
355
|
+
>{label}</span>
|
|
356
|
+
</button>
|
|
357
|
+
))}
|
|
358
|
+
</div>
|
|
359
|
+
)}
|
|
360
|
+
</div>
|
|
361
|
+
)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export function FeatureEditor({ featureId, content, frontmatter, contentVersion, onSave, onClose, onDelete, onOpenFile, onStartWithAI, onAddAttachment, onOpenAttachment, onRemoveAttachment }: FeatureEditorProps) {
|
|
365
|
+
const { cardSettings } = useStore()
|
|
366
|
+
const [currentFrontmatter, setCurrentFrontmatter] = useState(frontmatter)
|
|
367
|
+
const [currentContent, setCurrentContent] = useState(content)
|
|
368
|
+
const [confirmingDelete, setConfirmingDelete] = useState(false)
|
|
369
|
+
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
370
|
+
const currentFrontmatterRef = useRef(currentFrontmatter)
|
|
371
|
+
const currentContentRef = useRef(currentContent)
|
|
372
|
+
currentFrontmatterRef.current = currentFrontmatter
|
|
373
|
+
currentContentRef.current = currentContent
|
|
374
|
+
|
|
375
|
+
const save = useCallback(() => {
|
|
376
|
+
onSave(currentContentRef.current, currentFrontmatterRef.current)
|
|
377
|
+
}, [onSave])
|
|
378
|
+
|
|
379
|
+
// Clean up debounce on unmount
|
|
380
|
+
useEffect(() => {
|
|
381
|
+
return () => {
|
|
382
|
+
if (debounceRef.current) clearTimeout(debounceRef.current)
|
|
383
|
+
}
|
|
384
|
+
}, [])
|
|
385
|
+
|
|
386
|
+
// Set content when a new feature is opened (keyed by featureId)
|
|
387
|
+
useEffect(() => {
|
|
388
|
+
setCurrentContent(content)
|
|
389
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
390
|
+
}, [featureId, contentVersion])
|
|
391
|
+
|
|
392
|
+
// Reset frontmatter when prop changes
|
|
393
|
+
useEffect(() => {
|
|
394
|
+
setCurrentFrontmatter(frontmatter)
|
|
395
|
+
}, [frontmatter])
|
|
396
|
+
|
|
397
|
+
const handleContentChange = useCallback((value: string) => {
|
|
398
|
+
setCurrentContent(value)
|
|
399
|
+
if (debounceRef.current) clearTimeout(debounceRef.current)
|
|
400
|
+
debounceRef.current = setTimeout(() => {
|
|
401
|
+
onSave(value, currentFrontmatterRef.current)
|
|
402
|
+
}, 800)
|
|
403
|
+
}, [onSave])
|
|
404
|
+
|
|
405
|
+
const handleFrontmatterUpdate = useCallback((updates: Partial<FeatureFrontmatter>) => {
|
|
406
|
+
setCurrentFrontmatter(prev => {
|
|
407
|
+
const next = { ...prev, ...updates }
|
|
408
|
+
// Schedule a save with the updated frontmatter
|
|
409
|
+
if (debounceRef.current) clearTimeout(debounceRef.current)
|
|
410
|
+
debounceRef.current = setTimeout(() => {
|
|
411
|
+
onSave(currentContentRef.current, next)
|
|
412
|
+
}, 800)
|
|
413
|
+
return next
|
|
414
|
+
})
|
|
415
|
+
}, [onSave])
|
|
416
|
+
|
|
417
|
+
// Keyboard shortcuts
|
|
418
|
+
useEffect(() => {
|
|
419
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
420
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
|
421
|
+
e.preventDefault()
|
|
422
|
+
// Flush any pending debounce and save immediately
|
|
423
|
+
if (debounceRef.current) clearTimeout(debounceRef.current)
|
|
424
|
+
save()
|
|
425
|
+
}
|
|
426
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'b' && cardSettings.showBuildWithAI) {
|
|
427
|
+
e.preventDefault()
|
|
428
|
+
onStartWithAI('claude', 'default')
|
|
429
|
+
}
|
|
430
|
+
if (e.key === 'Escape') {
|
|
431
|
+
// Flush any pending save before closing
|
|
432
|
+
if (debounceRef.current) {
|
|
433
|
+
clearTimeout(debounceRef.current)
|
|
434
|
+
save()
|
|
435
|
+
}
|
|
436
|
+
onClose()
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
window.addEventListener('keydown', handleKeyDown)
|
|
440
|
+
return () => window.removeEventListener('keydown', handleKeyDown)
|
|
441
|
+
}, [save, onClose, onStartWithAI, cardSettings.showBuildWithAI])
|
|
442
|
+
|
|
443
|
+
return (
|
|
444
|
+
<div
|
|
445
|
+
className="h-full flex flex-col"
|
|
446
|
+
style={{
|
|
447
|
+
background: 'var(--vscode-editor-background)',
|
|
448
|
+
borderLeft: '1px solid var(--vscode-panel-border)',
|
|
449
|
+
}}
|
|
450
|
+
>
|
|
451
|
+
{/* Header */}
|
|
452
|
+
<div
|
|
453
|
+
className="flex items-center justify-between px-4 py-3"
|
|
454
|
+
style={{ borderBottom: '1px solid var(--vscode-panel-border)' }}
|
|
455
|
+
>
|
|
456
|
+
<div className="flex items-center gap-3">
|
|
457
|
+
<span className="text-xs font-mono" style={{ color: 'var(--vscode-descriptionForeground)' }}>{featureId}</span>
|
|
458
|
+
{confirmingDelete ? (
|
|
459
|
+
<div className="flex items-center gap-1.5">
|
|
460
|
+
<span className="text-xs" style={{ color: 'var(--vscode-errorForeground)' }}>Delete?</span>
|
|
461
|
+
<button
|
|
462
|
+
onClick={() => { setConfirmingDelete(false); onDelete() }}
|
|
463
|
+
className="px-2 py-1 text-xs font-medium rounded transition-colors text-white bg-red-600 hover:bg-red-700"
|
|
464
|
+
>
|
|
465
|
+
Yes
|
|
466
|
+
</button>
|
|
467
|
+
<button
|
|
468
|
+
onClick={() => setConfirmingDelete(false)}
|
|
469
|
+
className="px-2 py-1 text-xs font-medium rounded transition-colors vscode-hover-bg"
|
|
470
|
+
style={{ color: 'var(--vscode-foreground)' }}
|
|
471
|
+
>
|
|
472
|
+
No
|
|
473
|
+
</button>
|
|
474
|
+
</div>
|
|
475
|
+
) : (
|
|
476
|
+
<>
|
|
477
|
+
<button
|
|
478
|
+
onClick={() => { onOpenFile(); onClose(); }}
|
|
479
|
+
className="p-1.5 px-2 rounded border transition-colors vscode-hover-bg flex items-center gap-1"
|
|
480
|
+
style={{ color: 'var(--vscode-descriptionForeground)', borderColor: 'var(--vscode-widget-border, var(--vscode-contrastBorder, rgba(128,128,128,0.35)))' }}
|
|
481
|
+
title="Open .md file"
|
|
482
|
+
>
|
|
483
|
+
<FileText size={16} />
|
|
484
|
+
<span className="text-xs">OPEN</span>
|
|
485
|
+
</button>
|
|
486
|
+
<button
|
|
487
|
+
onClick={() => setConfirmingDelete(true)}
|
|
488
|
+
className="p-1.5 px-2 rounded border transition-colors vscode-hover-bg flex items-center gap-1"
|
|
489
|
+
style={{ color: 'var(--vscode-descriptionForeground)', borderColor: 'var(--vscode-widget-border, var(--vscode-contrastBorder, rgba(128,128,128,0.35)))' }}
|
|
490
|
+
title="Delete ticket"
|
|
491
|
+
>
|
|
492
|
+
<Trash2 size={16} />
|
|
493
|
+
<span className="text-xs">DELETE</span>
|
|
494
|
+
</button>
|
|
495
|
+
</>
|
|
496
|
+
)}
|
|
497
|
+
</div>
|
|
498
|
+
<div className="flex items-center gap-2">
|
|
499
|
+
{cardSettings.showBuildWithAI && <AIDropdown onSelect={onStartWithAI} />}
|
|
500
|
+
<button
|
|
501
|
+
onClick={onClose}
|
|
502
|
+
className="p-1.5 rounded transition-colors vscode-hover-bg"
|
|
503
|
+
style={{ color: 'var(--vscode-descriptionForeground)' }}
|
|
504
|
+
>
|
|
505
|
+
<X size={18} />
|
|
506
|
+
</button>
|
|
507
|
+
</div>
|
|
508
|
+
</div>
|
|
509
|
+
|
|
510
|
+
{/* Metadata */}
|
|
511
|
+
<div
|
|
512
|
+
className="flex flex-col py-0.5"
|
|
513
|
+
style={{ borderBottom: '1px solid var(--vscode-panel-border)' }}
|
|
514
|
+
>
|
|
515
|
+
<PropertyRow label="Status" icon={<CircleDot size={13} />}>
|
|
516
|
+
<Dropdown
|
|
517
|
+
value={currentFrontmatter.status}
|
|
518
|
+
options={statuses.map(s => ({ value: s, label: statusLabels[s], dot: statusDots[s] }))}
|
|
519
|
+
onChange={(v) => handleFrontmatterUpdate({ status: v as FeatureStatus })}
|
|
520
|
+
/>
|
|
521
|
+
</PropertyRow>
|
|
522
|
+
{cardSettings.showPriorityBadges && (
|
|
523
|
+
<PropertyRow label="Priority" icon={<Signal size={13} />}>
|
|
524
|
+
<Dropdown
|
|
525
|
+
value={currentFrontmatter.priority}
|
|
526
|
+
options={priorities.map(p => ({ value: p, label: priorityLabels[p], dot: priorityDots[p] }))}
|
|
527
|
+
onChange={(v) => handleFrontmatterUpdate({ priority: v as Priority })}
|
|
528
|
+
/>
|
|
529
|
+
</PropertyRow>
|
|
530
|
+
)}
|
|
531
|
+
{cardSettings.showAssignee && (
|
|
532
|
+
<PropertyRow label="Assignee" icon={<User size={13} />}>
|
|
533
|
+
<div className="flex items-center gap-2">
|
|
534
|
+
{currentFrontmatter.assignee && (
|
|
535
|
+
<span
|
|
536
|
+
className="shrink-0 w-4 h-4 rounded-full flex items-center justify-center text-[8px] font-bold"
|
|
537
|
+
style={{
|
|
538
|
+
background: 'var(--vscode-badge-background)',
|
|
539
|
+
color: 'var(--vscode-badge-foreground)',
|
|
540
|
+
}}
|
|
541
|
+
>{currentFrontmatter.assignee.split(/\s+/).filter(Boolean).map(w => w[0]).join('').toUpperCase().slice(0, 2)}</span>
|
|
542
|
+
)}
|
|
543
|
+
<input
|
|
544
|
+
type="text"
|
|
545
|
+
value={currentFrontmatter.assignee || ''}
|
|
546
|
+
onChange={(e) => handleFrontmatterUpdate({ assignee: e.target.value || null })}
|
|
547
|
+
placeholder="No assignee"
|
|
548
|
+
className="bg-transparent border-none outline-none text-xs w-32"
|
|
549
|
+
style={{ color: currentFrontmatter.assignee ? 'var(--vscode-foreground)' : 'var(--vscode-descriptionForeground)' }}
|
|
550
|
+
/>
|
|
551
|
+
</div>
|
|
552
|
+
</PropertyRow>
|
|
553
|
+
)}
|
|
554
|
+
{cardSettings.showDueDate && (
|
|
555
|
+
<PropertyRow label="Due date" icon={<Calendar size={13} />}>
|
|
556
|
+
<input
|
|
557
|
+
type="date"
|
|
558
|
+
value={currentFrontmatter.dueDate || ''}
|
|
559
|
+
onChange={(e) => handleFrontmatterUpdate({ dueDate: e.target.value || null })}
|
|
560
|
+
className="bg-transparent border-none outline-none text-xs"
|
|
561
|
+
style={{ color: currentFrontmatter.dueDate ? 'var(--vscode-foreground)' : 'var(--vscode-descriptionForeground)' }}
|
|
562
|
+
/>
|
|
563
|
+
</PropertyRow>
|
|
564
|
+
)}
|
|
565
|
+
{cardSettings.showLabels && (
|
|
566
|
+
<PropertyRow label="Labels" icon={<Tag size={13} />}>
|
|
567
|
+
<LabelEditor
|
|
568
|
+
labels={currentFrontmatter.labels}
|
|
569
|
+
onChange={(labels) => handleFrontmatterUpdate({ labels })}
|
|
570
|
+
/>
|
|
571
|
+
</PropertyRow>
|
|
572
|
+
)}
|
|
573
|
+
<PropertyRow label="Attachments" icon={<Paperclip size={13} />}>
|
|
574
|
+
<div className="flex items-center gap-1.5 flex-wrap">
|
|
575
|
+
{currentFrontmatter.attachments.map(attachment => (
|
|
576
|
+
<span
|
|
577
|
+
key={attachment}
|
|
578
|
+
className="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded"
|
|
579
|
+
style={{
|
|
580
|
+
background: 'var(--vscode-badge-background)',
|
|
581
|
+
color: 'var(--vscode-badge-foreground)',
|
|
582
|
+
}}
|
|
583
|
+
>
|
|
584
|
+
<button
|
|
585
|
+
type="button"
|
|
586
|
+
onClick={() => onOpenAttachment(attachment)}
|
|
587
|
+
className="inline-flex items-center gap-1 hover:underline"
|
|
588
|
+
title={attachment}
|
|
589
|
+
>
|
|
590
|
+
<Paperclip size={9} />
|
|
591
|
+
{attachment}
|
|
592
|
+
</button>
|
|
593
|
+
<button
|
|
594
|
+
type="button"
|
|
595
|
+
onClick={() => onRemoveAttachment(attachment)}
|
|
596
|
+
className="hover:text-red-500 transition-colors"
|
|
597
|
+
>
|
|
598
|
+
<X size={9} />
|
|
599
|
+
</button>
|
|
600
|
+
</span>
|
|
601
|
+
))}
|
|
602
|
+
<button
|
|
603
|
+
type="button"
|
|
604
|
+
onClick={onAddAttachment}
|
|
605
|
+
className="inline-flex items-center gap-0.5 px-1 py-0.5 text-[10px] rounded transition-colors vscode-hover-bg"
|
|
606
|
+
style={{ color: 'var(--vscode-descriptionForeground)' }}
|
|
607
|
+
>
|
|
608
|
+
<Plus size={10} />
|
|
609
|
+
</button>
|
|
610
|
+
</div>
|
|
611
|
+
</PropertyRow>
|
|
612
|
+
</div>
|
|
613
|
+
|
|
614
|
+
{/* Editor */}
|
|
615
|
+
<MarkdownEditor
|
|
616
|
+
value={currentContent}
|
|
617
|
+
onChange={handleContentChange}
|
|
618
|
+
placeholder="Start writing..."
|
|
619
|
+
className="flex-1 min-h-0"
|
|
620
|
+
/>
|
|
621
|
+
</div>
|
|
622
|
+
)
|
|
623
|
+
}
|