@stacksjs/defaults 0.75.38 → 0.75.41

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 (110) hide show
  1. package/ai/skills/stacks-ai/SKILL.md +24 -1
  2. package/ai/skills/stacks-commerce/SKILL.md +1 -1
  3. package/ai/skills/stacks-config/SKILL.md +1 -1
  4. package/ai/skills/stacks-database/SKILL.md +1 -1
  5. package/ai/skills/stacks-dependencies/SKILL.md +35 -3
  6. package/ai/skills/stacks-desktop/SKILL.md +12 -0
  7. package/ai/skills/stacks-env/SKILL.md +4 -1
  8. package/ai/skills/stacks-migrations/SKILL.md +2 -2
  9. package/ai/skills/stacks-models/SKILL.md +2 -2
  10. package/app/Actions/Commerce/Product/ProductStoreAction.ts +9 -1
  11. package/app/Actions/Commerce/Product/ProductUpdateAction.ts +4 -1
  12. package/app/Actions/Commerce/commerce-action.test.ts +30 -1
  13. package/app/Actions/Commerce/commerce-action.ts +44 -0
  14. package/app/Actions/Dashboard/Analytics/sales-analytics.test.ts +19 -0
  15. package/app/Actions/Dashboard/Analytics/sales-analytics.ts +2 -1
  16. package/app/Actions/Dashboard/Commerce/CommercePosCheckoutAction.ts +3 -2
  17. package/app/Actions/Dashboard/Commerce/commerce-dashboard.test.ts +29 -9
  18. package/app/Actions/Dashboard/Commerce/commerce-dashboard.ts +5 -12
  19. package/app/Actions/Dashboard/Commerce/commerce-delivery.test.ts +15 -0
  20. package/app/Actions/Dashboard/Commerce/commerce-delivery.ts +8 -10
  21. package/app/Actions/Dashboard/Commerce/commerce-pos.test.ts +26 -6
  22. package/app/Actions/Dashboard/Commerce/commerce-pos.ts +8 -3
  23. package/app/Actions/Dashboard/Commerce/commerce-product-records.ts +69 -0
  24. package/app/Actions/Dashboard/Marketing/abandoned-cart-records.test.ts +39 -0
  25. package/app/Actions/Dashboard/Marketing/abandoned-cart-records.ts +9 -3
  26. package/app/Actions/Dashboard/Marketing/campaign-records.ts +17 -3
  27. package/app/Actions/Payment/CreatePaymentIntentAction.ts +1 -1
  28. package/app/Actions/Payment/CreateSetupIntentAction.ts +1 -1
  29. package/app/Actions/Storefront/AddToCartAction.ts +4 -1
  30. package/app/Actions/Storefront/GetCartAction.ts +25 -10
  31. package/app/Actions/Storefront/PlaceOrderAction.ts +6 -1
  32. package/app/Actions/Storefront/_shipping.ts +63 -37
  33. package/app/Broadcasts/OrderShipped.ts +1 -1
  34. package/app/Mail/OrderConfirmation.ts +14 -7
  35. package/app/Models/Campaign.ts +9 -4
  36. package/app/Models/commerce/Cart.ts +5 -4
  37. package/app/Models/commerce/CartItem.ts +6 -4
  38. package/app/Models/commerce/Coupon.ts +9 -3
  39. package/app/Models/commerce/Customer.ts +2 -1
  40. package/app/Models/commerce/GiftCard.ts +5 -2
  41. package/app/Models/commerce/Order.ts +6 -5
  42. package/app/Models/commerce/OrderItem.ts +2 -1
  43. package/app/Models/commerce/ProductVariant.ts +71 -4
  44. package/app/Models/commerce/Transaction.ts +3 -2
  45. package/app/Storefront/StorefrontMoney.ts +54 -0
  46. package/ide/vscode/.vscode/settings.json +1 -1
  47. package/ide/vscode/README.md +65 -20
  48. package/ide/vscode/package.json +102 -12
  49. package/ide/vscode/src/extension.ts +59 -0
  50. package/ide/vscode/src/project.ts +440 -0
  51. package/ide/vscode/src/stacks.ts +475 -0
  52. package/ide/vscode/src/vscode.d.ts +181 -0
  53. package/ide/vscode/tests/extension.test.ts +420 -0
  54. package/ide/vscode/tests/project.test.ts +268 -0
  55. package/ide/vscode/tsconfig.json +19 -0
  56. package/package.json +4 -4
  57. package/resources/components/Dashboard/Analytics/CampaignAnalytics.stx +8 -8
  58. package/resources/components/Dashboard/Analytics/SalesAnalytics.stx +4 -3
  59. package/resources/components/Dashboard/Billing/BillingPlanSummary.stx +4 -1
  60. package/resources/components/Dashboard/Commerce/CommerceCouponDetailsDialog.stx +4 -1
  61. package/resources/components/Dashboard/Commerce/CommerceCouponDialog.stx +34 -10
  62. package/resources/components/Dashboard/Commerce/CommerceCouponsDashboard.stx +3 -1
  63. package/resources/components/Dashboard/Commerce/CommerceCouponsTable.stx +4 -1
  64. package/resources/components/Dashboard/Commerce/CommerceCustomerDetailsDialog.stx +2 -6
  65. package/resources/components/Dashboard/Commerce/CommerceCustomerDialog.stx +6 -6
  66. package/resources/components/Dashboard/Commerce/CommerceCustomersDashboard.stx +3 -11
  67. package/resources/components/Dashboard/Commerce/CommerceCustomersTable.stx +2 -6
  68. package/resources/components/Dashboard/Commerce/CommerceGiftCardBreakdown.stx +2 -6
  69. package/resources/components/Dashboard/Commerce/CommerceGiftCardDetailsDialog.stx +2 -6
  70. package/resources/components/Dashboard/Commerce/CommerceGiftCardDialog.stx +20 -17
  71. package/resources/components/Dashboard/Commerce/CommerceGiftCardsDashboard.stx +3 -2
  72. package/resources/components/Dashboard/Commerce/CommerceGiftCardsTable.stx +2 -6
  73. package/resources/components/Dashboard/Commerce/CommerceOrderDetailsDialog.stx +2 -6
  74. package/resources/components/Dashboard/Commerce/CommerceOrderDialog.stx +28 -20
  75. package/resources/components/Dashboard/Commerce/CommerceOrdersDashboard.stx +7 -15
  76. package/resources/components/Dashboard/Commerce/CommerceOrdersTable.stx +2 -6
  77. package/resources/components/Dashboard/Commerce/CommercePaymentsBreakdown.stx +2 -6
  78. package/resources/components/Dashboard/Commerce/CommercePaymentsTable.stx +2 -6
  79. package/resources/components/Dashboard/Commerce/CommercePosCart.stx +2 -6
  80. package/resources/components/Dashboard/Commerce/CommercePosCatalog.stx +2 -6
  81. package/resources/components/Dashboard/Commerce/CommercePosCheckoutDialog.stx +2 -6
  82. package/resources/components/Dashboard/Commerce/CommercePosDashboard.stx +5 -7
  83. package/resources/components/Dashboard/Commerce/CommercePosReceiptDialog.stx +2 -8
  84. package/resources/components/Dashboard/Commerce/CommerceProductDetailDashboard.stx +3 -20
  85. package/resources/components/Dashboard/Commerce/CommerceProductDialog.stx +27 -19
  86. package/resources/components/Dashboard/Commerce/CommerceProductsDashboard.stx +4 -15
  87. package/resources/components/Dashboard/Commerce/CommerceProductsTable.stx +2 -6
  88. package/resources/components/Dashboard/Commerce/Delivery/ShippingMethodDialog.stx +12 -10
  89. package/resources/components/Dashboard/Commerce/Delivery/ShippingMethodsTable.stx +3 -4
  90. package/resources/components/Dashboard/Commerce/Delivery/ShippingRateDialog.stx +7 -5
  91. package/resources/components/Dashboard/Commerce/Delivery/ShippingRatesDashboard.stx +3 -2
  92. package/resources/components/Dashboard/Commerce/Delivery/ShippingRatesTable.stx +3 -4
  93. package/resources/components/Dashboard/Commerce/PaymentDetailsDialog.stx +2 -6
  94. package/resources/components/Dashboard/Commerce/PaymentRefundDialog.stx +12 -10
  95. package/resources/components/Dashboard/Marketing/AbandonedCartsDashboard.stx +3 -10
  96. package/resources/components/Dashboard/Marketing/AbandonedCartsTable.stx +3 -8
  97. package/resources/components/Dashboard/Marketing/CampaignDialog.stx +26 -8
  98. package/resources/components/Dashboard/Marketing/CampaignsDashboard.stx +4 -2
  99. package/resources/components/Dashboard/Marketing/RecoveryCampaignDialog.stx +25 -13
  100. package/resources/components/Dashboard/Transaction/index.stx +6 -3
  101. package/resources/components/Storefront/CartDrawer.stx +11 -7
  102. package/resources/views/cart.stx +19 -23
  103. package/resources/views/checkout/contact.stx +12 -14
  104. package/resources/views/checkout/payment.stx +13 -13
  105. package/resources/views/checkout/shipping.stx +13 -13
  106. package/resources/views/cms/blocks/form.stx +50 -1
  107. package/resources/views/orders/[id].stx +13 -4
  108. package/vcs/github/workflows/buddy-bot.yml +5 -5
  109. package/vcs/github/workflows/ci.yml +3 -3
  110. package/vcs/github/workflows/release.yml +1 -1
@@ -0,0 +1,475 @@
1
+ import type * as vscode from 'vscode'
2
+ import type { BuddyCommand, PreviewPlan, Probe } from './project'
3
+ import { join } from 'node:path'
4
+ import {
5
+ argumentHint,
6
+ buddyCommandLine,
7
+ choosePreviewUrl,
8
+ freeTextCommandLine,
9
+ isLoopbackUrl,
10
+ loadProjectEnv,
11
+ parseBuddyListJson,
12
+ parseBuddyListText,
13
+ parseDevBannerUrl,
14
+ pickProjectRoot,
15
+ planPreview,
16
+ requiresArguments,
17
+ } from './project'
18
+
19
+ type Vscode = typeof vscode
20
+
21
+ /**
22
+ * What the extension needs from the machine, injected so the controller can
23
+ * be tested with a fake `vscode` and no real processes, sockets or files.
24
+ */
25
+ export interface Host {
26
+ exists: (path: string) => boolean
27
+ /** File contents, or `undefined` when the file does not exist. */
28
+ read: (path: string) => string | undefined
29
+ /** Run `file` with `args` in `cwd` and resolve its stdout. */
30
+ exec: (file: string, args: string[], cwd: string) => Promise<string>
31
+ probe: Probe
32
+ sleep: (ms: number) => Promise<void>
33
+ }
34
+
35
+ export const COMMANDS = {
36
+ openPreview: 'stacks.openPreview',
37
+ startDevServer: 'stacks.startDevServer',
38
+ runBuddyCommand: 'stacks.runBuddyCommand',
39
+ } as const
40
+
41
+ export const DEV_TERMINAL = 'Stacks Dev'
42
+ export const BUDDY_TERMINAL = 'Stacks Buddy'
43
+
44
+ export interface Timing {
45
+ /** How long to wait for a new terminal's shell integration before typing blind. */
46
+ shellIntegrationMs: number
47
+ /** How long `Start Dev Server` waits for the server before giving up on the preview. */
48
+ devReadyMs: number
49
+ /** How long to trust the banner alone before also probing ports. */
50
+ bannerGraceMs: number
51
+ }
52
+
53
+ const DEFAULT_TIMING: Timing = {
54
+ shellIntegrationMs: 3000,
55
+ devReadyMs: 120_000,
56
+ bannerGraceMs: 15_000,
57
+ }
58
+
59
+ interface DevRun {
60
+ terminal: vscode.Terminal
61
+ running: boolean
62
+ /** Whether output is being read through shell integration (so the banner URL will arrive). */
63
+ capturing: boolean
64
+ url?: string
65
+ }
66
+
67
+ interface CommandItem extends vscode.QuickPickItem {
68
+ command?: BuddyCommand
69
+ freeText?: string
70
+ }
71
+
72
+ export function createStacksExtension(api: Vscode, host: Host, timingOverrides: Partial<Timing> = {}): {
73
+ activate: (context: vscode.ExtensionContext) => void
74
+ deactivate: () => void
75
+ } {
76
+ const timing: Timing = { ...DEFAULT_TIMING, ...timingOverrides }
77
+ let devRun: DevRun | undefined
78
+ let statusItem: vscode.StatusBarItem | undefined
79
+ const commandLists = new Map<string, Promise<BuddyCommand[]>>()
80
+
81
+ const settings = () => api.workspace.getConfiguration('stacks')
82
+
83
+ function isProject(folder: string): boolean {
84
+ return host.exists(join(folder, 'buddy')) || host.exists(join(folder, 'config', 'app.ts'))
85
+ }
86
+
87
+ function projectRoot(): string | undefined {
88
+ const folders = (api.workspace.workspaceFolders ?? []).map(folder => folder.uri.fsPath)
89
+ const activeUri = api.window.activeTextEditor?.document.uri
90
+ const preferred = activeUri ? api.workspace.getWorkspaceFolder(activeUri)?.uri.fsPath : undefined
91
+ const root = pickProjectRoot(folders, preferred, isProject)
92
+
93
+ if (!root)
94
+ void api.window.showErrorMessage('Stacks: open a Stacks project folder (one containing ./buddy) first.')
95
+
96
+ return root
97
+ }
98
+
99
+ /** The root, if it has the `./buddy` the command is about to run. */
100
+ function buddyRoot(): string | undefined {
101
+ const root = projectRoot()
102
+ if (root && !host.exists(join(root, 'buddy'))) {
103
+ void api.window.showErrorMessage(`Stacks: there is no ./buddy in ${root}. Run the command from the project root, or run \`buddy install\` there first.`)
104
+ return undefined
105
+ }
106
+
107
+ return root
108
+ }
109
+
110
+ function previewPlan(root: string): PreviewPlan {
111
+ return planPreview({
112
+ env: loadProjectEnv(file => host.read(join(root, file))),
113
+ appConfigSource: host.read(join(root, 'config', 'app.ts')),
114
+ preferLocalhost: settings().get<boolean>('preview.preferLocalhost', false),
115
+ })
116
+ }
117
+
118
+ async function openInSimpleBrowser(url: string): Promise<void> {
119
+ let target = api.Uri.parse(url)
120
+ // In a remote window (SSH, WSL, Codespaces) the dev server's localhost is
121
+ // the remote machine's; this forwards the port and returns the local URL.
122
+ if (isLoopbackUrl(url))
123
+ target = await api.env.asExternalUri(target)
124
+
125
+ try {
126
+ // The Simple Browser's extension-facing command, which takes a column,
127
+ // so the preview opens beside the editor instead of replacing it.
128
+ await api.commands.executeCommand('simpleBrowser.api.open', target, {
129
+ viewColumn: api.ViewColumn.Beside,
130
+ preserveFocus: true,
131
+ })
132
+ }
133
+ catch {
134
+ await api.commands.executeCommand('simpleBrowser.show', target.toString(true))
135
+ }
136
+ }
137
+
138
+ async function openPreview(): Promise<void> {
139
+ const override = settings().get<string>('preview.url', '').trim()
140
+ if (override)
141
+ return openInSimpleBrowser(override)
142
+
143
+ const root = projectRoot()
144
+ if (!root)
145
+ return
146
+
147
+ if (devRun?.running && devRun.url)
148
+ return openInSimpleBrowser(devRun.url)
149
+
150
+ const plan = previewPlan(root)
151
+ const url = await choosePreviewUrl(plan, host.probe)
152
+ if (url)
153
+ return openInSimpleBrowser(url)
154
+
155
+ const choice = await api.window.showWarningMessage(
156
+ `Stacks: nothing is listening on ${plan.server.host}:${plan.server.port}. Is the dev server running?`,
157
+ 'Start Dev Server',
158
+ 'Open Anyway',
159
+ )
160
+
161
+ if (choice === 'Start Dev Server')
162
+ await startDevServer({ openPreview: true })
163
+ else if (choice === 'Open Anyway')
164
+ await openInSimpleBrowser(plan.candidates[0]!.url)
165
+ }
166
+
167
+ function findTerminal(name: string): vscode.Terminal | undefined {
168
+ return api.window.terminals.find(terminal => terminal.name === name && terminal.exitStatus === undefined)
169
+ }
170
+
171
+ function waitForShellIntegration(terminal: vscode.Terminal): Promise<vscode.TerminalShellIntegration | undefined> {
172
+ if (terminal.shellIntegration)
173
+ return Promise.resolve(terminal.shellIntegration)
174
+
175
+ return new Promise((resolve) => {
176
+ const timer = setTimeout(() => {
177
+ subscription.dispose()
178
+ resolve(undefined)
179
+ }, timing.shellIntegrationMs)
180
+
181
+ const subscription = api.window.onDidChangeTerminalShellIntegration((event) => {
182
+ if (event.terminal !== terminal)
183
+ return
184
+
185
+ clearTimeout(timer)
186
+ subscription.dispose()
187
+ resolve(event.shellIntegration)
188
+ })
189
+ })
190
+ }
191
+
192
+ /**
193
+ * Run the dev server in `run.terminal`. With shell integration, the output is
194
+ * read to pick up the banner URL and the end of the command; without it the
195
+ * command is typed into the terminal and the ports are probed instead.
196
+ */
197
+ async function launchDevServer(run: DevRun, commandLine: string): Promise<void> {
198
+ const integration = await waitForShellIntegration(run.terminal)
199
+ if (!integration) {
200
+ run.terminal.sendText(commandLine)
201
+ return
202
+ }
203
+
204
+ const execution = integration.executeCommand(commandLine)
205
+ run.capturing = true
206
+
207
+ const ended = api.window.onDidEndTerminalShellExecution((event) => {
208
+ if (event.execution !== execution)
209
+ return
210
+
211
+ run.running = false
212
+ run.url = undefined
213
+ ended.dispose()
214
+ updateStatus()
215
+ })
216
+
217
+ void (async () => {
218
+ let tail = ''
219
+ for await (const chunk of execution.read()) {
220
+ if (run.url)
221
+ continue
222
+
223
+ tail = (tail + chunk).slice(-16_384)
224
+ run.url = parseDevBannerUrl(tail)
225
+ }
226
+ })().catch(() => {
227
+ run.capturing = false
228
+ })
229
+ }
230
+
231
+ async function openWhenReady(run: DevRun, root: string): Promise<void> {
232
+ const plan = previewPlan(root)
233
+ const startedAt = Date.now()
234
+ updateStatus('starting')
235
+
236
+ try {
237
+ while (devRun === run && run.running && Date.now() - startedAt < timing.devReadyMs) {
238
+ if (run.url)
239
+ return await openInSimpleBrowser(run.url)
240
+
241
+ if (!run.capturing || Date.now() - startedAt > timing.bannerGraceMs) {
242
+ const url = await choosePreviewUrl(plan, host.probe)
243
+ if (url)
244
+ return await openInSimpleBrowser(url)
245
+ }
246
+
247
+ await host.sleep(1000)
248
+ }
249
+ }
250
+ finally {
251
+ updateStatus()
252
+ }
253
+ }
254
+
255
+ async function startDevServer(options: { openPreview?: boolean } = {}): Promise<void> {
256
+ const root = buddyRoot()
257
+ if (!root)
258
+ return
259
+
260
+ const existing = findTerminal(DEV_TERMINAL)
261
+ const knownFinished = existing !== undefined && devRun?.terminal === existing && !devRun.running
262
+
263
+ // A dev terminal this extension did not see finish may still be serving:
264
+ // it was started earlier in this window, or revived with the window.
265
+ // Typing `./buddy dev` into it would go to the running server's stdin.
266
+ if (existing && !knownFinished) {
267
+ existing.show(true)
268
+ const choice = await api.window.showInformationMessage(
269
+ 'Stacks: the dev server terminal is already open.',
270
+ 'Open Preview',
271
+ 'Restart',
272
+ )
273
+
274
+ if (choice === 'Open Preview')
275
+ return openPreview()
276
+ if (choice !== 'Restart')
277
+ return
278
+
279
+ existing.dispose()
280
+ devRun = undefined
281
+ }
282
+
283
+ const shouldOpenPreview = options.openPreview ?? settings().get<boolean>('devServer.openPreview', true)
284
+ const terminal = knownFinished && existing
285
+ ? existing
286
+ : api.window.createTerminal({
287
+ name: DEV_TERMINAL,
288
+ cwd: root,
289
+ // The preview opens inside VS Code, so `buddy dev` should not also
290
+ // open the system browser (core/buddy/src/commands/dev.ts).
291
+ env: shouldOpenPreview ? { STACKS_DEV_NO_OPEN: '1' } : undefined,
292
+ })
293
+
294
+ terminal.show(true)
295
+
296
+ const run: DevRun = { terminal, running: true, capturing: false }
297
+ devRun = run
298
+ await launchDevServer(run, buddyCommandLine('dev', settings().get<string[]>('devServer.args', [])))
299
+
300
+ if (shouldOpenPreview)
301
+ void openWhenReady(run, root)
302
+ }
303
+
304
+ function listBuddyCommands(root: string): Promise<BuddyCommand[]> {
305
+ const cached = commandLists.get(root)
306
+ if (cached)
307
+ return cached
308
+
309
+ const buddy = join(root, 'buddy')
310
+ const listing = (async () => {
311
+ const json = await host.exec(buddy, ['list', '--json'], root).catch(() => '')
312
+ const fromJson = parseBuddyListJson(json)
313
+ if (fromJson?.length)
314
+ return fromJson
315
+
316
+ const fromText = parseBuddyListText(await host.exec(buddy, ['list'], root))
317
+ if (!fromText.length)
318
+ throw new Error('`./buddy list` printed no commands')
319
+
320
+ return fromText
321
+ })()
322
+
323
+ commandLists.set(root, listing)
324
+ listing.catch(() => commandLists.delete(root))
325
+ return listing
326
+ }
327
+
328
+ function runInBuddyTerminal(root: string, commandLine: string): void {
329
+ const terminal = findTerminal(BUDDY_TERMINAL) ?? api.window.createTerminal({ name: BUDDY_TERMINAL, cwd: root })
330
+ terminal.show()
331
+ terminal.sendText(commandLine)
332
+ }
333
+
334
+ async function runPickedCommand(root: string, item: CommandItem): Promise<void> {
335
+ if (item.freeText !== undefined) {
336
+ const commandLine = freeTextCommandLine(item.freeText)
337
+ if (commandLine)
338
+ runInBuddyTerminal(root, commandLine)
339
+ return
340
+ }
341
+
342
+ const command = item.command
343
+ if (!command)
344
+ return
345
+
346
+ let args = ''
347
+ if (command.arguments.length) {
348
+ const required = requiresArguments(command)
349
+ const answer = await api.window.showInputBox({
350
+ title: buddyCommandLine(command.name),
351
+ prompt: command.usage ? `Usage: ${command.usage}` : command.description,
352
+ placeHolder: argumentHint(command),
353
+ ignoreFocusOut: true,
354
+ validateInput: value => required && !value.trim() ? `Arguments: ${argumentHint(command)}` : undefined,
355
+ })
356
+
357
+ if (answer === undefined)
358
+ return
359
+
360
+ args = answer
361
+ }
362
+
363
+ runInBuddyTerminal(root, buddyCommandLine(command.name, args))
364
+ }
365
+
366
+ async function runBuddyCommand(): Promise<void> {
367
+ const root = buddyRoot()
368
+ if (!root)
369
+ return
370
+
371
+ const picker = api.window.createQuickPick<CommandItem>()
372
+ picker.title = 'Run Buddy Command'
373
+ picker.placeholder = 'Loading ./buddy list ... or type any command, e.g. migrate --diff'
374
+ picker.matchOnDescription = true
375
+ picker.busy = true
376
+
377
+ let commands: BuddyCommand[] = []
378
+ const render = () => {
379
+ const value = picker.value.trim()
380
+ const items: CommandItem[] = commands.map(command => ({
381
+ label: command.name,
382
+ description: command.description,
383
+ detail: command.arguments.length ? `${command.name} ${argumentHint(command)}` : undefined,
384
+ command,
385
+ }))
386
+
387
+ // Typed text that is not exactly a command name runs as typed. It goes
388
+ // last so Enter on a partial name still picks the filtered command.
389
+ if (value && !commands.some(command => command.name === value))
390
+ items.push({ label: freeTextCommandLine(value) ?? value, description: 'Run as typed', alwaysShow: true, freeText: value })
391
+
392
+ picker.items = items
393
+ }
394
+
395
+ picker.onDidChangeValue(render)
396
+ picker.onDidHide(() => picker.dispose())
397
+ picker.onDidAccept(() => {
398
+ const typed = picker.value.trim()
399
+ const item = picker.selectedItems[0] ?? (typed ? { label: typed, freeText: typed } : undefined)
400
+ picker.hide()
401
+ if (item)
402
+ void runPickedCommand(root, item)
403
+ })
404
+ picker.show()
405
+
406
+ try {
407
+ commands = await listBuddyCommands(root)
408
+ picker.placeholder = 'Pick a buddy command, or type one with arguments'
409
+ }
410
+ catch {
411
+ picker.placeholder = 'Could not read ./buddy list - type a command to run, e.g. migrate --diff'
412
+ }
413
+ finally {
414
+ picker.busy = false
415
+ render()
416
+ }
417
+ }
418
+
419
+ function updateStatus(state: 'idle' | 'starting' = 'idle'): void {
420
+ if (!statusItem)
421
+ return
422
+
423
+ if (!settings().get<boolean>('statusBar.enabled', true)) {
424
+ statusItem.hide()
425
+ return
426
+ }
427
+
428
+ statusItem.text = state === 'starting' ? '$(loading~spin) Stacks' : '$(globe) Stacks'
429
+ statusItem.tooltip = state === 'starting'
430
+ ? 'Waiting for the Stacks dev server...'
431
+ : 'Open the Stacks dev server in the Simple Browser'
432
+ statusItem.show()
433
+ }
434
+
435
+ function activate(context: vscode.ExtensionContext): void {
436
+ // Commands are hidden from the palette until a Stacks project activated us.
437
+ void api.commands.executeCommand('setContext', 'stacks.isProject', true)
438
+
439
+ statusItem = api.window.createStatusBarItem('stacks.preview', api.StatusBarAlignment.Left, 0)
440
+ statusItem.name = 'Stacks Preview'
441
+ statusItem.command = COMMANDS.openPreview
442
+ updateStatus()
443
+
444
+ // Custom commands live in app/Commands and need no registration, so a new
445
+ // file there is a new buddy command: forget the cached list.
446
+ const commandFiles = api.workspace.createFileSystemWatcher('**/app/Commands/**')
447
+ const forgetCommands = () => commandLists.clear()
448
+
449
+ context.subscriptions.push(
450
+ statusItem,
451
+ commandFiles,
452
+ commandFiles.onDidCreate(forgetCommands),
453
+ commandFiles.onDidChange(forgetCommands),
454
+ commandFiles.onDidDelete(forgetCommands),
455
+ api.commands.registerCommand(COMMANDS.openPreview, openPreview),
456
+ api.commands.registerCommand(COMMANDS.startDevServer, () => startDevServer()),
457
+ api.commands.registerCommand(COMMANDS.runBuddyCommand, runBuddyCommand),
458
+ api.window.onDidCloseTerminal((terminal) => {
459
+ if (devRun?.terminal === terminal)
460
+ devRun = undefined
461
+ }),
462
+ api.workspace.onDidChangeConfiguration((event) => {
463
+ if (event.affectsConfiguration('stacks.statusBar'))
464
+ updateStatus()
465
+ }),
466
+ )
467
+ }
468
+
469
+ function deactivate(): void {
470
+ devRun = undefined
471
+ commandLists.clear()
472
+ }
473
+
474
+ return { activate, deactivate }
475
+ }
@@ -0,0 +1,181 @@
1
+ /**
2
+ * The slice of the VS Code extension API this extension uses.
3
+ *
4
+ * `@types/vscode` is the complete declaration, but it would be the only
5
+ * dependency of a package that otherwise has none, and the extension calls a
6
+ * couple of dozen members. The names and signatures below are copied from
7
+ * `@types/vscode` (engine ^1.93 for the terminal shell-integration API), so
8
+ * swapping this file for the real package later is a deletion, not a rewrite.
9
+ */
10
+ declare module 'vscode' {
11
+ export interface Disposable {
12
+ dispose: () => unknown
13
+ }
14
+
15
+ export type Event<T> = (listener: (event: T) => unknown) => Disposable
16
+
17
+ export class Uri {
18
+ static parse(value: string): Uri
19
+ static file(path: string): Uri
20
+ readonly scheme: string
21
+ readonly authority: string
22
+ readonly path: string
23
+ readonly fsPath: string
24
+ toString(skipEncoding?: boolean): string
25
+ }
26
+
27
+ export enum ViewColumn {
28
+ Active = -1,
29
+ Beside = -2,
30
+ One = 1,
31
+ }
32
+
33
+ export enum StatusBarAlignment {
34
+ Left = 1,
35
+ Right = 2,
36
+ }
37
+
38
+ export interface ExtensionContext {
39
+ readonly subscriptions: Disposable[]
40
+ }
41
+
42
+ export interface WorkspaceFolder {
43
+ readonly uri: Uri
44
+ readonly name: string
45
+ readonly index: number
46
+ }
47
+
48
+ export interface WorkspaceConfiguration {
49
+ get: <T>(section: string, defaultValue: T) => T
50
+ }
51
+
52
+ export interface ConfigurationChangeEvent {
53
+ affectsConfiguration: (section: string) => boolean
54
+ }
55
+
56
+ export interface FileSystemWatcher extends Disposable {
57
+ readonly onDidCreate: Event<Uri>
58
+ readonly onDidChange: Event<Uri>
59
+ readonly onDidDelete: Event<Uri>
60
+ }
61
+
62
+ export interface TerminalOptions {
63
+ name?: string
64
+ cwd?: string | Uri
65
+ env?: Record<string, string | null | undefined>
66
+ }
67
+
68
+ export interface TerminalExitStatus {
69
+ readonly code: number | undefined
70
+ }
71
+
72
+ export interface TerminalShellExecution {
73
+ read: () => AsyncIterable<string>
74
+ }
75
+
76
+ export interface TerminalShellIntegration {
77
+ executeCommand: (commandLine: string) => TerminalShellExecution
78
+ }
79
+
80
+ export interface Terminal {
81
+ readonly name: string
82
+ readonly exitStatus: TerminalExitStatus | undefined
83
+ readonly shellIntegration: TerminalShellIntegration | undefined
84
+ show: (preserveFocus?: boolean) => void
85
+ sendText: (text: string, shouldExecute?: boolean) => void
86
+ dispose: () => void
87
+ }
88
+
89
+ export interface TerminalShellIntegrationChangeEvent {
90
+ readonly terminal: Terminal
91
+ readonly shellIntegration: TerminalShellIntegration
92
+ }
93
+
94
+ export interface TerminalShellExecutionEndEvent {
95
+ readonly terminal: Terminal
96
+ readonly execution: TerminalShellExecution
97
+ readonly exitCode: number | undefined
98
+ }
99
+
100
+ export interface StatusBarItem {
101
+ text: string
102
+ tooltip: string | undefined
103
+ command: string | undefined
104
+ name: string | undefined
105
+ show: () => void
106
+ hide: () => void
107
+ dispose: () => void
108
+ }
109
+
110
+ export interface QuickPickItem {
111
+ label: string
112
+ description?: string
113
+ detail?: string
114
+ alwaysShow?: boolean
115
+ }
116
+
117
+ export interface QuickPick<T extends QuickPickItem> extends Disposable {
118
+ value: string
119
+ title: string | undefined
120
+ placeholder: string | undefined
121
+ items: readonly T[]
122
+ readonly selectedItems: readonly T[]
123
+ busy: boolean
124
+ matchOnDescription: boolean
125
+ readonly onDidChangeValue: Event<string>
126
+ readonly onDidAccept: Event<void>
127
+ readonly onDidHide: Event<void>
128
+ show: () => void
129
+ hide: () => void
130
+ }
131
+
132
+ export interface InputBoxOptions {
133
+ title?: string
134
+ prompt?: string
135
+ placeHolder?: string
136
+ value?: string
137
+ ignoreFocusOut?: boolean
138
+ validateInput?: (value: string) => string | undefined | null
139
+ }
140
+
141
+ export interface TextDocument {
142
+ readonly uri: Uri
143
+ }
144
+
145
+ export interface TextEditor {
146
+ readonly document: TextDocument
147
+ }
148
+
149
+ export namespace window {
150
+ export const activeTextEditor: TextEditor | undefined
151
+ export const terminals: readonly Terminal[]
152
+ export const onDidCloseTerminal: Event<Terminal>
153
+ export const onDidChangeTerminalShellIntegration: Event<TerminalShellIntegrationChangeEvent>
154
+ export const onDidEndTerminalShellExecution: Event<TerminalShellExecutionEndEvent>
155
+ export function createTerminal(options: TerminalOptions): Terminal
156
+ export function createStatusBarItem(id: string, alignment?: StatusBarAlignment, priority?: number): StatusBarItem
157
+ export function createQuickPick<T extends QuickPickItem>(): QuickPick<T>
158
+ export function showInputBox(options?: InputBoxOptions): PromiseLike<string | undefined>
159
+ export function showInformationMessage<T extends string>(message: string, ...items: T[]): PromiseLike<T | undefined>
160
+ export function showWarningMessage<T extends string>(message: string, ...items: T[]): PromiseLike<T | undefined>
161
+ export function showErrorMessage<T extends string>(message: string, ...items: T[]): PromiseLike<T | undefined>
162
+ }
163
+
164
+ export namespace workspace {
165
+ export const workspaceFolders: readonly WorkspaceFolder[] | undefined
166
+ export const onDidChangeConfiguration: Event<ConfigurationChangeEvent>
167
+ export function getConfiguration(section?: string): WorkspaceConfiguration
168
+ export function getWorkspaceFolder(uri: Uri): WorkspaceFolder | undefined
169
+ export function createFileSystemWatcher(globPattern: string): FileSystemWatcher
170
+ }
171
+
172
+ export namespace commands {
173
+ export function registerCommand(command: string, callback: (...args: any[]) => any): Disposable
174
+ export function executeCommand<T = unknown>(command: string, ...rest: any[]): PromiseLike<T>
175
+ }
176
+
177
+ export namespace env {
178
+ export function asExternalUri(target: Uri): PromiseLike<Uri>
179
+ export function openExternal(target: Uri): PromiseLike<boolean>
180
+ }
181
+ }