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,973 @@
|
|
|
1
|
+
import * as vscode from 'vscode'
|
|
2
|
+
import * as path from 'path'
|
|
3
|
+
import { generateKeyBetween, generateNKeysBetween } from 'fractional-indexing'
|
|
4
|
+
import { getTitleFromContent, generateFeatureFilename, extractNumericId } from '../shared/types'
|
|
5
|
+
import type { Feature, FeatureStatus, Priority, KanbanColumn, FeatureFrontmatter, CardDisplaySettings } from '../shared/types'
|
|
6
|
+
import { parseFeatureFile, serializeFeature } from '../sdk/parser'
|
|
7
|
+
import { ensureStatusSubfolders, moveFeatureFile, renameFeatureFile, getFeatureFilePath, getStatusFromPath } from './featureFileUtils'
|
|
8
|
+
import { readConfig, writeConfig, configToSettings, settingsToConfig, allocateCardId, syncCardIdCounter, CONFIG_FILENAME, DEFAULT_CONFIG } from '../shared/config'
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
interface CreateFeatureData {
|
|
12
|
+
status: FeatureStatus
|
|
13
|
+
priority: Priority
|
|
14
|
+
content: string
|
|
15
|
+
assignee: string | null
|
|
16
|
+
dueDate: string | null
|
|
17
|
+
labels: string[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class KanbanPanel {
|
|
21
|
+
public static readonly viewType = 'kanban-lite.panel'
|
|
22
|
+
public static currentPanel: KanbanPanel | undefined
|
|
23
|
+
|
|
24
|
+
private readonly _panel: vscode.WebviewPanel
|
|
25
|
+
private readonly _extensionUri: vscode.Uri
|
|
26
|
+
private readonly _context: vscode.ExtensionContext
|
|
27
|
+
private _features: Feature[] = []
|
|
28
|
+
private _disposables: vscode.Disposable[] = []
|
|
29
|
+
private _fileWatcher: vscode.FileSystemWatcher | undefined
|
|
30
|
+
private _configWatcher: vscode.FileSystemWatcher | undefined
|
|
31
|
+
private _currentEditingFeatureId: string | null = null
|
|
32
|
+
private _lastWrittenContent: string = ''
|
|
33
|
+
private _migrating = false
|
|
34
|
+
private _onDisposeCallbacks: (() => void)[] = []
|
|
35
|
+
|
|
36
|
+
public static createOrShow(extensionUri: vscode.Uri, context: vscode.ExtensionContext) {
|
|
37
|
+
const column = vscode.window.activeTextEditor
|
|
38
|
+
? vscode.window.activeTextEditor.viewColumn
|
|
39
|
+
: undefined
|
|
40
|
+
|
|
41
|
+
// If we already have a panel, show it
|
|
42
|
+
if (KanbanPanel.currentPanel) {
|
|
43
|
+
KanbanPanel.currentPanel._panel.reveal(column)
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Otherwise, create a new panel
|
|
48
|
+
const panel = vscode.window.createWebviewPanel(
|
|
49
|
+
KanbanPanel.viewType,
|
|
50
|
+
'Kanban Board',
|
|
51
|
+
column || vscode.ViewColumn.One,
|
|
52
|
+
{
|
|
53
|
+
enableScripts: true,
|
|
54
|
+
retainContextWhenHidden: true,
|
|
55
|
+
localResourceRoots: [
|
|
56
|
+
vscode.Uri.joinPath(extensionUri, 'dist'),
|
|
57
|
+
vscode.Uri.joinPath(extensionUri, 'dist', 'webview')
|
|
58
|
+
]
|
|
59
|
+
}
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
// Set the tab icon
|
|
63
|
+
panel.iconPath = {
|
|
64
|
+
light: vscode.Uri.joinPath(extensionUri, 'resources', 'kanban-light.svg'),
|
|
65
|
+
dark: vscode.Uri.joinPath(extensionUri, 'resources', 'kanban-dark.svg')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
KanbanPanel.currentPanel = new KanbanPanel(panel, extensionUri, context)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
public static revive(panel: vscode.WebviewPanel, extensionUri: vscode.Uri, context: vscode.ExtensionContext) {
|
|
72
|
+
KanbanPanel.currentPanel = new KanbanPanel(panel, extensionUri, context)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private constructor(panel: vscode.WebviewPanel, extensionUri: vscode.Uri, context: vscode.ExtensionContext) {
|
|
76
|
+
this._panel = panel
|
|
77
|
+
this._extensionUri = extensionUri
|
|
78
|
+
this._context = context
|
|
79
|
+
|
|
80
|
+
// Ensure webview options are set (critical for deserialization after reload)
|
|
81
|
+
this._panel.webview.options = {
|
|
82
|
+
enableScripts: true,
|
|
83
|
+
localResourceRoots: [
|
|
84
|
+
vscode.Uri.joinPath(extensionUri, 'dist'),
|
|
85
|
+
vscode.Uri.joinPath(extensionUri, 'dist', 'webview')
|
|
86
|
+
]
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Set the webview's initial html content
|
|
90
|
+
this._update()
|
|
91
|
+
|
|
92
|
+
// Listen for when the panel is disposed
|
|
93
|
+
this._panel.onDidDispose(() => this.dispose(), null, this._disposables)
|
|
94
|
+
|
|
95
|
+
// Handle messages from the webview
|
|
96
|
+
this._panel.webview.onDidReceiveMessage(
|
|
97
|
+
async (message) => {
|
|
98
|
+
switch (message.type) {
|
|
99
|
+
case 'ready':
|
|
100
|
+
await this._loadFeatures()
|
|
101
|
+
this._sendFeaturesToWebview()
|
|
102
|
+
break
|
|
103
|
+
case 'createFeature': {
|
|
104
|
+
await this._createFeature(message.data)
|
|
105
|
+
const createRoot = this._getWorkspaceRoot()
|
|
106
|
+
const createCfg = createRoot ? readConfig(createRoot) : DEFAULT_CONFIG
|
|
107
|
+
if (createCfg.markdownEditorMode) {
|
|
108
|
+
// Open the newly created feature in native editor
|
|
109
|
+
const created = this._features[this._features.length - 1]
|
|
110
|
+
if (created) {
|
|
111
|
+
this._openFeatureInNativeEditor(created.id)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
break
|
|
115
|
+
}
|
|
116
|
+
case 'moveFeature':
|
|
117
|
+
await this._moveFeature(message.featureId, message.newStatus, message.newOrder)
|
|
118
|
+
break
|
|
119
|
+
case 'deleteFeature':
|
|
120
|
+
await this._deleteFeature(message.featureId)
|
|
121
|
+
break
|
|
122
|
+
case 'updateFeature':
|
|
123
|
+
await this._updateFeature(message.featureId, message.updates)
|
|
124
|
+
break
|
|
125
|
+
case 'openFeature': {
|
|
126
|
+
const openRoot = this._getWorkspaceRoot()
|
|
127
|
+
const openCfg = openRoot ? readConfig(openRoot) : DEFAULT_CONFIG
|
|
128
|
+
if (openCfg.markdownEditorMode) {
|
|
129
|
+
this._openFeatureInNativeEditor(message.featureId)
|
|
130
|
+
} else {
|
|
131
|
+
await this._sendFeatureContent(message.featureId)
|
|
132
|
+
}
|
|
133
|
+
break
|
|
134
|
+
}
|
|
135
|
+
case 'saveFeatureContent':
|
|
136
|
+
await this._saveFeatureContent(message.featureId, message.content, message.frontmatter)
|
|
137
|
+
break
|
|
138
|
+
case 'closeFeature':
|
|
139
|
+
this._currentEditingFeatureId = null
|
|
140
|
+
break
|
|
141
|
+
case 'openFile': {
|
|
142
|
+
const feat = this._features.find(f => f.id === message.featureId)
|
|
143
|
+
if (feat) {
|
|
144
|
+
const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(feat.filePath))
|
|
145
|
+
await vscode.window.showTextDocument(doc, { viewColumn: vscode.ViewColumn.Beside })
|
|
146
|
+
}
|
|
147
|
+
break
|
|
148
|
+
}
|
|
149
|
+
case 'openSettings': {
|
|
150
|
+
const settingsRoot = this._getWorkspaceRoot()
|
|
151
|
+
const settingsCfg = settingsRoot ? readConfig(settingsRoot) : { ...DEFAULT_CONFIG }
|
|
152
|
+
const openSettings = configToSettings(settingsCfg)
|
|
153
|
+
this._panel.webview.postMessage({ type: 'showSettings', settings: openSettings })
|
|
154
|
+
break
|
|
155
|
+
}
|
|
156
|
+
case 'focusMenuBar':
|
|
157
|
+
// Focus must leave the webview before focusMenuBar works (VS Code limitation).
|
|
158
|
+
// Use Activity Bar (not Side Bar) — it's always visible and won't expand a collapsed sidebar.
|
|
159
|
+
await vscode.commands.executeCommand('workbench.action.focusActivityBar')
|
|
160
|
+
await vscode.commands.executeCommand('workbench.action.focusMenuBar')
|
|
161
|
+
break
|
|
162
|
+
case 'addAttachment':
|
|
163
|
+
await this._addAttachment(message.featureId)
|
|
164
|
+
break
|
|
165
|
+
case 'openAttachment':
|
|
166
|
+
await this._openAttachment(message.featureId, message.attachment)
|
|
167
|
+
break
|
|
168
|
+
case 'removeAttachment':
|
|
169
|
+
await this._removeAttachment(message.featureId, message.attachment)
|
|
170
|
+
break
|
|
171
|
+
case 'startWithAI':
|
|
172
|
+
await this._startWithAI(message.agent, message.permissionMode)
|
|
173
|
+
break
|
|
174
|
+
case 'saveSettings':
|
|
175
|
+
await this._saveSettings(message.settings)
|
|
176
|
+
break
|
|
177
|
+
case 'addColumn':
|
|
178
|
+
await this._addColumn(message.column)
|
|
179
|
+
break
|
|
180
|
+
case 'editColumn':
|
|
181
|
+
await this._editColumn(message.columnId, message.updates)
|
|
182
|
+
break
|
|
183
|
+
case 'removeColumn':
|
|
184
|
+
await this._removeColumn(message.columnId)
|
|
185
|
+
break
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
null,
|
|
189
|
+
this._disposables
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
// Set up file watcher for feature files
|
|
193
|
+
this._setupFileWatcher()
|
|
194
|
+
|
|
195
|
+
// Watch .kanban.json for config changes
|
|
196
|
+
this._setupConfigWatcher()
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private _setupFileWatcher(): void {
|
|
200
|
+
// Dispose old watcher if re-setting up (e.g. featuresDirectory changed)
|
|
201
|
+
if (this._fileWatcher) {
|
|
202
|
+
this._fileWatcher.dispose()
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const featuresDir = this._getWorkspaceFeaturesDir()
|
|
206
|
+
if (!featuresDir) return
|
|
207
|
+
|
|
208
|
+
// Watch for changes in the features directory (recursive for status subfolders)
|
|
209
|
+
const pattern = new vscode.RelativePattern(featuresDir, '**/*.md')
|
|
210
|
+
this._fileWatcher = vscode.workspace.createFileSystemWatcher(pattern)
|
|
211
|
+
|
|
212
|
+
// Debounce to avoid multiple rapid updates
|
|
213
|
+
let debounceTimer: NodeJS.Timeout | undefined
|
|
214
|
+
|
|
215
|
+
const handleFileChange = (uri?: vscode.Uri) => {
|
|
216
|
+
if (this._migrating) return
|
|
217
|
+
if (debounceTimer) clearTimeout(debounceTimer)
|
|
218
|
+
debounceTimer = setTimeout(async () => {
|
|
219
|
+
await this._loadFeatures()
|
|
220
|
+
this._sendFeaturesToWebview()
|
|
221
|
+
|
|
222
|
+
// If the changed file is the currently-edited feature, check for external changes
|
|
223
|
+
if (this._currentEditingFeatureId && uri) {
|
|
224
|
+
const editingFeature = this._features.find(f => f.id === this._currentEditingFeatureId)
|
|
225
|
+
if (editingFeature && editingFeature.filePath === uri.fsPath) {
|
|
226
|
+
const currentContent = this._serializeFeature(editingFeature)
|
|
227
|
+
if (currentContent !== this._lastWrittenContent) {
|
|
228
|
+
// External change detected — refresh the editor
|
|
229
|
+
this._sendFeatureContent(this._currentEditingFeatureId)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}, 100)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
this._fileWatcher.onDidChange((uri) => handleFileChange(uri), null, this._disposables)
|
|
237
|
+
this._fileWatcher.onDidCreate((uri) => handleFileChange(uri), null, this._disposables)
|
|
238
|
+
this._fileWatcher.onDidDelete((uri) => handleFileChange(uri), null, this._disposables)
|
|
239
|
+
|
|
240
|
+
this._disposables.push(this._fileWatcher)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private _setupConfigWatcher(): void {
|
|
244
|
+
if (this._configWatcher) {
|
|
245
|
+
this._configWatcher.dispose()
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const root = this._getWorkspaceRoot()
|
|
249
|
+
if (!root) return
|
|
250
|
+
|
|
251
|
+
const pattern = new vscode.RelativePattern(root, CONFIG_FILENAME)
|
|
252
|
+
this._configWatcher = vscode.workspace.createFileSystemWatcher(pattern)
|
|
253
|
+
|
|
254
|
+
let lastFeaturesDir = this._getWorkspaceFeaturesDir()
|
|
255
|
+
|
|
256
|
+
const handleConfigChange = () => {
|
|
257
|
+
const newFeaturesDir = this._getWorkspaceFeaturesDir()
|
|
258
|
+
if (lastFeaturesDir !== newFeaturesDir) {
|
|
259
|
+
lastFeaturesDir = newFeaturesDir
|
|
260
|
+
this._setupFileWatcher()
|
|
261
|
+
this._loadFeatures().then(() => this._sendFeaturesToWebview())
|
|
262
|
+
} else {
|
|
263
|
+
this._sendFeaturesToWebview()
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
this._configWatcher.onDidChange(handleConfigChange, null, this._disposables)
|
|
268
|
+
this._configWatcher.onDidCreate(handleConfigChange, null, this._disposables)
|
|
269
|
+
this._configWatcher.onDidDelete(handleConfigChange, null, this._disposables)
|
|
270
|
+
this._disposables.push(this._configWatcher)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
public onDispose(callback: () => void): void {
|
|
274
|
+
this._onDisposeCallbacks.push(callback)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
public dispose() {
|
|
278
|
+
KanbanPanel.currentPanel = undefined
|
|
279
|
+
|
|
280
|
+
for (const cb of this._onDisposeCallbacks) {
|
|
281
|
+
cb()
|
|
282
|
+
}
|
|
283
|
+
this._onDisposeCallbacks = []
|
|
284
|
+
|
|
285
|
+
this._panel.dispose()
|
|
286
|
+
|
|
287
|
+
while (this._disposables.length) {
|
|
288
|
+
const x = this._disposables.pop()
|
|
289
|
+
if (x) {
|
|
290
|
+
x.dispose()
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private _update() {
|
|
296
|
+
this._panel.webview.html = this._getHtmlForWebview(this._panel.webview)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private _getHtmlForWebview(webview: vscode.Webview): string {
|
|
300
|
+
const scriptUri = webview.asWebviewUri(
|
|
301
|
+
vscode.Uri.joinPath(this._extensionUri, 'dist', 'webview', 'index.js')
|
|
302
|
+
)
|
|
303
|
+
const styleUri = webview.asWebviewUri(
|
|
304
|
+
vscode.Uri.joinPath(this._extensionUri, 'dist', 'webview', 'style.css')
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
const nonce = this._getNonce()
|
|
308
|
+
|
|
309
|
+
return `<!DOCTYPE html>
|
|
310
|
+
<html lang="en">
|
|
311
|
+
<head>
|
|
312
|
+
<meta charset="UTF-8">
|
|
313
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
314
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src ${webview.cspSource} 'unsafe-inline'; script-src ${webview.cspSource} 'nonce-${nonce}';">
|
|
315
|
+
<link href="${styleUri}" rel="stylesheet">
|
|
316
|
+
<title>Kanban Board</title>
|
|
317
|
+
</head>
|
|
318
|
+
<body>
|
|
319
|
+
<div id="root"></div>
|
|
320
|
+
<script type="module" nonce="${nonce}" src="${scriptUri}"></script>
|
|
321
|
+
</body>
|
|
322
|
+
</html>`
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
private _getNonce(): string {
|
|
326
|
+
let text = ''
|
|
327
|
+
const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
|
328
|
+
for (let i = 0; i < 32; i++) {
|
|
329
|
+
text += possible.charAt(Math.floor(Math.random() * possible.length))
|
|
330
|
+
}
|
|
331
|
+
return text
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
private _getWorkspaceRoot(): string | null {
|
|
335
|
+
const workspaceFolders = vscode.workspace.workspaceFolders
|
|
336
|
+
if (!workspaceFolders || workspaceFolders.length === 0) return null
|
|
337
|
+
return workspaceFolders[0].uri.fsPath
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
private _getWorkspaceFeaturesDir(): string | null {
|
|
341
|
+
const root = this._getWorkspaceRoot()
|
|
342
|
+
if (!root) return null
|
|
343
|
+
const config = readConfig(root)
|
|
344
|
+
return path.join(root, config.featuresDirectory)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
private async _ensureFeaturesDir(): Promise<string | null> {
|
|
348
|
+
const featuresDir = this._getWorkspaceFeaturesDir()
|
|
349
|
+
if (!featuresDir) return null
|
|
350
|
+
|
|
351
|
+
try {
|
|
352
|
+
await vscode.workspace.fs.createDirectory(vscode.Uri.file(featuresDir))
|
|
353
|
+
const columnIds = this._getColumns().map(c => c.id)
|
|
354
|
+
await ensureStatusSubfolders(featuresDir, columnIds)
|
|
355
|
+
return featuresDir
|
|
356
|
+
} catch {
|
|
357
|
+
return null
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
private async _loadFeatures(): Promise<void> {
|
|
362
|
+
const featuresDir = this._getWorkspaceFeaturesDir()
|
|
363
|
+
if (!featuresDir) {
|
|
364
|
+
this._features = []
|
|
365
|
+
return
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
try {
|
|
369
|
+
await vscode.workspace.fs.createDirectory(vscode.Uri.file(featuresDir))
|
|
370
|
+
const columnIds = this._getColumns().map(c => c.id)
|
|
371
|
+
await ensureStatusSubfolders(featuresDir, columnIds)
|
|
372
|
+
|
|
373
|
+
// Phase 1: Migrate flat root .md files into their status subfolder
|
|
374
|
+
this._migrating = true
|
|
375
|
+
try {
|
|
376
|
+
const rootEntries = await vscode.workspace.fs.readDirectory(vscode.Uri.file(featuresDir))
|
|
377
|
+
for (const [name, type] of rootEntries) {
|
|
378
|
+
if (type !== vscode.FileType.File || !name.endsWith('.md')) continue
|
|
379
|
+
const filePath = path.join(featuresDir, name)
|
|
380
|
+
try {
|
|
381
|
+
const content = new TextDecoder().decode(await vscode.workspace.fs.readFile(vscode.Uri.file(filePath)))
|
|
382
|
+
const feature = this._parseFeatureFile(content, filePath)
|
|
383
|
+
if (feature) {
|
|
384
|
+
await moveFeatureFile(filePath, featuresDir, feature.status, feature.attachments)
|
|
385
|
+
}
|
|
386
|
+
} catch {
|
|
387
|
+
// Skip files that fail to migrate
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
} finally {
|
|
391
|
+
this._migrating = false
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Phase 2: Load .md files from ALL subdirectories
|
|
395
|
+
const features: Feature[] = []
|
|
396
|
+
const topEntries = await vscode.workspace.fs.readDirectory(vscode.Uri.file(featuresDir))
|
|
397
|
+
for (const [name, type] of topEntries) {
|
|
398
|
+
if (type !== vscode.FileType.Directory || name.startsWith('.')) continue
|
|
399
|
+
const subdir = path.join(featuresDir, name)
|
|
400
|
+
try {
|
|
401
|
+
const entries = await vscode.workspace.fs.readDirectory(vscode.Uri.file(subdir))
|
|
402
|
+
for (const [file, fileType] of entries) {
|
|
403
|
+
if (fileType !== vscode.FileType.File || !file.endsWith('.md')) continue
|
|
404
|
+
const filePath = path.join(subdir, file)
|
|
405
|
+
const content = new TextDecoder().decode(await vscode.workspace.fs.readFile(vscode.Uri.file(filePath)))
|
|
406
|
+
const feature = this._parseFeatureFile(content, filePath)
|
|
407
|
+
if (feature) features.push(feature)
|
|
408
|
+
}
|
|
409
|
+
} catch {
|
|
410
|
+
// Skip unreadable directories
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Phase 3: Reconcile status ↔ folder mismatches
|
|
415
|
+
this._migrating = true
|
|
416
|
+
try {
|
|
417
|
+
for (const feature of features) {
|
|
418
|
+
const pathStatus = getStatusFromPath(feature.filePath, featuresDir)
|
|
419
|
+
if (pathStatus !== null && pathStatus !== feature.status) {
|
|
420
|
+
try {
|
|
421
|
+
const newPath = await moveFeatureFile(feature.filePath, featuresDir, feature.status, feature.attachments)
|
|
422
|
+
feature.filePath = newPath
|
|
423
|
+
} catch {
|
|
424
|
+
// Will retry on next load
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
} finally {
|
|
429
|
+
this._migrating = false
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Migrate legacy integer order values to fractional indices
|
|
433
|
+
const hasLegacyOrder = features.some(f => /^\d+$/.test(f.order))
|
|
434
|
+
if (hasLegacyOrder) {
|
|
435
|
+
const byStatus = new Map<string, Feature[]>()
|
|
436
|
+
for (const f of features) {
|
|
437
|
+
const list = byStatus.get(f.status) || []
|
|
438
|
+
list.push(f)
|
|
439
|
+
byStatus.set(f.status, list)
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const migrationWrites: Feature[] = []
|
|
443
|
+
for (const columnFeatures of byStatus.values()) {
|
|
444
|
+
columnFeatures.sort((a, b) => parseInt(a.order) - parseInt(b.order))
|
|
445
|
+
const keys = generateNKeysBetween(null, null, columnFeatures.length)
|
|
446
|
+
for (let i = 0; i < columnFeatures.length; i++) {
|
|
447
|
+
columnFeatures[i].order = keys[i]
|
|
448
|
+
migrationWrites.push(columnFeatures[i])
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
for (const f of migrationWrites) {
|
|
453
|
+
const content = this._serializeFeature(f)
|
|
454
|
+
await vscode.workspace.fs.writeFile(vscode.Uri.file(f.filePath), new TextEncoder().encode(content))
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Sync ID counter with existing cards
|
|
459
|
+
const root = this._getWorkspaceRoot()
|
|
460
|
+
if (root) {
|
|
461
|
+
const numericIds = features
|
|
462
|
+
.map(f => parseInt(f.id, 10))
|
|
463
|
+
.filter(n => !Number.isNaN(n))
|
|
464
|
+
if (numericIds.length > 0) {
|
|
465
|
+
syncCardIdCounter(root, numericIds)
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
this._features = features.sort((a, b) => (a.order < b.order ? -1 : a.order > b.order ? 1 : 0))
|
|
470
|
+
} catch {
|
|
471
|
+
this._features = []
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
private _parseFeatureFile(content: string, filePath: string): Feature | null {
|
|
476
|
+
return parseFeatureFile(content, filePath)
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
private _serializeFeature(feature: Feature): string {
|
|
480
|
+
return serializeFeature(feature)
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
public triggerCreateDialog(): void {
|
|
484
|
+
this._panel.webview.postMessage({ type: 'triggerCreateDialog' })
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
public openFeature(featureId: string): void {
|
|
488
|
+
const root = this._getWorkspaceRoot()
|
|
489
|
+
const cfg = root ? readConfig(root) : DEFAULT_CONFIG
|
|
490
|
+
if (cfg.markdownEditorMode) {
|
|
491
|
+
this._openFeatureInNativeEditor(featureId)
|
|
492
|
+
} else {
|
|
493
|
+
this._sendFeatureContent(featureId)
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
private async _createFeature(data: CreateFeatureData): Promise<void> {
|
|
498
|
+
const featuresDir = await this._ensureFeaturesDir()
|
|
499
|
+
if (!featuresDir) {
|
|
500
|
+
vscode.window.showErrorMessage('No workspace folder open')
|
|
501
|
+
return
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const title = getTitleFromContent(data.content)
|
|
505
|
+
const workspaceRoot = this._getWorkspaceRoot()
|
|
506
|
+
if (!workspaceRoot) return
|
|
507
|
+
const numericId = allocateCardId(workspaceRoot)
|
|
508
|
+
const filename = generateFeatureFilename(numericId, title)
|
|
509
|
+
const now = new Date().toISOString()
|
|
510
|
+
const featuresInStatus = this._features
|
|
511
|
+
.filter(f => f.status === data.status)
|
|
512
|
+
.sort((a, b) => (a.order < b.order ? -1 : a.order > b.order ? 1 : 0))
|
|
513
|
+
const lastOrder = featuresInStatus.length > 0 ? featuresInStatus[featuresInStatus.length - 1].order : null
|
|
514
|
+
|
|
515
|
+
const feature: Feature = {
|
|
516
|
+
id: String(numericId),
|
|
517
|
+
status: data.status,
|
|
518
|
+
priority: data.priority,
|
|
519
|
+
assignee: data.assignee,
|
|
520
|
+
dueDate: data.dueDate,
|
|
521
|
+
created: now,
|
|
522
|
+
modified: now,
|
|
523
|
+
completedAt: data.status === 'done' ? now : null,
|
|
524
|
+
labels: data.labels,
|
|
525
|
+
attachments: [],
|
|
526
|
+
order: generateKeyBetween(lastOrder, null),
|
|
527
|
+
content: data.content,
|
|
528
|
+
filePath: getFeatureFilePath(featuresDir, data.status, filename)
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
await vscode.workspace.fs.createDirectory(vscode.Uri.file(path.dirname(feature.filePath)))
|
|
532
|
+
const content = this._serializeFeature(feature)
|
|
533
|
+
await vscode.workspace.fs.writeFile(vscode.Uri.file(feature.filePath), new TextEncoder().encode(content))
|
|
534
|
+
|
|
535
|
+
this._features.push(feature)
|
|
536
|
+
this._sendFeaturesToWebview()
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
private async _moveFeature(featureId: string, newStatus: string, newOrder: number): Promise<void> {
|
|
540
|
+
const feature = this._features.find(f => f.id === featureId)
|
|
541
|
+
if (!feature) return
|
|
542
|
+
|
|
543
|
+
const featuresDir = this._getWorkspaceFeaturesDir()
|
|
544
|
+
if (!featuresDir) return
|
|
545
|
+
|
|
546
|
+
const oldStatus = feature.status
|
|
547
|
+
const statusChanged = oldStatus !== newStatus
|
|
548
|
+
|
|
549
|
+
// Update feature status
|
|
550
|
+
feature.status = newStatus as FeatureStatus
|
|
551
|
+
feature.modified = new Date().toISOString()
|
|
552
|
+
if (statusChanged) {
|
|
553
|
+
feature.completedAt = newStatus === 'done' ? new Date().toISOString() : null
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// Get sorted features in the target column (excluding the moved feature)
|
|
557
|
+
const targetColumnFeatures = this._features
|
|
558
|
+
.filter(f => f.status === newStatus && f.id !== featureId)
|
|
559
|
+
.sort((a, b) => (a.order < b.order ? -1 : a.order > b.order ? 1 : 0))
|
|
560
|
+
|
|
561
|
+
// Compute fractional index between neighbors at the target position
|
|
562
|
+
const clampedOrder = Math.max(0, Math.min(newOrder, targetColumnFeatures.length))
|
|
563
|
+
const before = clampedOrder > 0 ? targetColumnFeatures[clampedOrder - 1].order : null
|
|
564
|
+
const after = clampedOrder < targetColumnFeatures.length ? targetColumnFeatures[clampedOrder].order : null
|
|
565
|
+
feature.order = generateKeyBetween(before, after)
|
|
566
|
+
|
|
567
|
+
// Only the moved feature needs to be written
|
|
568
|
+
const content = this._serializeFeature(feature)
|
|
569
|
+
await vscode.workspace.fs.writeFile(vscode.Uri.file(feature.filePath), new TextEncoder().encode(content))
|
|
570
|
+
|
|
571
|
+
// Move file when status changes
|
|
572
|
+
if (statusChanged) {
|
|
573
|
+
this._migrating = true
|
|
574
|
+
try {
|
|
575
|
+
const newPath = await moveFeatureFile(feature.filePath, featuresDir, newStatus, feature.attachments)
|
|
576
|
+
feature.filePath = newPath
|
|
577
|
+
} catch {
|
|
578
|
+
// Move failed; file stays in old folder, will reconcile on next load
|
|
579
|
+
} finally {
|
|
580
|
+
this._migrating = false
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
this._sendFeaturesToWebview()
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
private async _deleteFeature(featureId: string): Promise<void> {
|
|
588
|
+
const feature = this._features.find(f => f.id === featureId)
|
|
589
|
+
if (!feature) return
|
|
590
|
+
|
|
591
|
+
try {
|
|
592
|
+
await vscode.workspace.fs.delete(vscode.Uri.file(feature.filePath))
|
|
593
|
+
this._features = this._features.filter(f => f.id !== featureId)
|
|
594
|
+
this._sendFeaturesToWebview()
|
|
595
|
+
} catch (err) {
|
|
596
|
+
vscode.window.showErrorMessage(`Failed to delete feature: ${err}`)
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
private async _updateFeature(featureId: string, updates: Partial<Feature>): Promise<void> {
|
|
601
|
+
const feature = this._features.find(f => f.id === featureId)
|
|
602
|
+
if (!feature) return
|
|
603
|
+
|
|
604
|
+
const featuresDir = this._getWorkspaceFeaturesDir()
|
|
605
|
+
if (!featuresDir) return
|
|
606
|
+
|
|
607
|
+
const oldStatus = feature.status
|
|
608
|
+
const oldTitle = getTitleFromContent(feature.content)
|
|
609
|
+
|
|
610
|
+
// Merge updates
|
|
611
|
+
Object.assign(feature, updates)
|
|
612
|
+
feature.modified = new Date().toISOString()
|
|
613
|
+
if (oldStatus !== feature.status) {
|
|
614
|
+
feature.completedAt = feature.status === 'done' ? new Date().toISOString() : null
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// Persist to file
|
|
618
|
+
const content = this._serializeFeature(feature)
|
|
619
|
+
await vscode.workspace.fs.writeFile(vscode.Uri.file(feature.filePath), new TextEncoder().encode(content))
|
|
620
|
+
|
|
621
|
+
// Rename file if title changed (numeric-ID cards only)
|
|
622
|
+
const newTitle = getTitleFromContent(feature.content)
|
|
623
|
+
const numId = extractNumericId(feature.id)
|
|
624
|
+
if (numId !== null && newTitle !== oldTitle) {
|
|
625
|
+
const newFilename = generateFeatureFilename(numId, newTitle)
|
|
626
|
+
this._migrating = true
|
|
627
|
+
try {
|
|
628
|
+
feature.filePath = await renameFeatureFile(feature.filePath, newFilename)
|
|
629
|
+
} catch { /* retry next load */ } finally { this._migrating = false }
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// Move file when status changes
|
|
633
|
+
if (oldStatus !== feature.status) {
|
|
634
|
+
this._migrating = true
|
|
635
|
+
try {
|
|
636
|
+
const newPath = await moveFeatureFile(feature.filePath, featuresDir, feature.status, feature.attachments)
|
|
637
|
+
feature.filePath = newPath
|
|
638
|
+
} catch {
|
|
639
|
+
// Move failed; file stays in old folder, will reconcile on next load
|
|
640
|
+
} finally {
|
|
641
|
+
this._migrating = false
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
this._sendFeaturesToWebview()
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
private async _openFeatureInNativeEditor(featureId: string): Promise<void> {
|
|
649
|
+
const feature = this._features.find(f => f.id === featureId)
|
|
650
|
+
if (!feature) return
|
|
651
|
+
|
|
652
|
+
// Use a fixed column beside the panel so repeated clicks reuse the same split
|
|
653
|
+
const panelColumn = this._panel.viewColumn ?? vscode.ViewColumn.One
|
|
654
|
+
const targetColumn = panelColumn === vscode.ViewColumn.One ? vscode.ViewColumn.Two : vscode.ViewColumn.Beside
|
|
655
|
+
|
|
656
|
+
const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(feature.filePath))
|
|
657
|
+
await vscode.window.showTextDocument(doc, { viewColumn: targetColumn, preview: true })
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
private async _sendFeatureContent(featureId: string): Promise<void> {
|
|
661
|
+
const feature = this._features.find(f => f.id === featureId)
|
|
662
|
+
if (!feature) return
|
|
663
|
+
|
|
664
|
+
this._currentEditingFeatureId = featureId
|
|
665
|
+
|
|
666
|
+
const frontmatter: FeatureFrontmatter = {
|
|
667
|
+
id: feature.id,
|
|
668
|
+
status: feature.status,
|
|
669
|
+
priority: feature.priority,
|
|
670
|
+
assignee: feature.assignee,
|
|
671
|
+
dueDate: feature.dueDate,
|
|
672
|
+
created: feature.created,
|
|
673
|
+
modified: feature.modified,
|
|
674
|
+
completedAt: feature.completedAt,
|
|
675
|
+
labels: feature.labels,
|
|
676
|
+
attachments: feature.attachments,
|
|
677
|
+
order: feature.order
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
this._panel.webview.postMessage({
|
|
681
|
+
type: 'featureContent',
|
|
682
|
+
featureId: feature.id,
|
|
683
|
+
content: feature.content,
|
|
684
|
+
frontmatter
|
|
685
|
+
})
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
private async _saveFeatureContent(
|
|
689
|
+
featureId: string,
|
|
690
|
+
content: string,
|
|
691
|
+
frontmatter: FeatureFrontmatter
|
|
692
|
+
): Promise<void> {
|
|
693
|
+
const feature = this._features.find(f => f.id === featureId)
|
|
694
|
+
if (!feature) return
|
|
695
|
+
|
|
696
|
+
const featuresDir = this._getWorkspaceFeaturesDir()
|
|
697
|
+
if (!featuresDir) return
|
|
698
|
+
|
|
699
|
+
const oldStatus = feature.status
|
|
700
|
+
const oldTitle = getTitleFromContent(feature.content)
|
|
701
|
+
|
|
702
|
+
// Update feature in memory
|
|
703
|
+
feature.content = content
|
|
704
|
+
feature.status = frontmatter.status
|
|
705
|
+
feature.priority = frontmatter.priority
|
|
706
|
+
feature.assignee = frontmatter.assignee
|
|
707
|
+
feature.dueDate = frontmatter.dueDate
|
|
708
|
+
feature.labels = frontmatter.labels
|
|
709
|
+
feature.attachments = frontmatter.attachments || feature.attachments || []
|
|
710
|
+
feature.modified = new Date().toISOString()
|
|
711
|
+
if (oldStatus !== feature.status) {
|
|
712
|
+
feature.completedAt = feature.status === 'done' ? new Date().toISOString() : null
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// Save to file
|
|
716
|
+
const fileContent = this._serializeFeature(feature)
|
|
717
|
+
this._lastWrittenContent = fileContent
|
|
718
|
+
await vscode.workspace.fs.writeFile(vscode.Uri.file(feature.filePath), new TextEncoder().encode(fileContent))
|
|
719
|
+
|
|
720
|
+
// Rename file if title changed (numeric-ID cards only)
|
|
721
|
+
const saveNewTitle = getTitleFromContent(feature.content)
|
|
722
|
+
const saveNumId = extractNumericId(feature.id)
|
|
723
|
+
if (saveNumId !== null && saveNewTitle !== oldTitle) {
|
|
724
|
+
const newFilename = generateFeatureFilename(saveNumId, saveNewTitle)
|
|
725
|
+
this._migrating = true
|
|
726
|
+
try {
|
|
727
|
+
feature.filePath = await renameFeatureFile(feature.filePath, newFilename)
|
|
728
|
+
} catch { /* retry next load */ } finally { this._migrating = false }
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// Move file when status changes
|
|
732
|
+
if (oldStatus !== feature.status) {
|
|
733
|
+
this._migrating = true
|
|
734
|
+
try {
|
|
735
|
+
const newPath = await moveFeatureFile(feature.filePath, featuresDir, feature.status, feature.attachments)
|
|
736
|
+
feature.filePath = newPath
|
|
737
|
+
} catch {
|
|
738
|
+
// Move failed; file stays in old folder, will reconcile on next load
|
|
739
|
+
} finally {
|
|
740
|
+
this._migrating = false
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// Update all features in webview
|
|
745
|
+
this._sendFeaturesToWebview()
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
private async _addAttachment(featureId: string): Promise<void> {
|
|
749
|
+
const feature = this._features.find(f => f.id === featureId)
|
|
750
|
+
if (!feature) return
|
|
751
|
+
|
|
752
|
+
const uris = await vscode.window.showOpenDialog({
|
|
753
|
+
canSelectMany: true,
|
|
754
|
+
openLabel: 'Attach',
|
|
755
|
+
title: 'Select files to attach'
|
|
756
|
+
})
|
|
757
|
+
if (!uris || uris.length === 0) return
|
|
758
|
+
|
|
759
|
+
const featureDir = path.dirname(feature.filePath)
|
|
760
|
+
|
|
761
|
+
for (const uri of uris) {
|
|
762
|
+
const fileName = path.basename(uri.fsPath)
|
|
763
|
+
const destPath = path.join(featureDir, fileName)
|
|
764
|
+
|
|
765
|
+
// If file is not already in the feature directory, copy it
|
|
766
|
+
if (path.dirname(uri.fsPath) !== featureDir) {
|
|
767
|
+
await vscode.workspace.fs.copy(uri, vscode.Uri.file(destPath), { overwrite: true })
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// Add to attachments if not already present
|
|
771
|
+
if (!feature.attachments.includes(fileName)) {
|
|
772
|
+
feature.attachments.push(fileName)
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
feature.modified = new Date().toISOString()
|
|
777
|
+
const fileContent = this._serializeFeature(feature)
|
|
778
|
+
this._lastWrittenContent = fileContent
|
|
779
|
+
await vscode.workspace.fs.writeFile(vscode.Uri.file(feature.filePath), new TextEncoder().encode(fileContent))
|
|
780
|
+
|
|
781
|
+
this._sendFeaturesToWebview()
|
|
782
|
+
// Refresh the editor with updated frontmatter
|
|
783
|
+
if (this._currentEditingFeatureId === featureId) {
|
|
784
|
+
await this._sendFeatureContent(featureId)
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
private async _openAttachment(featureId: string, attachment: string): Promise<void> {
|
|
789
|
+
const feature = this._features.find(f => f.id === featureId)
|
|
790
|
+
if (!feature) return
|
|
791
|
+
|
|
792
|
+
const featureDir = path.dirname(feature.filePath)
|
|
793
|
+
const attachmentPath = path.resolve(featureDir, attachment)
|
|
794
|
+
|
|
795
|
+
try {
|
|
796
|
+
await vscode.workspace.fs.stat(vscode.Uri.file(attachmentPath))
|
|
797
|
+
const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(attachmentPath))
|
|
798
|
+
await vscode.window.showTextDocument(doc, { viewColumn: vscode.ViewColumn.Beside })
|
|
799
|
+
} catch {
|
|
800
|
+
// For binary files or files that can't be opened as text, reveal in OS
|
|
801
|
+
await vscode.env.openExternal(vscode.Uri.file(attachmentPath))
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
private async _removeAttachment(featureId: string, attachment: string): Promise<void> {
|
|
806
|
+
const feature = this._features.find(f => f.id === featureId)
|
|
807
|
+
if (!feature) return
|
|
808
|
+
|
|
809
|
+
feature.attachments = feature.attachments.filter(a => a !== attachment)
|
|
810
|
+
feature.modified = new Date().toISOString()
|
|
811
|
+
|
|
812
|
+
const fileContent = this._serializeFeature(feature)
|
|
813
|
+
this._lastWrittenContent = fileContent
|
|
814
|
+
await vscode.workspace.fs.writeFile(vscode.Uri.file(feature.filePath), new TextEncoder().encode(fileContent))
|
|
815
|
+
|
|
816
|
+
this._sendFeaturesToWebview()
|
|
817
|
+
if (this._currentEditingFeatureId === featureId) {
|
|
818
|
+
await this._sendFeatureContent(featureId)
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
private async _startWithAI(
|
|
823
|
+
agent?: 'claude' | 'codex' | 'opencode',
|
|
824
|
+
permissionMode?: 'default' | 'plan' | 'acceptEdits' | 'bypassPermissions'
|
|
825
|
+
): Promise<void> {
|
|
826
|
+
// Find the currently editing feature
|
|
827
|
+
const feature = this._features.find(f => f.id === this._currentEditingFeatureId)
|
|
828
|
+
if (!feature) {
|
|
829
|
+
vscode.window.showErrorMessage('No feature selected')
|
|
830
|
+
return
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// Parse title from the first # heading in content
|
|
834
|
+
const titleMatch = feature.content.match(/^#\s+(.+)$/m)
|
|
835
|
+
const title = titleMatch ? titleMatch[1].trim() : getTitleFromContent(feature.content)
|
|
836
|
+
|
|
837
|
+
const labels = feature.labels.length > 0 ? ` [${feature.labels.join(', ')}]` : ''
|
|
838
|
+
const description = feature.content.replace(/\n/g, ' ').replace(/\s+/g, ' ').trim()
|
|
839
|
+
const shortDesc = description.length > 200 ? description.substring(0, 200) + '...' : description
|
|
840
|
+
|
|
841
|
+
const prompt = `Implement this feature: "${title}" (${feature.priority} priority)${labels}. ${shortDesc} See full details in: ${feature.filePath}`
|
|
842
|
+
|
|
843
|
+
// Use provided agent or fall back to config
|
|
844
|
+
const aiRoot = this._getWorkspaceRoot()
|
|
845
|
+
const aiConfig = aiRoot ? readConfig(aiRoot) : DEFAULT_CONFIG
|
|
846
|
+
const selectedAgent = agent || aiConfig.aiAgent || 'claude'
|
|
847
|
+
const selectedPermissionMode = permissionMode || 'default'
|
|
848
|
+
|
|
849
|
+
let command: string
|
|
850
|
+
const escapedPrompt = prompt.replace(/"/g, '\\"')
|
|
851
|
+
|
|
852
|
+
switch (selectedAgent) {
|
|
853
|
+
case 'claude': {
|
|
854
|
+
const permissionFlag = selectedPermissionMode !== 'default' ? ` --permission-mode ${selectedPermissionMode}` : ''
|
|
855
|
+
command = `claude${permissionFlag} "${escapedPrompt}"`
|
|
856
|
+
break
|
|
857
|
+
}
|
|
858
|
+
case 'codex': {
|
|
859
|
+
const approvalMap: Record<string, string> = {
|
|
860
|
+
'default': 'suggest',
|
|
861
|
+
'plan': 'suggest',
|
|
862
|
+
'acceptEdits': 'auto-edit',
|
|
863
|
+
'bypassPermissions': 'full-auto'
|
|
864
|
+
}
|
|
865
|
+
const approvalMode = approvalMap[selectedPermissionMode] || 'suggest'
|
|
866
|
+
command = `codex --approval-mode ${approvalMode} "${escapedPrompt}"`
|
|
867
|
+
break
|
|
868
|
+
}
|
|
869
|
+
case 'opencode': {
|
|
870
|
+
command = `opencode "${escapedPrompt}"`
|
|
871
|
+
break
|
|
872
|
+
}
|
|
873
|
+
default:
|
|
874
|
+
command = `claude "${escapedPrompt}"`
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
const agentNames: Record<string, string> = {
|
|
878
|
+
'claude': 'Claude Code',
|
|
879
|
+
'codex': 'Codex',
|
|
880
|
+
'opencode': 'OpenCode'
|
|
881
|
+
}
|
|
882
|
+
const terminal = vscode.window.createTerminal({
|
|
883
|
+
name: agentNames[selectedAgent] || 'AI Agent',
|
|
884
|
+
cwd: vscode.workspace.workspaceFolders?.[0]?.uri.fsPath
|
|
885
|
+
})
|
|
886
|
+
terminal.show()
|
|
887
|
+
terminal.sendText(command)
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
private _saveSettings(settings: CardDisplaySettings): void {
|
|
891
|
+
const root = this._getWorkspaceRoot()
|
|
892
|
+
if (!root) return
|
|
893
|
+
const config = readConfig(root)
|
|
894
|
+
const updated = settingsToConfig(config, settings)
|
|
895
|
+
writeConfig(root, updated)
|
|
896
|
+
this._sendFeaturesToWebview()
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
private _getColumns(): KanbanColumn[] {
|
|
900
|
+
const root = this._getWorkspaceRoot()
|
|
901
|
+
if (!root) return [...DEFAULT_CONFIG.columns]
|
|
902
|
+
const config = readConfig(root)
|
|
903
|
+
return config.columns.map(c => ({ ...c }))
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
private _saveColumns(columns: KanbanColumn[]): void {
|
|
907
|
+
const root = this._getWorkspaceRoot()
|
|
908
|
+
if (!root) return
|
|
909
|
+
const config = readConfig(root)
|
|
910
|
+
config.columns = columns
|
|
911
|
+
writeConfig(root, config)
|
|
912
|
+
this._sendFeaturesToWebviewWithColumns(columns)
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
private _sendFeaturesToWebviewWithColumns(columns: KanbanColumn[]): void {
|
|
916
|
+
const root = this._getWorkspaceRoot()
|
|
917
|
+
const config = root ? readConfig(root) : { ...DEFAULT_CONFIG }
|
|
918
|
+
const settings = configToSettings(config)
|
|
919
|
+
|
|
920
|
+
// Override showBuildWithAI based on VS Code's AI feature toggle
|
|
921
|
+
const aiDisabled = vscode.workspace.getConfiguration('chat').get<boolean>('disableAIFeatures', false)
|
|
922
|
+
if (aiDisabled) {
|
|
923
|
+
settings.showBuildWithAI = false
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
this._panel.webview.postMessage({
|
|
927
|
+
type: 'init',
|
|
928
|
+
features: this._features,
|
|
929
|
+
columns,
|
|
930
|
+
settings
|
|
931
|
+
})
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
private async _addColumn(column: { name: string; color: string }): Promise<void> {
|
|
935
|
+
const columns = this._getColumns()
|
|
936
|
+
const id = column.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
|
937
|
+
let uniqueId = id
|
|
938
|
+
let counter = 1
|
|
939
|
+
while (columns.some(c => c.id === uniqueId)) {
|
|
940
|
+
uniqueId = `${id}-${counter++}`
|
|
941
|
+
}
|
|
942
|
+
columns.push({ id: uniqueId, name: column.name, color: column.color })
|
|
943
|
+
await this._saveColumns(columns)
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
private async _editColumn(columnId: string, updates: { name: string; color: string }): Promise<void> {
|
|
947
|
+
const columns = this._getColumns()
|
|
948
|
+
if (!columns.some(c => c.id === columnId)) return
|
|
949
|
+
const updatedColumns = columns.map(c =>
|
|
950
|
+
c.id === columnId ? { ...c, name: updates.name, color: updates.color } : c
|
|
951
|
+
)
|
|
952
|
+
await this._saveColumns(updatedColumns)
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
private async _removeColumn(columnId: string): Promise<void> {
|
|
956
|
+
const columns = this._getColumns()
|
|
957
|
+
const hasFeatures = this._features.some(f => f.status === columnId)
|
|
958
|
+
if (hasFeatures) {
|
|
959
|
+
vscode.window.showWarningMessage(`Cannot remove list "${columnId}" because it still contains features. Move or delete them first.`)
|
|
960
|
+
return
|
|
961
|
+
}
|
|
962
|
+
const updated = columns.filter(c => c.id !== columnId)
|
|
963
|
+
if (updated.length === 0) {
|
|
964
|
+
vscode.window.showWarningMessage('Cannot remove the last list.')
|
|
965
|
+
return
|
|
966
|
+
}
|
|
967
|
+
await this._saveColumns(updated)
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
private _sendFeaturesToWebview(): void {
|
|
971
|
+
this._sendFeaturesToWebviewWithColumns(this._getColumns())
|
|
972
|
+
}
|
|
973
|
+
}
|