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.
Files changed (99) hide show
  1. package/.editorconfig +9 -0
  2. package/.github/workflows/ci.yml +59 -0
  3. package/.github/workflows/release.yml +75 -0
  4. package/.prettierignore +6 -0
  5. package/.prettierrc.yaml +4 -0
  6. package/.vscode/extensions.json +3 -0
  7. package/.vscode/launch.json +17 -0
  8. package/.vscode/settings.json +21 -0
  9. package/.vscode/tasks.json +22 -0
  10. package/.vscodeignore +11 -0
  11. package/CHANGELOG.md +184 -0
  12. package/CLAUDE.md +58 -0
  13. package/CONTRIBUTING.md +114 -0
  14. package/LICENSE +22 -0
  15. package/README.md +482 -0
  16. package/SKILL.md +237 -0
  17. package/dist/cli.js +8716 -0
  18. package/dist/extension.js +8463 -0
  19. package/dist/mcp-server.js +1327 -0
  20. package/dist/standalone-webview/icons-Dx9MGYqN.js +180 -0
  21. package/dist/standalone-webview/icons-Dx9MGYqN.js.map +1 -0
  22. package/dist/standalone-webview/index.js +85 -0
  23. package/dist/standalone-webview/index.js.map +1 -0
  24. package/dist/standalone-webview/react-vendor-DkYdDBET.js +25 -0
  25. package/dist/standalone-webview/react-vendor-DkYdDBET.js.map +1 -0
  26. package/dist/standalone-webview/style.css +1 -0
  27. package/dist/standalone.js +7513 -0
  28. package/dist/webview/icons-Dx9MGYqN.js +180 -0
  29. package/dist/webview/icons-Dx9MGYqN.js.map +1 -0
  30. package/dist/webview/index.js +85 -0
  31. package/dist/webview/index.js.map +1 -0
  32. package/dist/webview/react-vendor-DkYdDBET.js +25 -0
  33. package/dist/webview/react-vendor-DkYdDBET.js.map +1 -0
  34. package/dist/webview/style.css +1 -0
  35. package/docs/images/board-overview.png +0 -0
  36. package/docs/images/editor-view.png +0 -0
  37. package/docs/plans/2026-02-20-kanban-json-config-design.md +74 -0
  38. package/docs/plans/2026-02-20-kanban-json-config.md +690 -0
  39. package/eslint.config.mjs +31 -0
  40. package/package.json +161 -0
  41. package/postcss.config.js +6 -0
  42. package/resources/icon-light.png +0 -0
  43. package/resources/icon-light.svg +105 -0
  44. package/resources/icon.png +0 -0
  45. package/resources/icon.svg +105 -0
  46. package/resources/kanban-dark.svg +21 -0
  47. package/resources/kanban-light.svg +21 -0
  48. package/resources/kanban.svg +21 -0
  49. package/src/cli/index.ts +846 -0
  50. package/src/extension/FeatureHeaderProvider.ts +370 -0
  51. package/src/extension/KanbanPanel.ts +973 -0
  52. package/src/extension/SidebarViewProvider.ts +507 -0
  53. package/src/extension/featureFileUtils.ts +82 -0
  54. package/src/extension/index.ts +234 -0
  55. package/src/mcp-server/index.ts +632 -0
  56. package/src/sdk/KanbanSDK.ts +349 -0
  57. package/src/sdk/__tests__/KanbanSDK.test.ts +468 -0
  58. package/src/sdk/__tests__/parser.test.ts +170 -0
  59. package/src/sdk/fileUtils.ts +76 -0
  60. package/src/sdk/index.ts +6 -0
  61. package/src/sdk/parser.ts +70 -0
  62. package/src/sdk/types.ts +15 -0
  63. package/src/shared/config.ts +113 -0
  64. package/src/shared/editorTypes.ts +14 -0
  65. package/src/shared/types.ts +120 -0
  66. package/src/standalone/__tests__/server.integration.test.ts +1916 -0
  67. package/src/standalone/__tests__/webhooks.test.ts +357 -0
  68. package/src/standalone/fileUtils.ts +70 -0
  69. package/src/standalone/index.ts +71 -0
  70. package/src/standalone/server.ts +1046 -0
  71. package/src/standalone/webhooks.ts +135 -0
  72. package/src/webview/App.tsx +469 -0
  73. package/src/webview/assets/main.css +329 -0
  74. package/src/webview/assets/standalone-theme.css +130 -0
  75. package/src/webview/components/ColumnDialog.tsx +119 -0
  76. package/src/webview/components/CreateFeatureDialog.tsx +524 -0
  77. package/src/webview/components/DatePicker.tsx +185 -0
  78. package/src/webview/components/FeatureCard.tsx +186 -0
  79. package/src/webview/components/FeatureEditor.tsx +623 -0
  80. package/src/webview/components/KanbanBoard.tsx +144 -0
  81. package/src/webview/components/KanbanColumn.tsx +159 -0
  82. package/src/webview/components/MarkdownEditor.tsx +291 -0
  83. package/src/webview/components/PrioritySelect.tsx +39 -0
  84. package/src/webview/components/QuickAddInput.tsx +72 -0
  85. package/src/webview/components/SettingsPanel.tsx +284 -0
  86. package/src/webview/components/Toolbar.tsx +175 -0
  87. package/src/webview/components/UndoToast.tsx +70 -0
  88. package/src/webview/index.html +12 -0
  89. package/src/webview/lib/utils.ts +6 -0
  90. package/src/webview/main.tsx +11 -0
  91. package/src/webview/standalone-main.tsx +13 -0
  92. package/src/webview/standalone-shim.ts +132 -0
  93. package/src/webview/standalone.html +12 -0
  94. package/src/webview/store/index.ts +241 -0
  95. package/tailwind.config.js +53 -0
  96. package/tsconfig.json +22 -0
  97. package/vite.config.ts +36 -0
  98. package/vite.standalone.config.ts +62 -0
  99. package/vitest.config.ts +15 -0
@@ -0,0 +1,144 @@
1
+ import { useState, useCallback } from 'react'
2
+ import { KanbanColumn } from './KanbanColumn'
3
+ import { useStore } from '../store'
4
+ import type { Feature, FeatureStatus } from '../../shared/types'
5
+
6
+ export interface DropTarget {
7
+ columnId: string
8
+ index: number
9
+ }
10
+
11
+ interface KanbanBoardProps {
12
+ onFeatureClick: (feature: Feature) => void
13
+ onAddFeature: (status: string) => void
14
+ onMoveFeature: (featureId: string, newStatus: string, newOrder: number) => void
15
+ onEditColumn: (columnId: string) => void
16
+ onRemoveColumn: (columnId: string) => void
17
+ }
18
+
19
+ export function KanbanBoard({ onFeatureClick, onAddFeature, onMoveFeature, onEditColumn, onRemoveColumn }: KanbanBoardProps) {
20
+ const columns = useStore((s) => s.columns)
21
+ const getFilteredFeaturesByStatus = useStore((s) => s.getFilteredFeaturesByStatus)
22
+ const getFeaturesByStatus = useStore((s) => s.getFeaturesByStatus)
23
+ const layout = useStore((s) => s.layout)
24
+ const [draggedFeature, setDraggedFeature] = useState<Feature | null>(null)
25
+ const [dropTarget, setDropTarget] = useState<DropTarget | null>(null)
26
+
27
+ const handleDragStart = useCallback((e: React.DragEvent, feature: Feature) => {
28
+ setDraggedFeature(feature)
29
+ e.dataTransfer.effectAllowed = 'move'
30
+ e.dataTransfer.setData('text/plain', feature.id)
31
+ }, [])
32
+
33
+ const handleDragOver = useCallback((e: React.DragEvent) => {
34
+ e.preventDefault()
35
+ e.dataTransfer.dropEffect = 'move'
36
+ }, [])
37
+
38
+ const handleDragOverCard = useCallback(
39
+ (e: React.DragEvent, columnId: string, cardIndex: number) => {
40
+ e.preventDefault()
41
+ e.dataTransfer.dropEffect = 'move'
42
+
43
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
44
+ const midY = rect.top + rect.height / 2
45
+ const insertIndex = e.clientY < midY ? cardIndex : cardIndex + 1
46
+
47
+ setDropTarget((prev) => {
48
+ if (prev && prev.columnId === columnId && prev.index === insertIndex) return prev
49
+ return { columnId, index: insertIndex }
50
+ })
51
+ },
52
+ []
53
+ )
54
+
55
+ const handleDrop = useCallback(
56
+ (e: React.DragEvent, columnId: string) => {
57
+ e.preventDefault()
58
+ if (!draggedFeature) return
59
+
60
+ const filteredFeatures = getFilteredFeaturesByStatus(columnId as FeatureStatus)
61
+ let filteredInsertIndex: number
62
+
63
+ if (dropTarget && dropTarget.columnId === columnId) {
64
+ filteredInsertIndex = dropTarget.index
65
+ } else {
66
+ // Dropped on empty area of the column — append to end
67
+ filteredInsertIndex = filteredFeatures.length
68
+ }
69
+
70
+ // Adjust index if dragging within the same column and moving downward
71
+ if (draggedFeature.status === columnId) {
72
+ const currentIndex = filteredFeatures.findIndex((f) => f.id === draggedFeature.id)
73
+ if (currentIndex !== -1 && filteredInsertIndex > currentIndex) {
74
+ filteredInsertIndex--
75
+ }
76
+ // No-op if dropping in the same position
77
+ if (currentIndex === filteredInsertIndex) {
78
+ setDraggedFeature(null)
79
+ setDropTarget(null)
80
+ return
81
+ }
82
+ }
83
+
84
+ // Translate filtered index to unfiltered index
85
+ const allFeatures = getFeaturesByStatus(columnId as FeatureStatus)
86
+ .filter((f) => f.id !== draggedFeature.id)
87
+ const filteredWithoutDragged = filteredFeatures.filter((f) => f.id !== draggedFeature.id)
88
+
89
+ let unfilteredInsertIndex: number
90
+
91
+ if (filteredWithoutDragged.length === 0) {
92
+ // No visible features — append to end of unfiltered list
93
+ unfilteredInsertIndex = allFeatures.length
94
+ } else if (filteredInsertIndex >= filteredWithoutDragged.length) {
95
+ // Inserting past end of filtered list — place after last visible feature
96
+ const lastVisible = filteredWithoutDragged[filteredWithoutDragged.length - 1]
97
+ const lastVisibleUnfilteredIdx = allFeatures.findIndex((f) => f.id === lastVisible.id)
98
+ unfilteredInsertIndex = lastVisibleUnfilteredIdx + 1
99
+ } else {
100
+ // Find the anchor feature at the filtered insert position
101
+ const anchorFeature = filteredWithoutDragged[filteredInsertIndex]
102
+ unfilteredInsertIndex = allFeatures.findIndex((f) => f.id === anchorFeature.id)
103
+ }
104
+
105
+ onMoveFeature(draggedFeature.id, columnId, unfilteredInsertIndex)
106
+ setDraggedFeature(null)
107
+ setDropTarget(null)
108
+ },
109
+ [draggedFeature, dropTarget, getFilteredFeaturesByStatus, getFeaturesByStatus, onMoveFeature]
110
+ )
111
+
112
+ const handleDragEnd = useCallback(() => {
113
+ setDraggedFeature(null)
114
+ setDropTarget(null)
115
+ }, [])
116
+
117
+ const isVertical = layout === 'vertical'
118
+
119
+ return (
120
+ <div className={isVertical ? "h-full overflow-y-auto p-4" : "h-full overflow-x-auto p-4"}>
121
+ <div className={isVertical ? "flex flex-col gap-4" : "flex gap-4 h-full min-w-max"}>
122
+ {columns.map((column) => (
123
+ <KanbanColumn
124
+ key={column.id}
125
+ column={column}
126
+ features={getFilteredFeaturesByStatus(column.id as FeatureStatus)}
127
+ onFeatureClick={onFeatureClick}
128
+ onAddFeature={onAddFeature}
129
+ onEditColumn={onEditColumn}
130
+ onRemoveColumn={onRemoveColumn}
131
+ onDragStart={handleDragStart}
132
+ onDragOver={handleDragOver}
133
+ onDragOverCard={handleDragOverCard}
134
+ onDrop={handleDrop}
135
+ onDragEnd={handleDragEnd}
136
+ draggedFeature={draggedFeature}
137
+ dropTarget={dropTarget}
138
+ layout={layout}
139
+ />
140
+ ))}
141
+ </div>
142
+ </div>
143
+ )
144
+ }
@@ -0,0 +1,159 @@
1
+ import { useState, useRef, useEffect } from 'react'
2
+ import { Plus, MoreVertical, Pencil, Trash2 } from 'lucide-react'
3
+ import { FeatureCard } from './FeatureCard'
4
+ import type { Feature, KanbanColumn as KanbanColumnType } from '../../shared/types'
5
+ import type { LayoutMode } from '../store'
6
+ import type { DropTarget } from './KanbanBoard'
7
+
8
+ interface KanbanColumnProps {
9
+ column: KanbanColumnType
10
+ features: Feature[]
11
+ onFeatureClick: (feature: Feature) => void
12
+ onAddFeature: (status: string) => void
13
+ onEditColumn: (columnId: string) => void
14
+ onRemoveColumn: (columnId: string) => void
15
+ onDragStart: (e: React.DragEvent, feature: Feature) => void
16
+ onDragOver: (e: React.DragEvent) => void
17
+ onDragOverCard: (e: React.DragEvent, columnId: string, cardIndex: number) => void
18
+ onDrop: (e: React.DragEvent, status: string) => void
19
+ onDragEnd: () => void
20
+ draggedFeature: Feature | null
21
+ dropTarget: DropTarget | null
22
+ layout: LayoutMode
23
+ }
24
+
25
+ export function KanbanColumn({
26
+ column,
27
+ features,
28
+ onFeatureClick,
29
+ onAddFeature,
30
+ onEditColumn,
31
+ onRemoveColumn,
32
+ onDragStart,
33
+ onDragOver,
34
+ onDragOverCard,
35
+ onDrop,
36
+ onDragEnd,
37
+ draggedFeature,
38
+ dropTarget,
39
+ layout
40
+ }: KanbanColumnProps) {
41
+ const isVertical = layout === 'vertical'
42
+ const isDropTarget = dropTarget && dropTarget.columnId === column.id
43
+ const [menuOpen, setMenuOpen] = useState(false)
44
+ const menuRef = useRef<HTMLDivElement>(null)
45
+
46
+ useEffect(() => {
47
+ if (!menuOpen) return
48
+ const handleClickOutside = (e: MouseEvent) => {
49
+ if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
50
+ setMenuOpen(false)
51
+ }
52
+ }
53
+ document.addEventListener('mousedown', handleClickOutside)
54
+ return () => document.removeEventListener('mousedown', handleClickOutside)
55
+ }, [menuOpen])
56
+
57
+ return (
58
+ <div
59
+ className={
60
+ isVertical
61
+ ? "flex flex-col bg-zinc-100 dark:bg-zinc-800/50 rounded-lg"
62
+ : "flex-shrink-0 w-72 h-full flex flex-col bg-zinc-100 dark:bg-zinc-800/50 rounded-lg"
63
+ }
64
+ onDragOver={onDragOver}
65
+ onDrop={(e) => onDrop(e, column.id)}
66
+ >
67
+ {/* Column Header */}
68
+ <div className="flex items-center justify-between w-full px-3 py-2 border-b border-zinc-200 dark:border-zinc-700">
69
+ <div className="flex items-center gap-2">
70
+ <div className="w-2 h-2 rounded-full" style={{ backgroundColor: column.color }} />
71
+ <h3 className="text-sm font-medium text-zinc-900 dark:text-zinc-100">{column.name}</h3>
72
+ <span className="text-xs text-zinc-500 dark:text-zinc-400 bg-zinc-200 dark:bg-zinc-700 px-1.5 py-0.5 rounded-full">
73
+ {features.length}
74
+ </span>
75
+ </div>
76
+ <div className="flex items-center gap-0.5">
77
+ <button
78
+ type="button"
79
+ onClick={() => onAddFeature(column.id)}
80
+ className="p-1 rounded hover:bg-zinc-200/60 dark:hover:bg-zinc-700/60 transition-colors"
81
+ title={`Add to ${column.name}`}
82
+ >
83
+ <Plus size={16} className="text-zinc-500" />
84
+ </button>
85
+ <div className="relative" ref={menuRef}>
86
+ <button
87
+ type="button"
88
+ onClick={() => setMenuOpen(!menuOpen)}
89
+ className="p-1 rounded hover:bg-zinc-200/60 dark:hover:bg-zinc-700/60 transition-colors"
90
+ title="Column options"
91
+ >
92
+ <MoreVertical size={16} className="text-zinc-500" />
93
+ </button>
94
+ {menuOpen && (
95
+ <div className="absolute right-0 top-full mt-1 z-50 bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-600 rounded-md shadow-lg py-1 min-w-[140px]">
96
+ <button
97
+ type="button"
98
+ onClick={() => { setMenuOpen(false); onEditColumn(column.id) }}
99
+ className="flex items-center gap-2 w-full px-3 py-1.5 text-sm text-zinc-700 dark:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-zinc-700 transition-colors"
100
+ >
101
+ <Pencil size={14} />
102
+ Edit List
103
+ </button>
104
+ <button
105
+ type="button"
106
+ onClick={() => { setMenuOpen(false); onRemoveColumn(column.id) }}
107
+ className="flex items-center gap-2 w-full px-3 py-1.5 text-sm text-red-600 dark:text-red-400 hover:bg-zinc-100 dark:hover:bg-zinc-700 transition-colors"
108
+ >
109
+ <Trash2 size={14} />
110
+ Remove List
111
+ </button>
112
+ </div>
113
+ )}
114
+ </div>
115
+ </div>
116
+ </div>
117
+
118
+ {/* Column Content */}
119
+ <div
120
+ className={
121
+ isVertical
122
+ ? "flex-1 p-2 flex flex-wrap gap-2"
123
+ : "flex-1 overflow-y-auto p-2 space-y-2 min-h-[200px]"
124
+ }
125
+ >
126
+ {features.map((feature, index) => (
127
+ <div key={feature.id}>
128
+ {/* Drop indicator before this card */}
129
+ {isDropTarget && dropTarget.index === index && (
130
+ <div className="h-0.5 bg-blue-500 rounded-full mx-1 mb-1" />
131
+ )}
132
+ <div
133
+ draggable
134
+ onDragStart={(e) => onDragStart(e, feature)}
135
+ onDragOver={(e) => onDragOverCard(e, column.id, index)}
136
+ onDragEnd={onDragEnd}
137
+ className={`${isVertical ? "w-64" : ""} ${
138
+ draggedFeature?.id === feature.id ? "opacity-40" : ""
139
+ }`}
140
+ >
141
+ <FeatureCard feature={feature} onClick={() => onFeatureClick(feature)} />
142
+ </div>
143
+ </div>
144
+ ))}
145
+
146
+ {/* Drop indicator at end of list */}
147
+ {isDropTarget && dropTarget.index === features.length && features.length > 0 && (
148
+ <div className="h-0.5 bg-blue-500 rounded-full mx-1" />
149
+ )}
150
+
151
+ {features.length === 0 && (
152
+ <div className={isVertical ? "text-sm text-zinc-400 dark:text-zinc-500 py-4" : "text-center py-8 text-sm text-zinc-400 dark:text-zinc-500"}>
153
+ No features
154
+ </div>
155
+ )}
156
+ </div>
157
+ </div>
158
+ )
159
+ }
@@ -0,0 +1,291 @@
1
+ import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
2
+ import { marked } from 'marked'
3
+ import { Heading, Bold, Italic, Quote, Code, Link, List, ListOrdered, ListChecks } from 'lucide-react'
4
+ import { cn } from '../lib/utils'
5
+
6
+ interface MarkdownEditorProps {
7
+ value: string
8
+ onChange: (value: string) => void
9
+ placeholder?: string
10
+ className?: string
11
+ autoFocus?: boolean
12
+ }
13
+
14
+ type FormatAction = 'heading' | 'bold' | 'italic' | 'quote' | 'code' | 'link' | 'ul' | 'ol' | 'tasklist'
15
+
16
+ function wrapSelection(
17
+ textarea: HTMLTextAreaElement,
18
+ value: string,
19
+ onChange: (v: string) => void,
20
+ action: FormatAction
21
+ ) {
22
+ const start = textarea.selectionStart
23
+ const end = textarea.selectionEnd
24
+ const selected = value.substring(start, end)
25
+ let before = value.substring(0, start)
26
+ const after = value.substring(end)
27
+ let replacement = selected
28
+ let cursorOffset = 0
29
+
30
+ switch (action) {
31
+ case 'heading': {
32
+ // Insert/cycle heading at line start
33
+ const lineStart = value.lastIndexOf('\n', start - 1) + 1
34
+ const linePrefix = value.substring(lineStart, start)
35
+ if (linePrefix.startsWith('### ')) {
36
+ // Remove heading
37
+ before = value.substring(0, lineStart) + linePrefix.slice(4)
38
+ replacement = selected
39
+ cursorOffset = -4
40
+ } else if (linePrefix.startsWith('## ')) {
41
+ before = value.substring(0, lineStart) + '### ' + linePrefix.slice(3)
42
+ replacement = selected
43
+ cursorOffset = 1
44
+ } else if (linePrefix.startsWith('# ')) {
45
+ before = value.substring(0, lineStart) + '## ' + linePrefix.slice(2)
46
+ replacement = selected
47
+ cursorOffset = 1
48
+ } else {
49
+ before = value.substring(0, lineStart) + '# ' + linePrefix
50
+ replacement = selected
51
+ cursorOffset = 2
52
+ }
53
+ break
54
+ }
55
+ case 'bold':
56
+ replacement = selected ? `**${selected}**` : '**bold**'
57
+ cursorOffset = selected ? 4 : 2
58
+ break
59
+ case 'italic':
60
+ replacement = selected ? `_${selected}_` : '_italic_'
61
+ cursorOffset = selected ? 2 : 1
62
+ break
63
+ case 'quote': {
64
+ const lines = selected ? selected.split('\n').map(l => `> ${l}`).join('\n') : '> '
65
+ replacement = lines
66
+ cursorOffset = selected ? replacement.length - selected.length : 2
67
+ break
68
+ }
69
+ case 'code':
70
+ if (selected.includes('\n')) {
71
+ replacement = `\`\`\`\n${selected}\n\`\`\``
72
+ cursorOffset = 4
73
+ } else {
74
+ replacement = selected ? `\`${selected}\`` : '`code`'
75
+ cursorOffset = selected ? 2 : 1
76
+ }
77
+ break
78
+ case 'link':
79
+ replacement = selected ? `[${selected}](url)` : '[text](url)'
80
+ cursorOffset = selected ? selected.length + 3 : 1
81
+ break
82
+ case 'ul': {
83
+ const ulLines = selected ? selected.split('\n').map(l => `- ${l}`).join('\n') : '- '
84
+ replacement = ulLines
85
+ cursorOffset = selected ? replacement.length - selected.length : 2
86
+ break
87
+ }
88
+ case 'ol': {
89
+ const olLines = selected ? selected.split('\n').map((l, i) => `${i + 1}. ${l}`).join('\n') : '1. '
90
+ replacement = olLines
91
+ cursorOffset = selected ? replacement.length - selected.length : 3
92
+ break
93
+ }
94
+ case 'tasklist': {
95
+ const tlLines = selected ? selected.split('\n').map(l => `- [ ] ${l}`).join('\n') : '- [ ] '
96
+ replacement = tlLines
97
+ cursorOffset = selected ? replacement.length - selected.length : 6
98
+ break
99
+ }
100
+ }
101
+
102
+ const newValue = before + replacement + after
103
+ onChange(newValue)
104
+ requestAnimationFrame(() => {
105
+ textarea.focus()
106
+ const newPos = start + (selected ? replacement.length : cursorOffset)
107
+ textarea.selectionStart = textarea.selectionEnd = newPos
108
+ })
109
+ }
110
+
111
+ interface ToolbarButtonProps {
112
+ icon: React.ReactNode
113
+ title: string
114
+ onClick: () => void
115
+ separator?: boolean
116
+ }
117
+
118
+ function ToolbarButton({ icon, title, onClick, separator }: ToolbarButtonProps) {
119
+ return (
120
+ <>
121
+ {separator && (
122
+ <div
123
+ className="w-px h-4 mx-1"
124
+ style={{ background: 'var(--vscode-panel-border)' }}
125
+ />
126
+ )}
127
+ <button
128
+ type="button"
129
+ onClick={onClick}
130
+ title={title}
131
+ className="p-1 rounded transition-colors"
132
+ style={{ color: 'var(--vscode-descriptionForeground)' }}
133
+ onMouseEnter={e => e.currentTarget.style.background = 'var(--vscode-list-hoverBackground)'}
134
+ onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
135
+ >
136
+ {icon}
137
+ </button>
138
+ </>
139
+ )
140
+ }
141
+
142
+ export function MarkdownEditor({ value, onChange, placeholder = 'Write markdown...', className, autoFocus }: MarkdownEditorProps) {
143
+ const [activeTab, setActiveTab] = useState<'write' | 'preview'>('write')
144
+ const textareaRef = useRef<HTMLTextAreaElement>(null)
145
+
146
+ const previewHtml = useMemo(() => {
147
+ if (!value.trim()) return ''
148
+ return marked.parse(value, { async: false, gfm: true, breaks: true }) as string
149
+ }, [value])
150
+
151
+ // Auto-focus textarea when switching to write tab
152
+ useEffect(() => {
153
+ if (activeTab === 'write' && textareaRef.current) {
154
+ textareaRef.current.focus()
155
+ }
156
+ }, [activeTab])
157
+
158
+ // Initial auto-focus
159
+ useEffect(() => {
160
+ if (autoFocus && textareaRef.current) {
161
+ textareaRef.current.focus()
162
+ }
163
+ }, [autoFocus])
164
+
165
+ const handleFormat = useCallback((action: FormatAction) => {
166
+ if (textareaRef.current) {
167
+ wrapSelection(textareaRef.current, value, onChange, action)
168
+ }
169
+ }, [value, onChange])
170
+
171
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
172
+ // Tab key inserts spaces instead of changing focus
173
+ if (e.key === 'Tab') {
174
+ e.preventDefault()
175
+ const textarea = e.currentTarget
176
+ const start = textarea.selectionStart
177
+ const end = textarea.selectionEnd
178
+ const newValue = value.substring(0, start) + ' ' + value.substring(end)
179
+ onChange(newValue)
180
+ requestAnimationFrame(() => {
181
+ textarea.selectionStart = textarea.selectionEnd = start + 2
182
+ })
183
+ }
184
+ // Ctrl/Cmd+B for bold
185
+ if ((e.metaKey || e.ctrlKey) && e.key === 'b') {
186
+ e.preventDefault()
187
+ e.stopPropagation()
188
+ handleFormat('bold')
189
+ }
190
+ // Ctrl/Cmd+I for italic
191
+ if ((e.metaKey || e.ctrlKey) && e.key === 'i') {
192
+ e.preventDefault()
193
+ handleFormat('italic')
194
+ }
195
+ // Ctrl/Cmd+K for link
196
+ if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
197
+ e.preventDefault()
198
+ handleFormat('link')
199
+ }
200
+ }
201
+
202
+ return (
203
+ <div className={cn('flex flex-col h-full', className)}>
204
+ {/* Tab bar + toolbar */}
205
+ <div
206
+ className="flex items-center shrink-0"
207
+ style={{ borderBottom: '1px solid var(--vscode-panel-border)' }}
208
+ >
209
+ {/* Tabs */}
210
+ <button
211
+ type="button"
212
+ onClick={() => setActiveTab('write')}
213
+ className="px-3 py-2 text-xs font-medium transition-colors relative"
214
+ style={{
215
+ color: activeTab === 'write' ? 'var(--vscode-foreground)' : 'var(--vscode-descriptionForeground)',
216
+ }}
217
+ >
218
+ Write
219
+ {activeTab === 'write' && (
220
+ <span
221
+ className="absolute bottom-0 left-0 right-0 h-[2px] rounded-t"
222
+ style={{ background: 'var(--vscode-focusBorder)' }}
223
+ />
224
+ )}
225
+ </button>
226
+ <button
227
+ type="button"
228
+ onClick={() => setActiveTab('preview')}
229
+ className="px-3 py-2 text-xs font-medium transition-colors relative"
230
+ style={{
231
+ color: activeTab === 'preview' ? 'var(--vscode-foreground)' : 'var(--vscode-descriptionForeground)',
232
+ }}
233
+ >
234
+ Preview
235
+ {activeTab === 'preview' && (
236
+ <span
237
+ className="absolute bottom-0 left-0 right-0 h-[2px] rounded-t"
238
+ style={{ background: 'var(--vscode-focusBorder)' }}
239
+ />
240
+ )}
241
+ </button>
242
+
243
+ {/* Toolbar - only visible on Write tab */}
244
+ {activeTab === 'write' && (
245
+ <div className="flex items-center ml-auto pr-2 gap-0.5">
246
+ <ToolbarButton icon={<Heading size={14} />} title="Heading" onClick={() => handleFormat('heading')} />
247
+ <ToolbarButton icon={<Bold size={14} />} title="Bold (⌘B)" onClick={() => handleFormat('bold')} />
248
+ <ToolbarButton icon={<Italic size={14} />} title="Italic (⌘I)" onClick={() => handleFormat('italic')} />
249
+ <ToolbarButton icon={<Quote size={14} />} title="Quote" onClick={() => handleFormat('quote')} separator />
250
+ <ToolbarButton icon={<Code size={14} />} title="Code" onClick={() => handleFormat('code')} />
251
+ <ToolbarButton icon={<Link size={14} />} title="Link (⌘K)" onClick={() => handleFormat('link')} />
252
+ <ToolbarButton icon={<List size={14} />} title="Bulleted list" onClick={() => handleFormat('ul')} separator />
253
+ <ToolbarButton icon={<ListOrdered size={14} />} title="Numbered list" onClick={() => handleFormat('ol')} />
254
+ <ToolbarButton icon={<ListChecks size={14} />} title="Task list" onClick={() => handleFormat('tasklist')} />
255
+ </div>
256
+ )}
257
+ </div>
258
+
259
+ {/* Content */}
260
+ <div className="flex-1 overflow-auto">
261
+ {activeTab === 'write' ? (
262
+ <textarea
263
+ ref={textareaRef}
264
+ value={value}
265
+ onChange={(e) => onChange(e.target.value)}
266
+ onKeyDown={handleKeyDown}
267
+ placeholder={placeholder}
268
+ className="markdown-editor-textarea"
269
+ spellCheck={false}
270
+ />
271
+ ) : (
272
+ <div className="min-h-[200px]">
273
+ {previewHtml ? (
274
+ <div
275
+ className="prose prose-sm dark:prose-invert max-w-none p-4"
276
+ dangerouslySetInnerHTML={{ __html: previewHtml }}
277
+ />
278
+ ) : (
279
+ <p
280
+ className="p-4 text-sm italic"
281
+ style={{ color: 'var(--vscode-descriptionForeground)' }}
282
+ >
283
+ Nothing to preview
284
+ </p>
285
+ )}
286
+ </div>
287
+ )}
288
+ </div>
289
+ </div>
290
+ )
291
+ }
@@ -0,0 +1,39 @@
1
+ import { ChevronDown } from 'lucide-react'
2
+ import type { Priority } from '../../shared/types'
3
+
4
+ interface PrioritySelectProps {
5
+ value: Priority
6
+ onChange: (priority: Priority) => void
7
+ className?: string
8
+ }
9
+
10
+ const priorities: { value: Priority; label: string; color: string }[] = [
11
+ { value: 'critical', label: 'Critical', color: 'bg-red-500' },
12
+ { value: 'high', label: 'High', color: 'bg-orange-500' },
13
+ { value: 'medium', label: 'Medium', color: 'bg-yellow-500' },
14
+ { value: 'low', label: 'Low', color: 'bg-green-500' }
15
+ ]
16
+
17
+ export function PrioritySelect({ value, onChange, className = '' }: PrioritySelectProps) {
18
+ const current = priorities.find((p) => p.value === value) || priorities[2]
19
+
20
+ return (
21
+ <div className={`relative ${className}`}>
22
+ <select
23
+ value={value}
24
+ onChange={(e) => onChange(e.target.value as Priority)}
25
+ className="appearance-none w-full bg-zinc-100 dark:bg-zinc-700 border border-zinc-200 dark:border-zinc-600 rounded-md px-3 py-2 pr-8 text-sm text-zinc-900 dark:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-blue-500 cursor-pointer"
26
+ >
27
+ {priorities.map((p) => (
28
+ <option key={p.value} value={p.value}>
29
+ {p.label}
30
+ </option>
31
+ ))}
32
+ </select>
33
+ <div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1.5 pointer-events-none">
34
+ <div className={`w-2 h-2 rounded-full ${current.color}`} />
35
+ <ChevronDown size={14} className="text-zinc-400" />
36
+ </div>
37
+ </div>
38
+ )
39
+ }