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,507 @@
1
+ import * as vscode from 'vscode'
2
+ import * as path from 'path'
3
+ import { getTitleFromContent } from '../shared/types'
4
+ import type { FeatureStatus, Priority, KanbanColumn } from '../shared/types'
5
+ import { readConfig, CONFIG_FILENAME } from '../shared/config'
6
+ import { KanbanPanel } from './KanbanPanel'
7
+
8
+ interface SidebarFeature {
9
+ id: string
10
+ title: string
11
+ status: FeatureStatus
12
+ priority: Priority
13
+ }
14
+
15
+ export class SidebarViewProvider implements vscode.WebviewViewProvider {
16
+ public static readonly viewType = 'kanban-lite.boardView'
17
+
18
+ private _view?: vscode.WebviewView
19
+ private _features: SidebarFeature[] = []
20
+ private _fileWatcher?: vscode.FileSystemWatcher
21
+ private _debounceTimer?: NodeJS.Timeout
22
+ private _disposables: vscode.Disposable[] = []
23
+
24
+ constructor(private readonly _extensionUri: vscode.Uri, private readonly _context: vscode.ExtensionContext) {
25
+ this._setupFileWatcher()
26
+
27
+ // Watch .kanban.json for config changes
28
+ const workspaceFolders = vscode.workspace.workspaceFolders
29
+ if (workspaceFolders) {
30
+ const root = workspaceFolders[0].uri.fsPath
31
+ const configPattern = new vscode.RelativePattern(root, CONFIG_FILENAME)
32
+ const configWatcher = vscode.workspace.createFileSystemWatcher(configPattern)
33
+
34
+ const handleConfigChange = () => {
35
+ this._setupFileWatcher()
36
+ this._refresh()
37
+ }
38
+
39
+ configWatcher.onDidChange(handleConfigChange, null, this._disposables)
40
+ configWatcher.onDidCreate(handleConfigChange, null, this._disposables)
41
+ configWatcher.onDidDelete(handleConfigChange, null, this._disposables)
42
+ this._disposables.push(configWatcher)
43
+ }
44
+ }
45
+
46
+ public resolveWebviewView(
47
+ webviewView: vscode.WebviewView,
48
+ _context: vscode.WebviewViewResolveContext,
49
+ _token: vscode.CancellationToken
50
+ ): void {
51
+ this._view = webviewView
52
+
53
+ webviewView.webview.options = {
54
+ enableScripts: true
55
+ }
56
+
57
+ webviewView.webview.onDidReceiveMessage(message => {
58
+ switch (message.type) {
59
+ case 'ready':
60
+ this._refresh()
61
+ break
62
+ case 'openBoard':
63
+ vscode.commands.executeCommand('kanban-lite.open')
64
+ break
65
+ case 'newFeature':
66
+ vscode.commands.executeCommand('kanban-lite.open')
67
+ // Wait for the panel to be ready, then trigger create dialog
68
+ setTimeout(() => {
69
+ KanbanPanel.currentPanel?.triggerCreateDialog()
70
+ }, 500)
71
+ break
72
+ case 'openFeature':
73
+ vscode.commands.executeCommand('kanban-lite.open')
74
+ setTimeout(() => {
75
+ KanbanPanel.currentPanel?.openFeature(message.featureId)
76
+ }, 500)
77
+ break
78
+ }
79
+ }, null, this._disposables)
80
+
81
+ webviewView.onDidChangeVisibility(() => {
82
+ if (webviewView.visible) {
83
+ vscode.commands.executeCommand('kanban-lite.open')
84
+ }
85
+ }, null, this._disposables)
86
+
87
+ webviewView.onDidDispose(() => {
88
+ this._view = undefined
89
+ })
90
+
91
+ // Auto-open the board when the sidebar first loads
92
+ vscode.commands.executeCommand('kanban-lite.open')
93
+
94
+ webviewView.webview.html = this._getHtml()
95
+ }
96
+
97
+ public setBoardOpen(open: boolean): void {
98
+ if (this._view) {
99
+ this._view.webview.postMessage({ type: 'boardOpenChanged', open })
100
+ }
101
+ }
102
+
103
+ public dispose(): void {
104
+ if (this._fileWatcher) {
105
+ this._fileWatcher.dispose()
106
+ }
107
+ if (this._debounceTimer) {
108
+ clearTimeout(this._debounceTimer)
109
+ }
110
+ for (const d of this._disposables) {
111
+ d.dispose()
112
+ }
113
+ }
114
+
115
+ private _setupFileWatcher(): void {
116
+ if (this._fileWatcher) {
117
+ this._fileWatcher.dispose()
118
+ }
119
+
120
+ const featuresDir = this._getFeaturesDir()
121
+ if (!featuresDir) return
122
+
123
+ const pattern = new vscode.RelativePattern(featuresDir, '**/*.md')
124
+ this._fileWatcher = vscode.workspace.createFileSystemWatcher(pattern)
125
+
126
+ const handleChange = () => {
127
+ if (this._debounceTimer) clearTimeout(this._debounceTimer)
128
+ this._debounceTimer = setTimeout(() => this._refresh(), 300)
129
+ }
130
+
131
+ this._fileWatcher.onDidChange(handleChange, null, this._disposables)
132
+ this._fileWatcher.onDidCreate(handleChange, null, this._disposables)
133
+ this._fileWatcher.onDidDelete(handleChange, null, this._disposables)
134
+ }
135
+
136
+ private async _refresh(): Promise<void> {
137
+ await this._loadFeatures()
138
+ if (this._view) {
139
+ this._view.webview.postMessage({
140
+ type: 'update',
141
+ features: this._features,
142
+ columns: this._getColumns()
143
+ })
144
+ this._view.webview.postMessage({
145
+ type: 'boardOpenChanged',
146
+ open: !!KanbanPanel.currentPanel
147
+ })
148
+ }
149
+ }
150
+
151
+ private _getFeaturesDir(): string | null {
152
+ const workspaceFolders = vscode.workspace.workspaceFolders
153
+ if (!workspaceFolders || workspaceFolders.length === 0) return null
154
+ const root = workspaceFolders[0].uri.fsPath
155
+ const config = readConfig(root)
156
+ return path.join(root, config.featuresDirectory)
157
+ }
158
+
159
+ private _getColumns(): KanbanColumn[] {
160
+ const workspaceFolders = vscode.workspace.workspaceFolders
161
+ if (!workspaceFolders || workspaceFolders.length === 0) return []
162
+ const root = workspaceFolders[0].uri.fsPath
163
+ const config = readConfig(root)
164
+ return config.columns
165
+ }
166
+
167
+ private async _loadFeatures(): Promise<void> {
168
+ const featuresDir = this._getFeaturesDir()
169
+ if (!featuresDir) {
170
+ this._features = []
171
+ return
172
+ }
173
+
174
+ const features: SidebarFeature[] = []
175
+
176
+ // Load .md files from ALL subdirectories
177
+ try {
178
+ const topEntries = await vscode.workspace.fs.readDirectory(vscode.Uri.file(featuresDir))
179
+ for (const [name, type] of topEntries) {
180
+ if (type !== vscode.FileType.Directory || name.startsWith('.')) continue
181
+ const subdir = path.join(featuresDir, name)
182
+ try {
183
+ const entries = await vscode.workspace.fs.readDirectory(vscode.Uri.file(subdir))
184
+ for (const [file, fileType] of entries) {
185
+ if (fileType !== vscode.FileType.File || !file.endsWith('.md')) continue
186
+ const filePath = path.join(subdir, file)
187
+ try {
188
+ const content = new TextDecoder().decode(await vscode.workspace.fs.readFile(vscode.Uri.file(filePath)))
189
+ const parsed = this._parseFrontmatter(content, file)
190
+ if (parsed) features.push(parsed)
191
+ } catch {
192
+ // Skip unreadable files
193
+ }
194
+ }
195
+ } catch {
196
+ // Skip unreadable directories
197
+ }
198
+ }
199
+ } catch {
200
+ // Root directory may not exist
201
+ }
202
+
203
+ this._features = features
204
+ }
205
+
206
+ private _parseFrontmatter(content: string, filename: string): SidebarFeature | null {
207
+ content = content.replace(/\r\n/g, '\n')
208
+ const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/)
209
+ if (!match) return null
210
+
211
+ const fm = match[1]
212
+ const body = match[2] || ''
213
+
214
+ const getValue = (key: string): string => {
215
+ const m = fm.match(new RegExp(`^${key}:\\s*(.*)$`, 'm'))
216
+ if (!m) return ''
217
+ const v = m[1].trim().replace(/^["']|["']$/g, '')
218
+ return v === 'null' ? '' : v
219
+ }
220
+
221
+ const basename = path.basename(filename, '.md')
222
+ const numericMatch = basename.match(/^(\d+)-/)
223
+ const id = getValue('id') || (numericMatch ? numericMatch[1] : basename)
224
+ const status = (getValue('status') as FeatureStatus) || 'backlog'
225
+ const priority = (getValue('priority') as Priority) || 'medium'
226
+ const title = getTitleFromContent(body)
227
+
228
+ return { id, title, status, priority }
229
+ }
230
+
231
+ private _getHtml(): string {
232
+ const nonce = this._getNonce()
233
+
234
+ return `<!DOCTYPE html>
235
+ <html lang="en">
236
+ <head>
237
+ <meta charset="UTF-8">
238
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
239
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'nonce-${nonce}'; script-src 'nonce-${nonce}';">
240
+ <style nonce="${nonce}">
241
+ * { box-sizing: border-box; margin: 0; padding: 0; }
242
+
243
+ body {
244
+ font-family: var(--vscode-font-family);
245
+ font-size: var(--vscode-font-size);
246
+ color: var(--vscode-foreground);
247
+ background: transparent;
248
+ padding: 12px 14px;
249
+ }
250
+
251
+ .actions {
252
+ display: flex;
253
+ flex-direction: column;
254
+ gap: 6px;
255
+ margin-bottom: 16px;
256
+ }
257
+
258
+ button {
259
+ display: flex;
260
+ align-items: center;
261
+ justify-content: center;
262
+ gap: 6px;
263
+ width: 100%;
264
+ padding: 6px 12px;
265
+ border: none;
266
+ border-radius: 4px;
267
+ cursor: pointer;
268
+ font-family: var(--vscode-font-family);
269
+ font-size: var(--vscode-font-size);
270
+ line-height: 20px;
271
+ }
272
+
273
+ .btn-primary {
274
+ background: var(--vscode-button-background);
275
+ color: var(--vscode-button-foreground);
276
+ }
277
+ .btn-primary:hover {
278
+ background: var(--vscode-button-hoverBackground);
279
+ }
280
+
281
+ .btn-secondary {
282
+ background: var(--vscode-button-secondaryBackground);
283
+ color: var(--vscode-button-secondaryForeground);
284
+ }
285
+ .btn-secondary:hover {
286
+ background: var(--vscode-button-secondaryHoverBackground);
287
+ }
288
+
289
+ .section {
290
+ margin-bottom: 14px;
291
+ }
292
+
293
+ .section-header {
294
+ display: flex;
295
+ align-items: center;
296
+ justify-content: space-between;
297
+ margin-bottom: 8px;
298
+ font-size: 11px;
299
+ font-weight: 600;
300
+ text-transform: uppercase;
301
+ letter-spacing: 0.5px;
302
+ color: var(--vscode-sideBarSectionHeader-foreground, var(--vscode-foreground));
303
+ opacity: 0.8;
304
+ }
305
+
306
+ .section-header .total {
307
+ font-weight: 400;
308
+ opacity: 0.7;
309
+ }
310
+
311
+ .stat-row {
312
+ display: flex;
313
+ align-items: center;
314
+ justify-content: space-between;
315
+ padding: 3px 0;
316
+ font-size: var(--vscode-font-size);
317
+ }
318
+
319
+ .stat-label {
320
+ display: flex;
321
+ align-items: center;
322
+ gap: 7px;
323
+ }
324
+
325
+ .dot {
326
+ width: 8px;
327
+ height: 8px;
328
+ border-radius: 50%;
329
+ flex-shrink: 0;
330
+ }
331
+
332
+ .stat-count {
333
+ opacity: 0.7;
334
+ font-variant-numeric: tabular-nums;
335
+ }
336
+
337
+ .feature-list {
338
+ list-style: none;
339
+ }
340
+
341
+ .feature-item {
342
+ display: flex;
343
+ align-items: center;
344
+ gap: 7px;
345
+ padding: 4px 6px;
346
+ border-radius: 4px;
347
+ cursor: pointer;
348
+ font-size: var(--vscode-font-size);
349
+ white-space: nowrap;
350
+ overflow: hidden;
351
+ text-overflow: ellipsis;
352
+ }
353
+
354
+ .feature-item:hover {
355
+ background: var(--vscode-list-hoverBackground);
356
+ }
357
+
358
+ .feature-dot {
359
+ width: 6px;
360
+ height: 6px;
361
+ border-radius: 50%;
362
+ flex-shrink: 0;
363
+ }
364
+
365
+ .feature-title {
366
+ overflow: hidden;
367
+ text-overflow: ellipsis;
368
+ }
369
+
370
+ .empty-state {
371
+ color: var(--vscode-descriptionForeground);
372
+ font-size: var(--vscode-font-size);
373
+ font-style: italic;
374
+ padding: 4px 0;
375
+ }
376
+
377
+ .separator {
378
+ height: 1px;
379
+ background: var(--vscode-sideBarSectionHeader-border, var(--vscode-panel-border, transparent));
380
+ margin: 12px 0;
381
+ }
382
+ </style>
383
+ </head>
384
+ <body>
385
+ <div class="actions">
386
+ <button class="btn-primary" id="openBoard">
387
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M14 1H2a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1zM2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm3 4a1 1 0 0 0-1 1v6a1 1 0 0 0 2 0V5a1 1 0 0 0-1-1zm3 0a1 1 0 0 0-1 1v4a1 1 0 0 0 2 0V5a1 1 0 0 0-1-1zm3 0a1 1 0 0 0-1 1v8a1 1 0 0 0 2 0V5a1 1 0 0 0-1-1z"/></svg>
388
+ Open Board
389
+ </button>
390
+ <button class="btn-secondary" id="newFeature">
391
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M8 1a.5.5 0 0 1 .5.5V7h5.5a.5.5 0 0 1 0 1H8.5v5.5a.5.5 0 0 1-1 0V8H2a.5.5 0 0 1 0-1h5.5V1.5A.5.5 0 0 1 8 1z"/></svg>
392
+ New Feature
393
+ </button>
394
+ </div>
395
+
396
+ <div class="separator"></div>
397
+
398
+ <div class="section" id="overviewSection">
399
+ <div class="section-header">
400
+ <span>Overview</span>
401
+ <span class="total" id="totalCount">0 total</span>
402
+ </div>
403
+ <div id="statRows"></div>
404
+ </div>
405
+
406
+ <div class="separator"></div>
407
+
408
+ <div class="section" id="inProgressSection" style="display:none;">
409
+ <div class="section-header">
410
+ <span>In Progress</span>
411
+ </div>
412
+ <ul class="feature-list" id="inProgressList"></ul>
413
+ </div>
414
+
415
+ <script nonce="${nonce}">
416
+ (function() {
417
+ const vscode = acquireVsCodeApi();
418
+ let columns = [];
419
+ let features = [];
420
+
421
+ document.getElementById('openBoard').addEventListener('click', () => {
422
+ vscode.postMessage({ type: 'openBoard' });
423
+ });
424
+ document.getElementById('newFeature').addEventListener('click', () => {
425
+ vscode.postMessage({ type: 'newFeature' });
426
+ });
427
+
428
+ window.addEventListener('message', e => {
429
+ const msg = e.data;
430
+ if (msg.type === 'update') {
431
+ columns = msg.columns;
432
+ features = msg.features;
433
+ render();
434
+ } else if (msg.type === 'boardOpenChanged') {
435
+ document.getElementById('openBoard').style.display = msg.open ? 'none' : '';
436
+ }
437
+ });
438
+
439
+ function render() {
440
+ // Total count
441
+ document.getElementById('totalCount').textContent = features.length + ' total';
442
+
443
+ // Stat rows
444
+ const statRows = document.getElementById('statRows');
445
+ statRows.innerHTML = '';
446
+ for (const col of columns) {
447
+ const count = features.filter(f => f.status === col.id).length;
448
+ const row = document.createElement('div');
449
+ row.className = 'stat-row';
450
+ row.innerHTML =
451
+ '<span class="stat-label">' +
452
+ '<span class="dot" style="background:' + col.color + '"></span>' +
453
+ col.name +
454
+ '</span>' +
455
+ '<span class="stat-count">' + count + '</span>';
456
+ statRows.appendChild(row);
457
+ }
458
+
459
+ // In-progress features
460
+ const inProgressCol = columns.find(c => c.id === 'in-progress');
461
+ const inProgressColor = inProgressCol ? inProgressCol.color : '#f59e0b';
462
+ const inProgress = features.filter(f => f.status === 'in-progress');
463
+ const section = document.getElementById('inProgressSection');
464
+ const list = document.getElementById('inProgressList');
465
+
466
+ if (inProgress.length > 0) {
467
+ section.style.display = '';
468
+ list.innerHTML = '';
469
+ for (const f of inProgress) {
470
+ const li = document.createElement('li');
471
+ li.className = 'feature-item';
472
+ li.title = f.title;
473
+ li.innerHTML =
474
+ '<span class="feature-dot" style="background:' + inProgressColor + '"></span>' +
475
+ '<span class="feature-title">' + escapeHtml(f.title) + '</span>';
476
+ li.addEventListener('click', () => {
477
+ vscode.postMessage({ type: 'openFeature', featureId: f.id });
478
+ });
479
+ list.appendChild(li);
480
+ }
481
+ } else {
482
+ section.style.display = 'none';
483
+ }
484
+ }
485
+
486
+ function escapeHtml(str) {
487
+ const div = document.createElement('div');
488
+ div.textContent = str;
489
+ return div.innerHTML;
490
+ }
491
+
492
+ vscode.postMessage({ type: 'ready' });
493
+ })();
494
+ </script>
495
+ </body>
496
+ </html>`
497
+ }
498
+
499
+ private _getNonce(): string {
500
+ let text = ''
501
+ const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
502
+ for (let i = 0; i < 32; i++) {
503
+ text += possible.charAt(Math.floor(Math.random() * possible.length))
504
+ }
505
+ return text
506
+ }
507
+ }
@@ -0,0 +1,82 @@
1
+ import * as path from 'path'
2
+ import * as vscode from 'vscode'
3
+
4
+ export function getFeatureFilePath(featuresDir: string, status: string, filename: string): string {
5
+ return path.join(featuresDir, status, `${filename}.md`)
6
+ }
7
+
8
+ export async function ensureStatusSubfolders(featuresDir: string, statuses: string[]): Promise<void> {
9
+ for (const status of statuses) {
10
+ await vscode.workspace.fs.createDirectory(vscode.Uri.file(path.join(featuresDir, status)))
11
+ }
12
+ }
13
+
14
+ export async function moveFeatureFile(
15
+ currentPath: string,
16
+ featuresDir: string,
17
+ newStatus: string,
18
+ attachments?: string[]
19
+ ): Promise<string> {
20
+ const filename = path.basename(currentPath)
21
+ const targetDir = path.join(featuresDir, newStatus)
22
+ let targetPath = path.join(targetDir, filename)
23
+
24
+ if (currentPath === targetPath) return currentPath
25
+
26
+ const ext = path.extname(filename)
27
+ const base = path.basename(filename, ext)
28
+ let counter = 1
29
+ while (await fileExists(targetPath)) {
30
+ targetPath = path.join(targetDir, `${base}-${counter}${ext}`)
31
+ counter++
32
+ }
33
+
34
+ await vscode.workspace.fs.createDirectory(vscode.Uri.file(targetDir))
35
+ await vscode.workspace.fs.rename(vscode.Uri.file(currentPath), vscode.Uri.file(targetPath))
36
+
37
+ if (attachments && attachments.length > 0) {
38
+ const sourceDir = path.dirname(currentPath)
39
+ for (const attachment of attachments) {
40
+ const srcAttach = path.join(sourceDir, attachment)
41
+ const destAttach = path.join(targetDir, attachment)
42
+ try {
43
+ if (await fileExists(srcAttach)) {
44
+ await vscode.workspace.fs.rename(
45
+ vscode.Uri.file(srcAttach),
46
+ vscode.Uri.file(destAttach)
47
+ )
48
+ }
49
+ } catch {
50
+ // Best effort -- skip failed attachment moves
51
+ }
52
+ }
53
+ }
54
+
55
+ return targetPath
56
+ }
57
+
58
+ export async function renameFeatureFile(currentPath: string, newFilename: string): Promise<string> {
59
+ const dir = path.dirname(currentPath)
60
+ const newPath = path.join(dir, `${newFilename}.md`)
61
+ if (currentPath === newPath) return currentPath
62
+ await vscode.workspace.fs.rename(vscode.Uri.file(currentPath), vscode.Uri.file(newPath))
63
+ return newPath
64
+ }
65
+
66
+ export function getStatusFromPath(filePath: string, featuresDir: string): string | null {
67
+ const relative = path.relative(featuresDir, filePath)
68
+ const parts = relative.split(path.sep)
69
+ if (parts.length === 2) {
70
+ return parts[0]
71
+ }
72
+ return null
73
+ }
74
+
75
+ async function fileExists(filePath: string): Promise<boolean> {
76
+ try {
77
+ await vscode.workspace.fs.stat(vscode.Uri.file(filePath))
78
+ return true
79
+ } catch {
80
+ return false
81
+ }
82
+ }