buddy-workbench 0.2.0 → 0.2.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "buddy-workbench",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,6 +23,9 @@ export function settingsStatus() {
23
23
  clipboardEnabled: settings.clipboardEnabled !== false,
24
24
  clipboardImageEnabled: settings.clipboardImageEnabled !== false,
25
25
  clipboardDeduplicateMinutes: typeof settings.clipboardDeduplicateMinutes === 'number' && !isNaN(settings.clipboardDeduplicateMinutes) ? settings.clipboardDeduplicateMinutes : 60,
26
+ mindMapNodeMaxWidth: Number.isInteger(settings.mindMapNodeMaxWidth) && settings.mindMapNodeMaxWidth >= 120 && settings.mindMapNodeMaxWidth <= 800
27
+ ? settings.mindMapNodeMaxWidth
28
+ : null,
26
29
  dataDir: paths.dataDir
27
30
  };
28
31
  }
@@ -105,6 +108,22 @@ export function saveClipboardDeduplicateMinutes(minutes) {
105
108
  return settingsStatus();
106
109
  }
107
110
 
111
+ export function saveMindMapNodeMaxWidth(width) {
112
+ const settings = readSettings();
113
+ if (width === null) {
114
+ delete settings.mindMapNodeMaxWidth;
115
+ } else {
116
+ const value = Number(width);
117
+ if (!Number.isInteger(value) || value < 120 || value > 800) {
118
+ throw new Error('Mind map node maximum width must be an integer between 120 and 800.');
119
+ }
120
+ settings.mindMapNodeMaxWidth = value;
121
+ }
122
+ mkdirSync(dirname(paths.settings), { recursive: true });
123
+ writeFileSync(paths.settings, JSON.stringify(settings, null, 2), { mode: 0o600 });
124
+ return settingsStatus();
125
+ }
126
+
108
127
  export function readPrReviewRules(defaultRules) {
109
128
  const settings = readSettings();
110
129
  const savedRules = settings.prReviewRules && typeof settings.prReviewRules === 'object' ? settings.prReviewRules : {};
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync, statSync } from 'node:fs';
2
2
  import { Router } from 'express';
3
3
  import { paths } from '../config.js';
4
- import { saveAccessToken, saveAiApiHost, saveClipboardDeduplicateMinutes, saveClipboardEnabled, saveClipboardImageEnabled, saveDefaultBrowser, saveDefaultEditor, saveDomain, saveJiraIssuePrefix, saveTheme, settingsStatus } from '../repositories/settings.js';
4
+ import { saveAccessToken, saveAiApiHost, saveClipboardDeduplicateMinutes, saveClipboardEnabled, saveClipboardImageEnabled, saveDefaultBrowser, saveDefaultEditor, saveDomain, saveJiraIssuePrefix, saveMindMapNodeMaxWidth, saveTheme, settingsStatus } from '../repositories/settings.js';
5
5
  import { getNpmPackageVersions, installNvmVersion, manageGlobalNpmPackage, readDevConfigurations, readNvmConfiguration, saveDevConfigurations, searchNpmPackages, useSuggestedNpmPrefix } from '../services/dev-configurations.js';
6
6
  import { openInSystemBrowser, openInSystemEditor, openInSystemFolder, revealInSystemFolder } from '../services/browser.js';
7
7
  import { selectDirectory, selectFile } from '../services/dialog.js';
@@ -149,6 +149,13 @@ router.put('/clipboard-deduplicate-minutes', (req, res) => {
149
149
  if (typeof minutes !== 'number' || isNaN(minutes) || minutes < 0) return res.status(400).json({ error: 'Minutes must be a non-negative number.' });
150
150
  res.json(saveClipboardDeduplicateMinutes(minutes));
151
151
  });
152
+ router.put('/mind-map-node-max-width', (req, res) => {
153
+ const { width } = req.body || {};
154
+ if (width !== null && (typeof width !== 'number' || !Number.isInteger(width) || width < 120 || width > 800)) {
155
+ return res.status(400).json({ error: 'Width must be an integer between 120 and 800, or null to use the layout default.' });
156
+ }
157
+ try { res.json(saveMindMapNodeMaxWidth(width)); } catch (error) { res.status(400).json({ error: error.message }); }
158
+ });
152
159
  router.put('/:service-access-token', (req, res) => {
153
160
  const { token } = req.body || {};
154
161
  if (typeof token !== 'string') return res.status(400).json({ error: 'Token must be a string.' });
@@ -2,12 +2,14 @@ import AppKit
2
2
  import CoreGraphics
3
3
  import ImageIO
4
4
  import Foundation
5
+ import UniformTypeIdentifiers
5
6
 
6
7
  // Keep this process alive so Swift is compiled once. Pasteboard image bytes
7
8
  // are requested only after the pasteboard ownership changes.
8
9
  let outputDirectory = URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true)
9
10
  let maxInputBytes = 100 * 1024 * 1024
10
11
  let maxPixelCount = 40_000_000
12
+ let maxCaptureAttempts = 40
11
13
  let imageTypes: [NSPasteboard.PasteboardType] = [
12
14
  .png,
13
15
  NSPasteboard.PasteboardType("public.jpeg"),
@@ -15,22 +17,37 @@ let imageTypes: [NSPasteboard.PasteboardType] = [
15
17
  NSPasteboard.PasteboardType("public.heic")
16
18
  ]
17
19
 
20
+ func isImageType(_ type: NSPasteboard.PasteboardType) -> Bool {
21
+ if imageTypes.contains(type) { return true }
22
+ if let uniformType = UTType(type.rawValue), uniformType.conforms(to: .image) { return true }
23
+ return type.rawValue.localizedCaseInsensitiveContains("image")
24
+ }
25
+
18
26
  func emit(_ value: [String: Any]) {
19
27
  guard let data = try? JSONSerialization.data(withJSONObject: value),
20
28
  let line = String(data: data, encoding: .utf8) else { return }
21
29
  FileHandle.standardOutput.write(Data((line + "\n").utf8))
22
30
  }
23
31
 
24
- // Returns false only when the pasteboard advertised image data but could not
25
- // provide it yet. That case is retried briefly for delayed clipboard owners.
26
- func captureImage(_ pasteboard: NSPasteboard, changeCount: Int, startedAt: Date) -> Bool {
27
- guard let type = pasteboard.types?.first(where: { imageTypes.contains($0) }) else { return true }
32
+ enum CaptureResult: Equatable {
33
+ case complete
34
+ case retry
35
+ case waitingForImageType
36
+ }
37
+
38
+ // Browser clipboard owners can publish the image type or fulfill its data
39
+ // asynchronously, so keep checking the same pasteboard change for a short time.
40
+ func captureImage(_ pasteboard: NSPasteboard, changeCount: Int, startedAt: Date) -> CaptureResult {
41
+ let availableTypes = pasteboard.types ?? []
42
+ let type = imageTypes.first(where: { availableTypes.contains($0) })
43
+ ?? availableTypes.first(where: isImageType)
44
+ guard let type else { return .waitingForImageType }
28
45
  guard let input = pasteboard.data(forType: type) else {
29
- return false
46
+ return .retry
30
47
  }
31
48
  guard !input.isEmpty, input.count <= maxInputBytes else {
32
49
  emit(["kind": "error", "stage": "image validation", "message": "Image data was empty or exceeded the 100 MiB input limit.", "changeCount": changeCount, "elapsedMs": Int(Date().timeIntervalSince(startedAt) * 1000)])
33
- return true
50
+ return .complete
34
51
  }
35
52
  guard let source = CGImageSourceCreateWithData(input as CFData, nil),
36
53
  let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any],
@@ -39,22 +56,22 @@ func captureImage(_ pasteboard: NSPasteboard, changeCount: Int, startedAt: Date)
39
56
  width > 0, height > 0,
40
57
  width <= maxPixelCount / height else {
41
58
  emit(["kind": "error", "stage": "image validation", "message": "Image dimensions were invalid or exceeded the 40 megapixel limit.", "changeCount": changeCount, "elapsedMs": Int(Date().timeIntervalSince(startedAt) * 1000)])
42
- return true
59
+ return .complete
43
60
  }
44
61
  guard let image = CGImageSourceCreateImageAtIndex(source, 0, [kCGImageSourceShouldCache: true] as CFDictionary) else {
45
62
  emit(["kind": "error", "stage": "image decode", "message": "ImageIO could not decode the clipboard image.", "changeCount": changeCount, "elapsedMs": Int(Date().timeIntervalSince(startedAt) * 1000)])
46
- return true
63
+ return .complete
47
64
  }
48
65
 
49
66
  let jpeg = NSMutableData()
50
67
  guard let destination = CGImageDestinationCreateWithData(jpeg, "public.jpeg" as CFString, 1, nil) else {
51
68
  emit(["kind": "error", "stage": "JPEG encode", "message": "ImageIO could not create a JPEG encoder.", "changeCount": changeCount, "elapsedMs": Int(Date().timeIntervalSince(startedAt) * 1000)])
52
- return true
69
+ return .complete
53
70
  }
54
71
  CGImageDestinationAddImage(destination, image, [kCGImageDestinationLossyCompressionQuality: 0.75] as CFDictionary)
55
72
  guard CGImageDestinationFinalize(destination), jpeg.length > 0 else {
56
73
  emit(["kind": "error", "stage": "JPEG encode", "message": "ImageIO could not finish JPEG encoding.", "changeCount": changeCount, "elapsedMs": Int(Date().timeIntervalSince(startedAt) * 1000)])
57
- return true
74
+ return .complete
58
75
  }
59
76
 
60
77
  let filename = "capture-\(UUID().uuidString).jpg"
@@ -76,7 +93,7 @@ func captureImage(_ pasteboard: NSPasteboard, changeCount: Int, startedAt: Date)
76
93
  try? FileManager.default.removeItem(at: outputURL)
77
94
  emit(["kind": "error", "stage": "JPEG save", "message": "Could not write the temporary JPEG.", "changeCount": changeCount, "elapsedMs": Int(Date().timeIntervalSince(startedAt) * 1000)])
78
95
  }
79
- return true
96
+ return .complete
80
97
  }
81
98
 
82
99
  emit(["kind": "ready"])
@@ -99,11 +116,11 @@ while true {
99
116
 
100
117
  if let pending = pendingChangeCount {
101
118
  attemptsForPendingChange += 1
102
- let completed = captureImage(pasteboard, changeCount: pending, startedAt: pendingStartedAt)
103
- if !completed && attemptsForPendingChange >= 12 {
104
- emit(["kind": "error", "stage": "pasteboard read", "message": "Image data remained unavailable after 12 retries.", "changeCount": pending, "elapsedMs": Int(Date().timeIntervalSince(pendingStartedAt) * 1000)])
119
+ let result = captureImage(pasteboard, changeCount: pending, startedAt: pendingStartedAt)
120
+ if result == .retry && attemptsForPendingChange >= maxCaptureAttempts {
121
+ emit(["kind": "error", "stage": "pasteboard read", "message": "Image data remained unavailable after \(maxCaptureAttempts) retries.", "changeCount": pending, "elapsedMs": Int(Date().timeIntervalSince(pendingStartedAt) * 1000)])
105
122
  }
106
- if completed || attemptsForPendingChange >= 12 {
123
+ if result == .complete || attemptsForPendingChange >= maxCaptureAttempts {
107
124
  pendingChangeCount = nil
108
125
  }
109
126
  }