kanban-lite 1.0.21 → 1.0.23
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/{CLAUDE.md → AGENTS.md} +13 -0
- package/CHANGELOG.md +68 -0
- package/README.md +10 -0
- package/dist/cli.js +168 -102
- package/dist/extension.js +178 -104
- package/dist/mcp-server.js +145 -95
- package/dist/sdk/index.cjs +126 -93
- package/dist/sdk/index.mjs +126 -93
- package/dist/sdk/sdk/KanbanSDK.d.ts +39 -7
- package/dist/sdk/shared/config.d.ts +4 -0
- package/dist/sdk/shared/types.d.ts +4 -0
- package/dist/standalone-webview/index.js +58 -58
- package/dist/standalone-webview/index.js.map +1 -1
- package/dist/standalone-webview/style.css +1 -1
- package/dist/standalone.js +606 -364
- package/dist/webview/index.js +57 -57
- package/dist/webview/index.js.map +1 -1
- package/dist/webview/style.css +1 -1
- package/docs/plans/2026-02-26-settings-tabs-design.md +40 -0
- package/docs/plans/2026-02-26-settings-tabs.md +166 -0
- package/docs/plans/2026-02-27-zoom-settings-design.md +82 -0
- package/docs/plans/2026-02-27-zoom-settings.md +395 -0
- package/docs/sdk.md +3 -6
- package/package.json +1 -1
- package/src/cli/index.ts +12 -2
- package/src/extension/KanbanPanel.ts +25 -5
- package/src/mcp-server/index.ts +20 -2
- package/src/sdk/KanbanSDK.ts +64 -7
- package/src/sdk/__tests__/KanbanSDK.test.ts +17 -1
- package/src/sdk/__tests__/metadata.test.ts +3 -1
- package/src/sdk/__tests__/multi-board.test.ts +2 -0
- package/src/sdk/parser.ts +50 -83
- package/src/shared/config.ts +14 -2
- package/src/shared/types.ts +4 -0
- package/src/standalone/__tests__/server.integration.test.ts +2 -2
- package/src/standalone/index.ts +7 -4
- package/src/standalone/server.ts +31 -6
- package/src/webview/App.tsx +42 -3
- package/src/webview/assets/main.css +31 -2
- package/src/webview/components/KanbanBoard.tsx +35 -3
- package/src/webview/components/KanbanColumn.tsx +40 -4
- package/src/webview/components/SettingsPanel.tsx +179 -77
- package/src/webview/components/Toolbar.tsx +127 -32
- package/src/webview/store/index.ts +26 -28
package/src/sdk/parser.ts
CHANGED
|
@@ -50,45 +50,25 @@ export function parseFeatureFile(content: string, filePath: string): Feature | n
|
|
|
50
50
|
const frontmatter = frontmatterMatch[1]
|
|
51
51
|
const rest = frontmatterMatch[2] || ''
|
|
52
52
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
53
|
+
let parsed: Record<string, unknown>
|
|
54
|
+
try {
|
|
55
|
+
const loaded = yaml.load(frontmatter, { schema: yaml.JSON_SCHEMA })
|
|
56
|
+
if (!loaded || typeof loaded !== 'object' || Array.isArray(loaded)) return null
|
|
57
|
+
parsed = loaded as Record<string, unknown>
|
|
58
|
+
} catch {
|
|
59
|
+
return null
|
|
58
60
|
}
|
|
59
61
|
|
|
60
|
-
const
|
|
61
|
-
const
|
|
62
|
-
if (
|
|
63
|
-
return
|
|
62
|
+
const str = (key: string): string => {
|
|
63
|
+
const val = parsed[key]
|
|
64
|
+
if (val == null) return ''
|
|
65
|
+
return String(val)
|
|
64
66
|
}
|
|
65
67
|
|
|
66
|
-
const
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
if (/^metadata:\s*$/.test(lines[j])) {
|
|
71
|
-
metaStart = j + 1
|
|
72
|
-
break
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
if (metaStart === -1) return undefined
|
|
76
|
-
const indentedLines: string[] = []
|
|
77
|
-
for (let j = metaStart; j < lines.length; j++) {
|
|
78
|
-
if (/^\s/.test(lines[j]) || lines[j].trim() === '') {
|
|
79
|
-
indentedLines.push(lines[j])
|
|
80
|
-
} else {
|
|
81
|
-
break
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
if (indentedLines.length === 0) return undefined
|
|
85
|
-
try {
|
|
86
|
-
const parsed = yaml.load(indentedLines.join('\n'))
|
|
87
|
-
if (parsed && typeof parsed === 'object') return parsed as Record<string, any>
|
|
88
|
-
return undefined
|
|
89
|
-
} catch {
|
|
90
|
-
return undefined
|
|
91
|
-
}
|
|
68
|
+
const arr = (key: string): string[] => {
|
|
69
|
+
const val = parsed[key]
|
|
70
|
+
if (!Array.isArray(val)) return []
|
|
71
|
+
return val.filter(v => v != null).map(String)
|
|
92
72
|
}
|
|
93
73
|
|
|
94
74
|
// Split rest into card body and comment sections
|
|
@@ -111,23 +91,26 @@ export function parseFeatureFile(content: string, filePath: string): Feature | n
|
|
|
111
91
|
}
|
|
112
92
|
}
|
|
113
93
|
|
|
114
|
-
const
|
|
115
|
-
const
|
|
94
|
+
const actions = arr('actions')
|
|
95
|
+
const rawMeta = parsed.metadata
|
|
96
|
+
const meta = rawMeta != null && typeof rawMeta === 'object' && !Array.isArray(rawMeta)
|
|
97
|
+
? rawMeta as Record<string, unknown>
|
|
98
|
+
: undefined
|
|
116
99
|
|
|
117
100
|
return {
|
|
118
|
-
version: parseInt(
|
|
119
|
-
id:
|
|
120
|
-
status: (
|
|
121
|
-
priority: (
|
|
122
|
-
assignee:
|
|
123
|
-
dueDate:
|
|
124
|
-
created:
|
|
125
|
-
modified:
|
|
126
|
-
completedAt:
|
|
127
|
-
labels:
|
|
128
|
-
attachments:
|
|
101
|
+
version: typeof parsed.version === 'number' ? parsed.version : parseInt(str('version'), 10) || 0,
|
|
102
|
+
id: str('id') || extractIdFromFilename(filePath),
|
|
103
|
+
status: (str('status') as FeatureStatus) || 'backlog',
|
|
104
|
+
priority: (str('priority') as Priority) || 'medium',
|
|
105
|
+
assignee: parsed.assignee != null ? String(parsed.assignee) : null,
|
|
106
|
+
dueDate: parsed.dueDate != null ? String(parsed.dueDate) : null,
|
|
107
|
+
created: str('created') || new Date().toISOString(),
|
|
108
|
+
modified: str('modified') || new Date().toISOString(),
|
|
109
|
+
completedAt: parsed.completedAt != null ? String(parsed.completedAt) : null,
|
|
110
|
+
labels: arr('labels'),
|
|
111
|
+
attachments: arr('attachments'),
|
|
129
112
|
comments,
|
|
130
|
-
order:
|
|
113
|
+
order: str('order') || 'a0',
|
|
131
114
|
content: body.trim(),
|
|
132
115
|
...(meta ? { metadata: meta } : {}),
|
|
133
116
|
...(actions.length > 0 ? { actions } : {}),
|
|
@@ -146,43 +129,27 @@ export function parseFeatureFile(content: string, filePath: string): Feature | n
|
|
|
146
129
|
* @returns The complete markdown string ready to be written to a `.md` file.
|
|
147
130
|
*/
|
|
148
131
|
export function serializeFeature(feature: Feature): string {
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
if (feature.actions && feature.actions.length > 0) {
|
|
166
|
-
lines.push(`actions: [${feature.actions.map(a => `"${a}"`).join(', ')}]`)
|
|
132
|
+
const frontmatterObj: Record<string, unknown> = {
|
|
133
|
+
version: feature.version ?? CARD_FORMAT_VERSION,
|
|
134
|
+
id: feature.id,
|
|
135
|
+
status: feature.status,
|
|
136
|
+
priority: feature.priority,
|
|
137
|
+
assignee: feature.assignee ?? null,
|
|
138
|
+
dueDate: feature.dueDate ?? null,
|
|
139
|
+
created: feature.created,
|
|
140
|
+
modified: feature.modified,
|
|
141
|
+
completedAt: feature.completedAt ?? null,
|
|
142
|
+
labels: feature.labels,
|
|
143
|
+
attachments: feature.attachments || [],
|
|
144
|
+
order: feature.order,
|
|
145
|
+
...(feature.actions?.length ? { actions: feature.actions } : {}),
|
|
146
|
+
...(feature.metadata && Object.keys(feature.metadata).length > 0 ? { metadata: feature.metadata } : {}),
|
|
167
147
|
}
|
|
168
148
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
lines.push('metadata:')
|
|
172
|
-
for (const line of metaYaml.trimEnd().split('\n')) {
|
|
173
|
-
lines.push(' ' + line)
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
lines.push('---')
|
|
178
|
-
lines.push('')
|
|
179
|
-
|
|
180
|
-
const frontmatter = lines.join('\n')
|
|
181
|
-
|
|
182
|
-
let result = frontmatter + feature.content
|
|
149
|
+
const yamlStr = yaml.dump(frontmatterObj, { lineWidth: -1, quotingType: '"', forceQuotes: true })
|
|
150
|
+
let result = `---\n${yamlStr}---\n\n${feature.content}`
|
|
183
151
|
|
|
184
|
-
const
|
|
185
|
-
for (const comment of comments) {
|
|
152
|
+
for (const comment of feature.comments || []) {
|
|
186
153
|
result += '\n\n---\n'
|
|
187
154
|
result += `comment: true\n`
|
|
188
155
|
result += `id: "${comment.id}"\n`
|
package/src/shared/config.ts
CHANGED
|
@@ -84,6 +84,10 @@ export interface KanbanConfig {
|
|
|
84
84
|
markdownEditorMode: boolean
|
|
85
85
|
/** Whether to show the deleted column in the UI. */
|
|
86
86
|
showDeletedColumn: boolean
|
|
87
|
+
/** Zoom level for the board view (75–150). */
|
|
88
|
+
boardZoom: number
|
|
89
|
+
/** Zoom level for the card detail panel (75–150). */
|
|
90
|
+
cardZoom: number
|
|
87
91
|
/** Port number for the standalone HTTP server. */
|
|
88
92
|
port: number
|
|
89
93
|
/** Registered webhook endpoints for event notifications. */
|
|
@@ -145,6 +149,8 @@ export const DEFAULT_CONFIG: KanbanConfig = {
|
|
|
145
149
|
compactMode: false,
|
|
146
150
|
markdownEditorMode: false,
|
|
147
151
|
showDeletedColumn: false,
|
|
152
|
+
boardZoom: 100,
|
|
153
|
+
cardZoom: 100,
|
|
148
154
|
port: 3000,
|
|
149
155
|
labels: {}
|
|
150
156
|
}
|
|
@@ -211,6 +217,8 @@ function migrateConfigV1ToV2(raw: Record<string, unknown>): KanbanConfig {
|
|
|
211
217
|
compactMode: v1.compactMode,
|
|
212
218
|
markdownEditorMode: v1.markdownEditorMode,
|
|
213
219
|
showDeletedColumn: false,
|
|
220
|
+
boardZoom: 100,
|
|
221
|
+
cardZoom: 100,
|
|
214
222
|
port: 3000
|
|
215
223
|
}
|
|
216
224
|
}
|
|
@@ -386,7 +394,9 @@ export function configToSettings(config: KanbanConfig): CardDisplaySettings {
|
|
|
386
394
|
markdownEditorMode: config.markdownEditorMode,
|
|
387
395
|
showDeletedColumn: config.showDeletedColumn,
|
|
388
396
|
defaultPriority: config.defaultPriority,
|
|
389
|
-
defaultStatus: config.defaultStatus
|
|
397
|
+
defaultStatus: config.defaultStatus,
|
|
398
|
+
boardZoom: config.boardZoom ?? 100,
|
|
399
|
+
cardZoom: config.cardZoom ?? 100
|
|
390
400
|
}
|
|
391
401
|
}
|
|
392
402
|
|
|
@@ -414,6 +424,8 @@ export function settingsToConfig(config: KanbanConfig, settings: CardDisplaySett
|
|
|
414
424
|
compactMode: settings.compactMode,
|
|
415
425
|
showDeletedColumn: settings.showDeletedColumn,
|
|
416
426
|
defaultPriority: settings.defaultPriority,
|
|
417
|
-
defaultStatus: settings.defaultStatus
|
|
427
|
+
defaultStatus: settings.defaultStatus,
|
|
428
|
+
boardZoom: settings.boardZoom,
|
|
429
|
+
cardZoom: settings.cardZoom
|
|
418
430
|
}
|
|
419
431
|
}
|
package/src/shared/types.ts
CHANGED
|
@@ -250,6 +250,10 @@ export interface CardDisplaySettings {
|
|
|
250
250
|
defaultPriority: Priority
|
|
251
251
|
/** The default column/status assigned to newly created cards. */
|
|
252
252
|
defaultStatus: string
|
|
253
|
+
/** Zoom level for the board view as a percentage (75–150). Default 100. */
|
|
254
|
+
boardZoom: number
|
|
255
|
+
/** Zoom level for the card detail panel as a percentage (75–150). Default 100. */
|
|
256
|
+
cardZoom: number
|
|
253
257
|
}
|
|
254
258
|
|
|
255
259
|
export interface LabelDefinition {
|
|
@@ -376,7 +376,7 @@ describe('Standalone Server Integration', () => {
|
|
|
376
376
|
expect(fileContent).toContain('status: "todo"')
|
|
377
377
|
expect(fileContent).toContain('priority: "high"')
|
|
378
378
|
expect(fileContent).toContain('# My New Feature')
|
|
379
|
-
expect(fileContent).toContain('
|
|
379
|
+
expect(fileContent).toContain('- "frontend"')
|
|
380
380
|
})
|
|
381
381
|
|
|
382
382
|
it('should create feature in its status subfolder', async () => {
|
|
@@ -710,7 +710,7 @@ describe('Standalone Server Integration', () => {
|
|
|
710
710
|
const fileContent = fs.readFileSync(path.join(tempDir, 'boards', 'default', 'backlog', 'update-me.md'), 'utf-8')
|
|
711
711
|
expect(fileContent).toContain('priority: "critical"')
|
|
712
712
|
expect(fileContent).toContain('assignee: "alice"')
|
|
713
|
-
expect(fileContent).toContain('
|
|
713
|
+
expect(fileContent).toContain('- "urgent"')
|
|
714
714
|
})
|
|
715
715
|
|
|
716
716
|
it('should set completedAt when status changes to done', async () => {
|
package/src/standalone/index.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import { readConfig } from '../shared/config'
|
|
1
2
|
import { startServer } from './server'
|
|
2
3
|
|
|
3
|
-
function parseArgs(args: string[]): { dir: string; port: number; noBrowser: boolean } {
|
|
4
|
+
function parseArgs(args: string[], defaultPort: number): { dir: string; port: number; noBrowser: boolean } {
|
|
4
5
|
let dir = '.kanban'
|
|
5
|
-
let port =
|
|
6
|
+
let port = defaultPort
|
|
6
7
|
let noBrowser = false
|
|
7
8
|
|
|
8
9
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -25,7 +26,7 @@ Usage: kanban-md [options]
|
|
|
25
26
|
|
|
26
27
|
Options:
|
|
27
28
|
-d, --dir <path> Features directory (default: .kanban)
|
|
28
|
-
-p, --port <number> Port to listen on (default: 3000)
|
|
29
|
+
-p, --port <number> Port to listen on (default: .kanban.json port or 3000)
|
|
29
30
|
--no-browser Don't open browser automatically
|
|
30
31
|
-h, --help Show this help message
|
|
31
32
|
|
|
@@ -43,7 +44,9 @@ REST API available at http://localhost:<port>/api
|
|
|
43
44
|
return { dir, port, noBrowser }
|
|
44
45
|
}
|
|
45
46
|
|
|
46
|
-
|
|
47
|
+
// Read config from cwd to get the configured port default
|
|
48
|
+
const configPort = readConfig(process.cwd()).port
|
|
49
|
+
const { dir, port, noBrowser } = parseArgs(process.argv.slice(2), configPort)
|
|
47
50
|
|
|
48
51
|
const server = startServer(dir, port)
|
|
49
52
|
|
package/src/standalone/server.ts
CHANGED
|
@@ -260,10 +260,7 @@ export function startServer(featuresDir: string, port: number, webviewDir?: stri
|
|
|
260
260
|
|
|
261
261
|
async function doPurgeDeletedCards(): Promise<boolean> {
|
|
262
262
|
try {
|
|
263
|
-
|
|
264
|
-
for (const card of deletedCards) {
|
|
265
|
-
await sdk.permanentlyDeleteCard(card.id, currentBoardId)
|
|
266
|
-
}
|
|
263
|
+
await sdk.purgeDeletedCards(currentBoardId)
|
|
267
264
|
await loadFeatures()
|
|
268
265
|
broadcast(buildInitMessage())
|
|
269
266
|
return true
|
|
@@ -312,6 +309,21 @@ export function startServer(featuresDir: string, port: number, webviewDir?: stri
|
|
|
312
309
|
}
|
|
313
310
|
}
|
|
314
311
|
|
|
312
|
+
async function doCleanupColumn(columnId: string): Promise<boolean> {
|
|
313
|
+
try {
|
|
314
|
+
migrating = true
|
|
315
|
+
await sdk.cleanupColumn(columnId, currentBoardId)
|
|
316
|
+
await loadFeatures()
|
|
317
|
+
broadcast(buildInitMessage())
|
|
318
|
+
return true
|
|
319
|
+
} catch (err) {
|
|
320
|
+
console.error('Failed to cleanup column:', err)
|
|
321
|
+
return false
|
|
322
|
+
} finally {
|
|
323
|
+
migrating = false
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
315
327
|
function doSaveSettings(newSettings: CardDisplaySettings): void {
|
|
316
328
|
sdk.updateSettings(newSettings)
|
|
317
329
|
broadcast(buildInitMessage())
|
|
@@ -518,6 +530,10 @@ export function startServer(featuresDir: string, port: number, webviewDir?: stri
|
|
|
518
530
|
await doRemoveColumn(msg.columnId as string)
|
|
519
531
|
break
|
|
520
532
|
|
|
533
|
+
case 'cleanupColumn':
|
|
534
|
+
await doCleanupColumn(msg.columnId as string)
|
|
535
|
+
break
|
|
536
|
+
|
|
521
537
|
case 'removeAttachment': {
|
|
522
538
|
const featureId = msg.featureId as string
|
|
523
539
|
const feature = await doRemoveAttachment(featureId, msg.attachment as string)
|
|
@@ -643,14 +659,17 @@ export function startServer(featuresDir: string, port: number, webviewDir?: stri
|
|
|
643
659
|
|
|
644
660
|
case 'renameLabel': {
|
|
645
661
|
await sdk.renameLabel(msg.oldName as string, msg.newName as string)
|
|
662
|
+
await loadFeatures()
|
|
646
663
|
broadcast({ type: 'labelsUpdated', labels: sdk.getLabels() })
|
|
647
664
|
broadcast(buildInitMessage())
|
|
648
665
|
break
|
|
649
666
|
}
|
|
650
667
|
|
|
651
668
|
case 'deleteLabel': {
|
|
652
|
-
sdk.deleteLabel(msg.name as string)
|
|
669
|
+
await sdk.deleteLabel(msg.name as string)
|
|
670
|
+
await loadFeatures()
|
|
653
671
|
broadcast({ type: 'labelsUpdated', labels: sdk.getLabels() })
|
|
672
|
+
broadcast(buildInitMessage())
|
|
654
673
|
break
|
|
655
674
|
}
|
|
656
675
|
|
|
@@ -1291,6 +1310,9 @@ export function startServer(featuresDir: string, port: number, webviewDir?: stri
|
|
|
1291
1310
|
const newName = body.newName as string
|
|
1292
1311
|
if (!newName) return jsonError(res, 400, 'newName is required')
|
|
1293
1312
|
await sdk.renameLabel(name, newName)
|
|
1313
|
+
await loadFeatures()
|
|
1314
|
+
broadcast({ type: 'labelsUpdated', labels: sdk.getLabels() })
|
|
1315
|
+
broadcast(buildInitMessage())
|
|
1294
1316
|
return jsonOk(res, sdk.getLabels())
|
|
1295
1317
|
} catch (err) {
|
|
1296
1318
|
return jsonError(res, 400, String(err))
|
|
@@ -1301,7 +1323,10 @@ export function startServer(featuresDir: string, port: number, webviewDir?: stri
|
|
|
1301
1323
|
if (params) {
|
|
1302
1324
|
try {
|
|
1303
1325
|
const name = decodeURIComponent(params.name)
|
|
1304
|
-
sdk.deleteLabel(name)
|
|
1326
|
+
await sdk.deleteLabel(name)
|
|
1327
|
+
await loadFeatures()
|
|
1328
|
+
broadcast({ type: 'labelsUpdated', labels: sdk.getLabels() })
|
|
1329
|
+
broadcast(buildInitMessage())
|
|
1305
1330
|
return jsonOk(res, { success: true })
|
|
1306
1331
|
} catch (err) {
|
|
1307
1332
|
return jsonError(res, 400, String(err))
|
package/src/webview/App.tsx
CHANGED
|
@@ -131,6 +131,29 @@ function App(): React.JSX.Element {
|
|
|
131
131
|
return
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
// Ctrl/Cmd +/- for board zoom, Ctrl/Cmd+Shift +/- for card detail zoom
|
|
135
|
+
if ((e.key === '=' || e.key === '+' || e.key === '-') && (e.ctrlKey || e.metaKey)) {
|
|
136
|
+
e.preventDefault()
|
|
137
|
+
const delta = (e.key === '-') ? -5 : 5
|
|
138
|
+
const { cardSettings } = useStore.getState()
|
|
139
|
+
if (e.shiftKey) {
|
|
140
|
+
const newZoom = Math.max(75, Math.min(150, cardSettings.cardZoom + delta))
|
|
141
|
+
if (newZoom !== cardSettings.cardZoom) {
|
|
142
|
+
const next = { ...cardSettings, cardZoom: newZoom }
|
|
143
|
+
setCardSettings(next)
|
|
144
|
+
vscode.postMessage({ type: 'saveSettings', settings: next })
|
|
145
|
+
}
|
|
146
|
+
} else {
|
|
147
|
+
const newZoom = Math.max(75, Math.min(150, cardSettings.boardZoom + delta))
|
|
148
|
+
if (newZoom !== cardSettings.boardZoom) {
|
|
149
|
+
const next = { ...cardSettings, boardZoom: newZoom }
|
|
150
|
+
setCardSettings(next)
|
|
151
|
+
vscode.postMessage({ type: 'saveSettings', settings: next })
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
|
|
134
157
|
// Ignore if user is typing in an input or contentEditable (e.g. TipTap editor)
|
|
135
158
|
if (
|
|
136
159
|
e.target instanceof HTMLInputElement ||
|
|
@@ -178,7 +201,7 @@ function App(): React.JSX.Element {
|
|
|
178
201
|
window.removeEventListener('mousedown', handleMouseDown)
|
|
179
202
|
if (altDownTimer) clearTimeout(altDownTimer)
|
|
180
203
|
}
|
|
181
|
-
}, [createFeatureOpen, handleUndoLatest])
|
|
204
|
+
}, [createFeatureOpen, handleUndoLatest, setCardSettings])
|
|
182
205
|
|
|
183
206
|
// Listen for VSCode theme changes
|
|
184
207
|
useEffect(() => {
|
|
@@ -202,6 +225,13 @@ function App(): React.JSX.Element {
|
|
|
202
225
|
return () => observer.disconnect()
|
|
203
226
|
}, [setIsDarkMode])
|
|
204
227
|
|
|
228
|
+
// Sync zoom CSS custom properties
|
|
229
|
+
useEffect(() => {
|
|
230
|
+
const root = document.documentElement
|
|
231
|
+
root.style.setProperty('--board-zoom', String(cardSettings.boardZoom / 100))
|
|
232
|
+
root.style.setProperty('--card-zoom', String(cardSettings.cardZoom / 100))
|
|
233
|
+
}, [cardSettings.boardZoom, cardSettings.cardZoom])
|
|
234
|
+
|
|
205
235
|
// Listen for messages from extension
|
|
206
236
|
useEffect(() => {
|
|
207
237
|
const handleMessage = (event: MessageEvent<ExtensionMessage>) => {
|
|
@@ -387,6 +417,14 @@ function App(): React.JSX.Element {
|
|
|
387
417
|
vscode.postMessage({ type: 'removeColumn', columnId })
|
|
388
418
|
}
|
|
389
419
|
|
|
420
|
+
const handleCleanupColumn = (columnId: string): void => {
|
|
421
|
+
const col = columns.find(c => c.id === columnId)
|
|
422
|
+
if (!col) return
|
|
423
|
+
const featuresInColumn = useStore.getState().features.filter(f => f.status === columnId)
|
|
424
|
+
if (featuresInColumn.length === 0) return
|
|
425
|
+
vscode.postMessage({ type: 'cleanupColumn', columnId })
|
|
426
|
+
}
|
|
427
|
+
|
|
390
428
|
const handleSaveColumn = (data: { name: string; color: string }): void => {
|
|
391
429
|
if (editingColumn) {
|
|
392
430
|
vscode.postMessage({ type: 'editColumn', columnId: editingColumn.id, updates: data })
|
|
@@ -483,19 +521,20 @@ function App(): React.JSX.Element {
|
|
|
483
521
|
onCreateBoard={(name) => vscode.postMessage({ type: 'createBoard', name })}
|
|
484
522
|
/>
|
|
485
523
|
<div className="flex-1 flex overflow-hidden">
|
|
486
|
-
<div className={editingFeature ? 'w-1/2' : 'w-full'}>
|
|
524
|
+
<div className={`board-zoom-scope ${editingFeature ? 'w-1/2' : 'w-full'}`}>
|
|
487
525
|
<KanbanBoard
|
|
488
526
|
onFeatureClick={handleFeatureClick}
|
|
489
527
|
onAddFeature={handleAddFeatureInColumn}
|
|
490
528
|
onMoveFeature={handleMoveFeature}
|
|
491
529
|
onEditColumn={handleEditColumn}
|
|
492
530
|
onRemoveColumn={handleRemoveColumn}
|
|
531
|
+
onCleanupColumn={handleCleanupColumn}
|
|
493
532
|
onPurgeDeletedCards={handlePurgeDeletedCards}
|
|
494
533
|
selectedFeatureId={editingFeature?.id}
|
|
495
534
|
/>
|
|
496
535
|
</div>
|
|
497
536
|
{editingFeature && (
|
|
498
|
-
<div className="w-1/2">
|
|
537
|
+
<div className="w-1/2" style={{ fontSize: `calc(1em * var(--card-zoom, 1))` }}>
|
|
499
538
|
<FeatureEditor
|
|
500
539
|
featureId={editingFeature.id}
|
|
501
540
|
content={editingFeature.content}
|
|
@@ -76,6 +76,7 @@ input:focus-visible {
|
|
|
76
76
|
|
|
77
77
|
/* Prose (markdown) styling for editor */
|
|
78
78
|
.prose {
|
|
79
|
+
font-size: calc(1rem * var(--card-zoom, 1));
|
|
79
80
|
line-height: 1.625;
|
|
80
81
|
}
|
|
81
82
|
|
|
@@ -281,7 +282,7 @@ input:focus-visible {
|
|
|
281
282
|
background: transparent;
|
|
282
283
|
color: var(--vscode-foreground);
|
|
283
284
|
font-family: var(--vscode-editor-font-family, 'SF Mono', Monaco, Menlo, Consolas, monospace);
|
|
284
|
-
font-size: var(--vscode-editor-font-size, 13px);
|
|
285
|
+
font-size: calc(var(--vscode-editor-font-size, 13px) * var(--card-zoom, 1));
|
|
285
286
|
line-height: 1.6;
|
|
286
287
|
tab-size: 2;
|
|
287
288
|
}
|
|
@@ -330,7 +331,7 @@ input:focus-visible {
|
|
|
330
331
|
|
|
331
332
|
/* Comment markdown styling - compact prose for comment bubbles */
|
|
332
333
|
.comment-markdown {
|
|
333
|
-
font-size: 0.75rem;
|
|
334
|
+
font-size: calc(0.75rem * var(--card-zoom, 1));
|
|
334
335
|
line-height: 1.5;
|
|
335
336
|
}
|
|
336
337
|
|
|
@@ -432,3 +433,31 @@ input:focus-visible {
|
|
|
432
433
|
.comment-markdown em {
|
|
433
434
|
font-style: italic;
|
|
434
435
|
}
|
|
436
|
+
|
|
437
|
+
/* Board zoom scaling */
|
|
438
|
+
.board-zoom-scope {
|
|
439
|
+
font-size: calc(1em * var(--board-zoom, 1));
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/* Settings slider */
|
|
443
|
+
.settings-slider {
|
|
444
|
+
-webkit-appearance: none;
|
|
445
|
+
appearance: none;
|
|
446
|
+
width: 5rem;
|
|
447
|
+
height: 4px;
|
|
448
|
+
border-radius: 2px;
|
|
449
|
+
outline: none;
|
|
450
|
+
cursor: pointer;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
.settings-slider::-webkit-slider-thumb {
|
|
454
|
+
-webkit-appearance: none;
|
|
455
|
+
appearance: none;
|
|
456
|
+
width: 14px;
|
|
457
|
+
height: 14px;
|
|
458
|
+
border-radius: 50%;
|
|
459
|
+
background: var(--vscode-button-background);
|
|
460
|
+
border: 2px solid var(--vscode-editor-background);
|
|
461
|
+
box-shadow: 0 0 0 1px var(--vscode-button-background);
|
|
462
|
+
cursor: pointer;
|
|
463
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { useState, useCallback } from 'react'
|
|
1
|
+
import { useState, useCallback, useRef, useEffect } from 'react'
|
|
2
2
|
import { KanbanColumn } from './KanbanColumn'
|
|
3
3
|
import { useStore } from '../store'
|
|
4
|
+
import type { SortOrder } from '../store'
|
|
4
5
|
import type { Feature, FeatureStatus } from '../../shared/types'
|
|
5
6
|
import { DELETED_COLUMN } from '../../shared/types'
|
|
6
7
|
|
|
@@ -15,16 +16,41 @@ interface KanbanBoardProps {
|
|
|
15
16
|
onMoveFeature: (featureId: string, newStatus: string, newOrder: number) => void
|
|
16
17
|
onEditColumn: (columnId: string) => void
|
|
17
18
|
onRemoveColumn: (columnId: string) => void
|
|
19
|
+
onCleanupColumn: (columnId: string) => void
|
|
18
20
|
onPurgeDeletedCards: () => void
|
|
19
21
|
selectedFeatureId?: string
|
|
20
22
|
}
|
|
21
23
|
|
|
22
|
-
export function KanbanBoard({ onFeatureClick, onAddFeature, onMoveFeature, onEditColumn, onRemoveColumn, onPurgeDeletedCards, selectedFeatureId }: KanbanBoardProps) {
|
|
24
|
+
export function KanbanBoard({ onFeatureClick, onAddFeature, onMoveFeature, onEditColumn, onRemoveColumn, onCleanupColumn, onPurgeDeletedCards, selectedFeatureId }: KanbanBoardProps) {
|
|
25
|
+
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (!selectedFeatureId) return
|
|
29
|
+
const container = scrollContainerRef.current
|
|
30
|
+
if (!container) return
|
|
31
|
+
// Wait a frame so the panel layout transition has started and the board has shrunk
|
|
32
|
+
requestAnimationFrame(() => {
|
|
33
|
+
const cardEl = container.querySelector<HTMLElement>(`[data-card-id="${selectedFeatureId}"]`)
|
|
34
|
+
if (!cardEl) return
|
|
35
|
+
const containerRect = container.getBoundingClientRect()
|
|
36
|
+
const cardRect = cardEl.getBoundingClientRect()
|
|
37
|
+
// Check if the card is fully visible horizontally inside the scroll container
|
|
38
|
+
const isFullyVisible = cardRect.left >= containerRect.left && cardRect.right <= containerRect.right
|
|
39
|
+
if (!isFullyVisible) {
|
|
40
|
+
// Scroll so the card's right edge is visible with 10px breathing room
|
|
41
|
+
const overflow = cardRect.right - containerRect.right
|
|
42
|
+
container.scrollBy({ left: overflow + 10, behavior: 'smooth' })
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
}, [selectedFeatureId])
|
|
46
|
+
|
|
23
47
|
const columns = useStore((s) => s.columns)
|
|
24
48
|
const cardSettings = useStore((s) => s.cardSettings)
|
|
25
49
|
const getFilteredFeaturesByStatus = useStore((s) => s.getFilteredFeaturesByStatus)
|
|
26
50
|
const getFeaturesByStatus = useStore((s) => s.getFeaturesByStatus)
|
|
27
51
|
const layout = useStore((s) => s.layout)
|
|
52
|
+
const columnSorts = useStore((s) => s.columnSorts)
|
|
53
|
+
const setColumnSort = useStore((s) => s.setColumnSort)
|
|
28
54
|
const [draggedFeature, setDraggedFeature] = useState<Feature | null>(null)
|
|
29
55
|
const [dropTarget, setDropTarget] = useState<DropTarget | null>(null)
|
|
30
56
|
|
|
@@ -121,7 +147,7 @@ export function KanbanBoard({ onFeatureClick, onAddFeature, onMoveFeature, onEdi
|
|
|
121
147
|
const isVertical = layout === 'vertical'
|
|
122
148
|
|
|
123
149
|
return (
|
|
124
|
-
<div className={isVertical ? "h-full overflow-y-auto p-4" : "h-full overflow-x-auto p-4"}>
|
|
150
|
+
<div ref={scrollContainerRef} className={isVertical ? "h-full overflow-y-auto p-4" : "h-full overflow-x-auto p-4"}>
|
|
125
151
|
<div className={isVertical ? "flex flex-col gap-4" : "flex gap-4 h-full min-w-max"}>
|
|
126
152
|
{columns.map((column) => (
|
|
127
153
|
<KanbanColumn
|
|
@@ -132,6 +158,7 @@ export function KanbanBoard({ onFeatureClick, onAddFeature, onMoveFeature, onEdi
|
|
|
132
158
|
onAddFeature={onAddFeature}
|
|
133
159
|
onEditColumn={onEditColumn}
|
|
134
160
|
onRemoveColumn={onRemoveColumn}
|
|
161
|
+
onCleanupColumn={onCleanupColumn}
|
|
135
162
|
onDragStart={handleDragStart}
|
|
136
163
|
onDragOver={handleDragOver}
|
|
137
164
|
onDragOverCard={handleDragOverCard}
|
|
@@ -141,6 +168,8 @@ export function KanbanBoard({ onFeatureClick, onAddFeature, onMoveFeature, onEdi
|
|
|
141
168
|
dropTarget={dropTarget}
|
|
142
169
|
layout={layout}
|
|
143
170
|
selectedFeatureId={selectedFeatureId}
|
|
171
|
+
sort={(columnSorts[column.id] || 'order') as SortOrder}
|
|
172
|
+
onSortChange={(s) => setColumnSort(column.id, s)}
|
|
144
173
|
/>
|
|
145
174
|
))}
|
|
146
175
|
{cardSettings.showDeletedColumn && (
|
|
@@ -152,6 +181,7 @@ export function KanbanBoard({ onFeatureClick, onAddFeature, onMoveFeature, onEdi
|
|
|
152
181
|
onAddFeature={onAddFeature}
|
|
153
182
|
onEditColumn={onEditColumn}
|
|
154
183
|
onRemoveColumn={onRemoveColumn}
|
|
184
|
+
onCleanupColumn={onCleanupColumn}
|
|
155
185
|
onDragStart={handleDragStart}
|
|
156
186
|
onDragOver={handleDragOver}
|
|
157
187
|
onDragOverCard={handleDragOverCard}
|
|
@@ -163,6 +193,8 @@ export function KanbanBoard({ onFeatureClick, onAddFeature, onMoveFeature, onEdi
|
|
|
163
193
|
isDeletedColumn
|
|
164
194
|
onPurgeColumn={onPurgeDeletedCards}
|
|
165
195
|
selectedFeatureId={selectedFeatureId}
|
|
196
|
+
sort={(columnSorts[DELETED_COLUMN.id] || 'order') as SortOrder}
|
|
197
|
+
onSortChange={(s) => setColumnSort(DELETED_COLUMN.id, s)}
|
|
166
198
|
/>
|
|
167
199
|
)}
|
|
168
200
|
</div>
|