rvlib-shipkit 1.6.0 → 1.8.0

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 (41) hide show
  1. package/CHANGELOG.md +26 -2
  2. package/package.json +1 -1
  3. package/src/checks/SK027-agent-wiki-links.check.ts +6 -2
  4. package/src/checks/SK033-global-contribution.check.ts +47 -0
  5. package/src/cli/cmd_GlobalContext.ts +47 -0
  6. package/src/cli/cmd_GlobalProcesses.ts +38 -1
  7. package/src/cli/cmd_Insights.ts +78 -0
  8. package/src/cli.ts +6 -1
  9. package/src/core/global-contribution.ts +42 -0
  10. package/src/core/trace-audit.ts +16 -7
  11. package/src/home/global-context.ts +145 -0
  12. package/src/home/gui-app/ShipkitGui.swift +125 -41
  13. package/src/home/home-routes.ts +25 -0
  14. package/src/home/insights-shape.ts +124 -0
  15. package/src/home/insights.ts +90 -0
  16. package/src/home/serve-web.ts +3 -3
  17. package/src/home/widgets.ts +12 -2
  18. package/src/serve/hub-app/home-st.ts +12 -5
  19. package/src/serve/hub-app/home-strip.css +2 -0
  20. package/src/serve/hub-app/home-strip.tsx +4 -0
  21. package/src/serve/hub-app/home.html +1 -0
  22. package/src/serve/hub-app/hub-app.tsx +2 -0
  23. package/src/serve/hub-app/icons.tsx +1 -0
  24. package/src/serve/hub-app/index.html +1 -0
  25. package/src/serve/hub-app/insights-st.ts +111 -0
  26. package/src/serve/hub-app/insights-view.tsx +112 -0
  27. package/src/serve/hub-app/insights.css +24 -0
  28. package/src/serve/hub-app/keys.ts +1 -1
  29. package/src/serve/hub-app/machine-st.ts +23 -0
  30. package/src/serve/hub-app/palette-st.ts +2 -1
  31. package/src/serve/hub-app/project-list.tsx +3 -2
  32. package/src/serve/hub-app/projects-svc.ts +1 -1
  33. package/src/serve/hub-app/repo-st.ts +1 -1
  34. package/src/serve/hub-runtime.ts +31 -21
  35. package/src/serve/run-lanes.ts +134 -0
  36. package/src/shipkit-files.ts +3 -2
  37. package/src/suite/judge-suites.ts +27 -7
  38. package/src/suite/reads-env.ts +8 -0
  39. package/src/suite/select.ts +510 -0
  40. package/src/suite/suite-cli.ts +196 -14
  41. package/src/suite/suite-reads-preload.ts +335 -0
@@ -17,55 +17,110 @@ func stays(_ url: URL) -> Bool {
17
17
  url.host == "127.0.0.1" || url.host == "localhost" || url.host == startURL.host
18
18
  }
19
19
 
20
- /** a window's content: the web view, and the find bar above it when shown.
21
- * WKWebView is an NSTextFinderClient, so the platform find bar searches the
22
- * page; this view is the bar's container */
23
- final class Pane: NSView, NSTextFinderBarContainer {
20
+ /** a window's content: the find bar above the web view when shown. WebKit's
21
+ * public find api searches the page: NSTextFinder never shows its bar for a
22
+ * WKWebView, which is no NSTextFinderClient at runtime */
23
+ final class Pane: NSView, NSSearchFieldDelegate {
24
24
  let web: WKWebView
25
- let finder = NSTextFinder()
25
+ let bar = NSStackView()
26
+ let field = NSSearchField()
27
+ let status = NSTextField(labelWithString: "")
28
+ static let barHeight: CGFloat = 34
26
29
 
27
30
  init(web: WKWebView) {
28
31
  self.web = web
29
32
  super.init(frame: .zero)
30
33
  addSubview(web)
31
- finder.client = web
32
- finder.findBarContainer = self
33
- finder.isIncrementalSearchingEnabled = true
34
- finder.incrementalSearchingShouldDimContentView = false
34
+ field.placeholderString = "find in page"
35
+ field.delegate = self
36
+ field.sendsSearchStringImmediately = true
37
+ status.textColor = .secondaryLabelColor
38
+ let prev = NSButton(title: "‹", target: self, action: #selector(findPrevious))
39
+ let next = NSButton(title: "›", target: self, action: #selector(findNext))
40
+ let done = NSButton(title: "Done", target: self, action: #selector(hideFind))
41
+ prev.toolTip = "previous match (⇧⌘G, ⇧↩)"
42
+ next.toolTip = "next match (⌘G, ↩)"
43
+ for b in [prev, next, done] { b.bezelStyle = .rounded; b.controlSize = .small }
44
+ field.controlSize = .small
45
+ field.widthAnchor.constraint(greaterThanOrEqualToConstant: 220).isActive = true
46
+ bar.orientation = .horizontal
47
+ bar.spacing = 6
48
+ bar.edgeInsets = NSEdgeInsets(top: 4, left: 8, bottom: 4, right: 8)
49
+ for v in [field, status, prev, next, done] as [NSView] { bar.addArrangedSubview(v) }
50
+ bar.setHuggingPriority(.defaultLow, for: .horizontal)
51
+ bar.isHidden = true
52
+ addSubview(bar)
35
53
  }
36
54
 
37
55
  required init?(coder: NSCoder) { fatalError("not from a nib") }
38
56
 
39
57
  override var isFlipped: Bool { true }
40
58
 
41
- var findBarView: NSView? {
59
+ var isFindBarVisible = false {
42
60
  didSet {
43
- oldValue?.removeFromSuperview()
44
- if let bar = findBarView { addSubview(bar) }
61
+ bar.isHidden = !isFindBarVisible
45
62
  needsLayout = true
46
63
  }
47
64
  }
48
65
 
49
- var isFindBarVisible = false {
50
- didSet {
51
- findBarView?.isHidden = !isFindBarVisible
52
- needsLayout = true
66
+ override func layout() {
67
+ super.layout()
68
+ let top = isFindBarVisible ? Pane.barHeight : 0
69
+ bar.frame = NSRect(x: 0, y: 0, width: bounds.width, height: Pane.barHeight)
70
+ web.frame = NSRect(x: 0, y: top, width: bounds.width, height: max(0, bounds.height - top))
71
+ }
72
+
73
+ @objc func showFind() {
74
+ isFindBarVisible = true
75
+ window?.makeFirstResponder(field)
76
+ field.currentEditor()?.selectAll(nil)
77
+ }
78
+
79
+ @objc func hideFind() {
80
+ isFindBarVisible = false
81
+ window?.makeFirstResponder(web)
82
+ }
83
+
84
+ @objc func findNext() { search(backwards: false) }
85
+ @objc func findPrevious() { search(backwards: true) }
86
+
87
+ /** the next match of the field's text from the page's selection, wrapping;
88
+ * `done` hears whether one was found */
89
+ func search(backwards: Bool, done: ((Bool) -> Void)? = nil) {
90
+ let q = field.stringValue
91
+ if q.isEmpty {
92
+ status.stringValue = ""
93
+ done?(false)
94
+ return
95
+ }
96
+ let c = WKFindConfiguration()
97
+ c.backwards = backwards
98
+ c.wraps = true
99
+ c.caseSensitive = false
100
+ web.find(q, configuration: c) { r in
101
+ self.status.stringValue = r.matchFound ? "" : "no match"
102
+ done?(r.matchFound)
53
103
  }
54
104
  }
55
105
 
56
- func findBarViewDidChangeHeight() { needsLayout = true }
106
+ /** typing searches again from the top of the page */
107
+ func fromTop(done: ((Bool) -> Void)? = nil) {
108
+ web.evaluateJavaScript("getSelection().removeAllRanges()") { _, _ in self.search(backwards: false, done: done) }
109
+ }
57
110
 
58
- func contentView() -> NSView? { web }
111
+ func controlTextDidChange(_ note: Notification) { fromTop() }
59
112
 
60
- override func layout() {
61
- super.layout()
62
- var top: CGFloat = 0
63
- if let bar = findBarView, isFindBarVisible {
64
- let h = bar.frame.height
65
- bar.frame = NSRect(x: 0, y: 0, width: bounds.width, height: h)
66
- top = h
113
+ // in the field: return is the next match, shift return the previous, esc closes
114
+ func control(_ control: NSControl, textView: NSTextView, doCommandBy sel: Selector) -> Bool {
115
+ if sel == #selector(NSResponder.insertNewline(_:)) {
116
+ search(backwards: NSApp.currentEvent?.modifierFlags.contains(.shift) == true)
117
+ return true
67
118
  }
68
- web.frame = NSRect(x: 0, y: top, width: bounds.width, height: max(0, bounds.height - top))
119
+ if sel == #selector(NSResponder.cancelOperation(_:)) {
120
+ hideFind()
121
+ return true
122
+ }
123
+ return false
69
124
  }
70
125
  }
71
126
 
@@ -74,12 +129,12 @@ final class Delegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKU
74
129
  var opened = false
75
130
 
76
131
  func applicationDidFinishLaunching(_ note: Notification) {
132
+ if CommandLine.arguments.contains("--self-test-find") { return selfTestFind() }
77
133
  buildMenu()
78
134
  // esc closes the find bar of the key window, and reaches the page otherwise
79
135
  NSEvent.addLocalMonitorForEvents(matching: .keyDown) { e in
80
136
  if e.keyCode == 53, let pane = self.keyPane(), pane.isFindBarVisible {
81
- pane.finder.performAction(.hideFindInterface)
82
- pane.window?.makeFirstResponder(pane.web)
137
+ pane.hideFind()
83
138
  return nil
84
139
  }
85
140
  return e
@@ -128,11 +183,10 @@ final class Delegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKU
128
183
  @objc func zoomOut(_ sender: Any?) { zoom = zoom / 1.1 }
129
184
  @objc func zoomReset(_ sender: Any?) { zoom = 1 }
130
185
 
131
- // the menu item's tag is the NSTextFinder.Action it performs
132
- @objc func findAction(_ sender: NSMenuItem) {
133
- guard let pane = keyPane(), let action = NSTextFinder.Action(rawValue: sender.tag) else { return }
134
- pane.finder.performAction(action)
135
- }
186
+ @objc func showFind(_ sender: Any?) { keyPane()?.showFind() }
187
+ @objc func hideFind(_ sender: Any?) { keyPane()?.hideFind() }
188
+ @objc func findNext(_ sender: Any?) { keyPane()?.findNext() }
189
+ @objc func findPrevious(_ sender: Any?) { keyPane()?.findPrevious() }
136
190
 
137
191
  // no public api opens the inspector: `_inspector` then `show` is the
138
192
  // WebKit call the context menu's Inspect Element makes. Where it is gone,
@@ -151,10 +205,9 @@ final class Delegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKU
151
205
  }
152
206
 
153
207
  func validateMenuItem(_ item: NSMenuItem) -> Bool {
154
- if item.action == #selector(findAction(_:)) {
155
- guard let pane = keyPane(), let action = NSTextFinder.Action(rawValue: item.tag) else { return false }
156
- return pane.finder.validateAction(action)
157
- }
208
+ if item.action == #selector(hideFind(_:)) { return keyPane()?.isFindBarVisible == true }
209
+ if item.action == #selector(findNext(_:)) || item.action == #selector(findPrevious(_:)) { return keyPane()?.field.stringValue.isEmpty == false }
210
+ if item.action == #selector(showFind(_:)) { return keyPane() != nil }
158
211
  if item.action == #selector(devTools(_:)) || item.action == #selector(reload(_:)) { return keyPane() != nil }
159
212
  return true
160
213
  }
@@ -184,12 +237,43 @@ final class Delegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKU
184
237
  NSApp.activate(ignoringOtherApps: true)
185
238
  }
186
239
 
240
+ var onLoad: ((WKWebView) -> Void)?
241
+
242
+ // `--self-test-find`: an offscreen window on a page holding a word twice,
243
+ // the find bar opened, a word on the page searched, then one that is not;
244
+ // what happened printed, exit 0 only when the bar shows and finds
245
+ func selfTestFind() {
246
+ let web = WKWebView(frame: .zero, configuration: WKWebViewConfiguration())
247
+ web.navigationDelegate = self
248
+ let pane = Pane(web: web)
249
+ let win = NSWindow(contentRect: NSRect(x: -4000, y: -4000, width: 600, height: 400), styleMask: [.titled], backing: .buffered, defer: false)
250
+ win.contentView = pane
251
+ win.orderFrontRegardless()
252
+ windows.append(win)
253
+ onLoad = { _ in
254
+ pane.showFind()
255
+ pane.field.stringValue = "needle"
256
+ pane.fromTop { found in
257
+ pane.field.stringValue = "haystack"
258
+ pane.fromTop { absent in
259
+ print("find-bar-visible=\(pane.isFindBarVisible)")
260
+ print("found=\(found)")
261
+ print("absent-found=\(absent)")
262
+ print("status=\(pane.status.stringValue)")
263
+ exit(pane.isFindBarVisible && found && !absent ? 0 : 1)
264
+ }
265
+ }
266
+ }
267
+ web.loadHTMLString("<p>one needle, two needle</p>", baseURL: nil)
268
+ }
269
+
187
270
  func windowWillClose(_ note: Notification) {
188
271
  windows.removeAll { $0 === note.object as? NSWindow }
189
272
  }
190
273
 
191
274
  // the page title names the window, so cmd-tab and the window menu tell pages apart
192
275
  func webView(_ web: WKWebView, didFinish nav: WKNavigation!) {
276
+ onLoad?(web)
193
277
  web.window?.title = (web.title?.isEmpty == false) ? web.title! : appName
194
278
  }
195
279
 
@@ -242,10 +326,10 @@ final class Delegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKU
242
326
  edit.addItem(.separator())
243
327
  let findItem = edit.addItem(withTitle: "Find", action: nil, keyEquivalent: "")
244
328
  let find = NSMenu(title: "Find")
245
- item(find, "Find…", #selector(findAction(_:)), "f", tag: NSTextFinder.Action.showFindInterface.rawValue)
246
- item(find, "Find Next", #selector(findAction(_:)), "g", tag: NSTextFinder.Action.nextMatch.rawValue)
247
- item(find, "Find Previous", #selector(findAction(_:)), "g", [.command, .shift], tag: NSTextFinder.Action.previousMatch.rawValue)
248
- item(find, "Hide Find Bar", #selector(findAction(_:)), "", [], tag: NSTextFinder.Action.hideFindInterface.rawValue)
329
+ item(find, "Find…", #selector(showFind(_:)), "f")
330
+ item(find, "Find Next", #selector(findNext(_:)), "g")
331
+ item(find, "Find Previous", #selector(findPrevious(_:)), "g", [.command, .shift])
332
+ item(find, "Hide Find Bar", #selector(hideFind(_:)), "", [])
249
333
  edit.setSubmenu(find, for: findItem)
250
334
  editItem.submenu = edit
251
335
  let viewItem = NSMenuItem()
@@ -17,6 +17,8 @@ import { Hubs, type WorkerView } from './hubs.ts'
17
17
  import type { Planner, PlanRow } from '../plan/planner.ts'
18
18
  import type { Widgets, WidgetView } from './widgets.ts'
19
19
  import { ActivityCache, repoActivity, windowMinutes } from './activity.ts'
20
+ import { devRepos, InsightsCache, type Scope } from './insights.ts'
21
+ import { GLOBAL_CONTEXT_EVERY_MS, houseContext, writeIfChanged } from './global-context.ts'
20
22
  import { HouseRuns } from '../serve/house-runs.ts'
21
23
 
22
24
  /** how far back a repo's git page lists its commits */
@@ -111,6 +113,8 @@ export function homeRoutes(d: HomeDeps) {
111
113
  const modernizing = new Map<string, ModernizeView & { log: string }>()
112
114
  // read only while the activity page asks, never on the tick
113
115
  const activity = new ActivityCache({ declared: (name) => d.plan?.declared(name) ?? null, titles: (name) => d.plan?.titles(name) ?? null })
116
+ // read only while the insights page asks, one answer per scope
117
+ const insights = new InsightsCache()
114
118
  const modernizeLog = (repo: string): string => join(d.dir, 'modernize', `${repo}.log`)
115
119
  // the dock: every loaded repo's recent runs, read only while a page asks
116
120
  const house = new HouseRuns(() => repos)
@@ -123,6 +127,7 @@ export function homeRoutes(d: HomeDeps) {
123
127
  }
124
128
 
125
129
  let active = new Set<string>()
130
+ let contextAt = -Infinity
126
131
  if (d.plan != null) d.plan.active = (name) => active.has(name)
127
132
 
128
133
  async function tick(): Promise<void> {
@@ -130,6 +135,14 @@ export function homeRoutes(d: HomeDeps) {
130
135
  active = activeSet(rows, d.state.data.active, rows.find((r) => r.path === d.root)?.name ?? d.name)
131
136
  // the plan's clock: one look per tick, and it starts at most one batch
132
137
  d.plan?.tick()
138
+ // the house's generated context: rewritten only when a source moved,
139
+ // and never held on
140
+ if (Date.now() - contextAt >= GLOBAL_CONTEXT_EVERY_MS) {
141
+ contextAt = Date.now()
142
+ void houseContext(d.root)
143
+ .then((c) => { if (c != null && writeIfChanged(c.path, c.text)) console.log(`ok: wrote ${c.path}`) })
144
+ .catch((e) => console.error(`WARN the global context could not be written: ${(e as Error).message}`))
145
+ }
133
146
  // a widget render never holds the tick back
134
147
  void d.widgets?.tick(repos.map((r) => ({ name: r.name, path: r.path }))).catch((e) => console.error(`WARN the widgets broke: ${(e as Error).message}`))
135
148
  await d.hubs.tick(active)
@@ -176,6 +189,12 @@ export function homeRoutes(d: HomeDeps) {
176
189
  const q = new URL(req.url).searchParams
177
190
  return Response.json(await activity.get(repos, windowMinutes(q.get('m')), { fresh: q.get('fresh') === '1' }))
178
191
  }
192
+ if (path === '/api/home/insights.json') {
193
+ if (repos.length === 0) await refresh()
194
+ const q = new URL(req.url).searchParams
195
+ const scope: Scope = q.get('scope') === 'dev' ? 'dev' : 'house'
196
+ return Response.json(await insights.get(scope, () => (scope === 'dev' ? devRepos() : repos), { fresh: q.get('fresh') === '1' }))
197
+ }
179
198
  // one repo's git for its git page: a week of commits, and HEAD's last
180
199
  // one past that, whatever window the activity page shows
181
200
  if (path === '/api/home/git.json') {
@@ -209,6 +228,12 @@ export function homeRoutes(d: HomeDeps) {
209
228
  if (b.act === 'restart') return Response.json(await d.hubs.restart(r))
210
229
  return new Response(`act must be ensure, stop or restart`, { status: 400 })
211
230
  }
231
+ if (path === '/api/home/widgets/refresh') {
232
+ const b = await body(req)
233
+ const v = await d.widgets?.refresh(String(b.repo), String(b.id))
234
+ if (v == null) return new Response(`no widget ${String(b.id)} in ${String(b.repo)}`, { status: 404 })
235
+ return Response.json(v)
236
+ }
212
237
  if (path === '/api/home/planned/run') {
213
238
  const b = await body(req)
214
239
  if (d.plan == null || !d.plan.ask(String(b.repo), String(b.check))) return new Response(`no planned check ${String(b.check)} in ${String(b.repo)}`, { status: 404 })
@@ -0,0 +1,124 @@
1
+ // the insights page's boxes, computed from every package.json a scope of repos
2
+ // tracks. Pure: no disk, so the page, `sk insights` and the tests share it.
3
+ // Every box has one shape (a row per key, the repos using it, one value per
4
+ // use), so a new box is one function in BOXES and the page draws it unchanged.
5
+
6
+ /** one package.json, read: its repo, its dir inside the repo ('' at the root) */
7
+ export type Manifest = {
8
+ repo: string
9
+ where: string
10
+ json: { dependencies?: Record<string, string>; devDependencies?: Record<string, string>; peerDependencies?: Record<string, string>; scripts?: Record<string, string> }
11
+ }
12
+
13
+ /** one package.json using the row's key, and what it says for it */
14
+ export type InsightUse = { repo: string; where: string; value: string; dev?: boolean }
15
+
16
+ export type InsightRow = {
17
+ key: string
18
+ /** distinct repos using it: the sort */
19
+ repos: number
20
+ /** its distinct values, most used first (the version ranges of a package) */
21
+ values: string[]
22
+ uses: InsightUse[]
23
+ }
24
+
25
+ export type InsightBox = {
26
+ id: string
27
+ title: string
28
+ tags: string[]
29
+ /** one line on what it lists and how it sorts */
30
+ hint: string
31
+ /** what a row's key is, for the filter's placeholder */
32
+ noun: string
33
+ /** the filter reads the values too (a script's command), not only the key */
34
+ searchValues?: boolean
35
+ rows: InsightRow[]
36
+ }
37
+
38
+ export const SCOPES = ['house', 'dev'] as const
39
+ export type Scope = (typeof SCOPES)[number]
40
+
41
+ export type InsightsAnswer = {
42
+ now: number
43
+ scope: Scope
44
+ /** repos read, and package.json files found in them */
45
+ repos: number
46
+ files: number
47
+ boxes: InsightBox[]
48
+ /** a manifest that would not parse, as `repo/where: why` */
49
+ errors: string[]
50
+ ms: number
51
+ }
52
+
53
+ const byCount = <T>(xs: T[]): T[] => {
54
+ const n = new Map<T, number>()
55
+ for (const x of xs) n.set(x, (n.get(x) ?? 0) + 1)
56
+ return [...n.keys()].sort((a, b) => n.get(b)! - n.get(a)!)
57
+ }
58
+
59
+ /** one row per key, the uses gathered, sorted by distinct repos then key */
60
+ function rowsOf(uses: [string, InsightUse][]): InsightRow[] {
61
+ const by = new Map<string, InsightUse[]>()
62
+ for (const [k, u] of uses) {
63
+ if (!by.has(k)) by.set(k, [])
64
+ by.get(k)!.push(u)
65
+ }
66
+ return [...by]
67
+ .map(([key, us]) => ({ key, repos: new Set(us.map((u) => u.repo)).size, values: byCount(us.map((u) => u.value)), uses: us.sort((a, b) => a.repo.localeCompare(b.repo) || a.where.localeCompare(b.where)) }))
68
+ .sort((a, b) => b.repos - a.repos || a.key.localeCompare(b.key))
69
+ }
70
+
71
+ /** a range that names no published version: a workspace or local link */
72
+ export const localRange = (v: string): boolean => /^(workspace:|file:|link:|portal:)/.test(v)
73
+
74
+ function depUses(ms: Manifest[]): [string, InsightUse][] {
75
+ return ms.flatMap((m) => [
76
+ ...Object.entries(m.json.dependencies ?? {}).map(([k, v]): [string, InsightUse] => [k, { repo: m.repo, where: m.where, value: v }]),
77
+ ...Object.entries(m.json.devDependencies ?? {}).map(([k, v]): [string, InsightUse] => [k, { repo: m.repo, where: m.where, value: v, dev: true }]),
78
+ ])
79
+ }
80
+
81
+ export const BOXES: ((ms: Manifest[]) => InsightBox)[] = [
82
+ (ms) => ({
83
+ id: 'deps',
84
+ title: 'dependency usage',
85
+ tags: ['deps'],
86
+ hint: 'every package in a dependencies or devDependencies, by how many repos use it',
87
+ noun: 'package',
88
+ rows: rowsOf(depUses(ms)),
89
+ }),
90
+ (ms) => ({
91
+ id: 'drift',
92
+ title: 'version drift',
93
+ tags: ['deps', 'health'],
94
+ hint: 'packages asked at more than one version range across repos, by how many ranges',
95
+ noun: 'package',
96
+ rows: rowsOf(depUses(ms).filter(([, u]) => !localRange(u.value)))
97
+ .filter((r) => r.values.length > 1)
98
+ .sort((a, b) => b.values.length - a.values.length || b.repos - a.repos || a.key.localeCompare(b.key)),
99
+ }),
100
+ (ms) => ({
101
+ id: 'scripts',
102
+ title: 'script names',
103
+ tags: ['scripts'],
104
+ hint: 'every package.json script name, by how many repos declare it, each with its command',
105
+ noun: 'script',
106
+ searchValues: true,
107
+ rows: rowsOf(ms.flatMap((m) => Object.entries(m.json.scripts ?? {}).map(([k, v]): [string, InsightUse] => [k, { repo: m.repo, where: m.where, value: v }]))),
108
+ }),
109
+ ]
110
+
111
+ /** a row matches a query on its key, or on a value when its box searches them */
112
+ export const rowMatches = (b: InsightBox, r: InsightRow, q: string): boolean => {
113
+ const s = q.trim().toLowerCase()
114
+ return s === '' || r.key.toLowerCase().includes(s) || (b.searchValues === true && r.values.some((v) => v.toLowerCase().includes(s)))
115
+ }
116
+
117
+ /** every box over these manifests */
118
+ export const boxesOf = (ms: Manifest[]): InsightBox[] => BOXES.map((b) => b(ms))
119
+
120
+ /** every tag the boxes carry, in first seen order */
121
+ export const tagsOf = (boxes: InsightBox[]): string[] => [...new Set(boxes.flatMap((b) => b.tags))]
122
+
123
+ /** a use's place, as the page and the cli print it */
124
+ export const placeWords = (u: InsightUse): string => (u.where === '' ? u.repo : `${u.repo}/${u.where}`)
@@ -0,0 +1,90 @@
1
+ // the insights page's data: every package.json a scope of repos tracks, read
2
+ // and turned into boxes (insights-shape.ts). Two scopes: the house repos, or
3
+ // every folder of ~/dev with a package.json or a .git, so a package used in an
4
+ // unwired repo can still be found and reused.
5
+ //
6
+ // the one constraint a reader breaks first: this runs only when the page or
7
+ // `sk insights` asks (no tick), and an answer is kept per scope for
8
+ // INSIGHTS_TTL_MS, so a page left open never walks ~/dev twice a minute.
9
+ import { existsSync, readdirSync, readFileSync } from 'fs'
10
+ import { homedir } from 'os'
11
+ import { join } from 'path'
12
+ import { gitBin } from '../core/git-bin.ts'
13
+ import { boxesOf, type InsightsAnswer, type Manifest, type Scope } from './insights-shape.ts'
14
+
15
+ export * from './insights-shape.ts'
16
+
17
+ export const INSIGHTS_TTL_MS = 5 * 60_000
18
+ export const INSIGHTS_CONCURRENCY = 12
19
+
20
+ export type InsightRepo = { name: string; path: string }
21
+
22
+ /** every folder of ~/dev that holds a package.json or a .git, by name */
23
+ export function devRepos(userHome: string = homedir()): InsightRepo[] {
24
+ const root = join(userHome, 'dev')
25
+ let names: string[] = []
26
+ try {
27
+ names = readdirSync(root, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith('.')).map((e) => e.name)
28
+ } catch {
29
+ return []
30
+ }
31
+ return names
32
+ .map((name) => ({ name, path: join(root, name) }))
33
+ .filter((r) => existsSync(join(r.path, 'package.json')) || existsSync(join(r.path, '.git')))
34
+ .sort((a, b) => a.name.localeCompare(b.name))
35
+ }
36
+
37
+ /** the package.json paths a repo tracks; the root one alone outside git */
38
+ async function manifestPaths(dir: string): Promise<string[]> {
39
+ if (!existsSync(join(dir, '.git'))) return existsSync(join(dir, 'package.json')) ? ['package.json'] : []
40
+ const p = Bun.spawn([gitBin(), 'ls-files', '-z', '--', 'package.json', '**/package.json'], { cwd: dir, stdout: 'pipe', stderr: 'ignore', env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' } })
41
+ const [out, code] = await Promise.all([new Response(p.stdout).text(), p.exited])
42
+ if (code !== 0) return existsSync(join(dir, 'package.json')) ? ['package.json'] : []
43
+ return out.split('\0').filter(Boolean)
44
+ }
45
+
46
+ /** one repo's manifests, read; a file that does not parse is an error line */
47
+ export async function repoManifests(r: InsightRepo): Promise<{ manifests: Manifest[]; errors: string[] }> {
48
+ const manifests: Manifest[] = []
49
+ const errors: string[] = []
50
+ for (const rel of await manifestPaths(r.path)) {
51
+ const where = rel === 'package.json' ? '' : rel.slice(0, -'/package.json'.length)
52
+ try {
53
+ manifests.push({ repo: r.name, where, json: JSON.parse(readFileSync(join(r.path, rel), 'utf8')) })
54
+ } catch (e) {
55
+ // a deleted but still tracked file is no error, only nothing to read
56
+ if ((e as NodeJS.ErrnoException).code !== 'ENOENT') errors.push(`${r.name}/${rel}: ${(e as Error).message}`)
57
+ }
58
+ }
59
+ return { manifests, errors }
60
+ }
61
+
62
+ /** THE computation: the daemon's route and `sk insights` both call it */
63
+ export async function insightsOf(repos: InsightRepo[], scope: Scope, now = Date.now()): Promise<InsightsAnswer> {
64
+ const t0 = performance.now()
65
+ const all: { manifests: Manifest[]; errors: string[] }[] = []
66
+ for (let i = 0; i < repos.length; i += INSIGHTS_CONCURRENCY) all.push(...(await Promise.all(repos.slice(i, i + INSIGHTS_CONCURRENCY).map(repoManifests))))
67
+ const manifests = all.flatMap((a) => a.manifests)
68
+ return { now, scope, repos: repos.length, files: manifests.length, boxes: boxesOf(manifests), errors: all.flatMap((a) => a.errors), ms: Math.round(performance.now() - t0) }
69
+ }
70
+
71
+ /** one answer per scope, shared for INSIGHTS_TTL_MS; `fresh` always reads */
72
+ export class InsightsCache {
73
+ private kept = new Map<Scope, InsightsAnswer>()
74
+ private running = new Map<Scope, Promise<InsightsAnswer>>()
75
+
76
+ constructor(private o: { ttlMs?: number; now?: () => number } = {}) {}
77
+
78
+ get(scope: Scope, repos: () => InsightRepo[], o: { fresh?: boolean } = {}): Promise<InsightsAnswer> {
79
+ const now = (this.o.now ?? Date.now)()
80
+ const k = this.kept.get(scope)
81
+ if (k != null && o.fresh !== true && now - k.now < (this.o.ttlMs ?? INSIGHTS_TTL_MS)) return Promise.resolve(k)
82
+ const going = this.running.get(scope)
83
+ if (going != null) return going
84
+ const p = insightsOf(repos(), scope, now)
85
+ .then((a) => { this.kept.set(scope, a); return a })
86
+ .finally(() => this.running.delete(scope))
87
+ this.running.set(scope, p)
88
+ return p
89
+ }
90
+ }
@@ -14,7 +14,7 @@ import { hubBase } from './home-paths.ts'
14
14
  /** the page routes: ONE app for the overview, the plan, and the hub page of
15
15
  * each known repo, which moves between them in place (home-st.ts) */
16
16
  export function pageRoutes<T>(names: string[], page: T): Record<string, T> {
17
- const out: Record<string, T> = { '/': page, '/planned': page, '/activity': page, '/archived': page }
17
+ const out: Record<string, T> = { '/': page, '/planned': page, '/activity': page, '/archived': page, '/insights': page }
18
18
  for (const n of names) out[`${hubBase(n)}/`] = page
19
19
  return out
20
20
  }
@@ -51,8 +51,8 @@ export const REPO_ANSWER_MS = 30_000
51
51
  export const LIST_EVERY_MS = 30_000
52
52
  export const LIST_RETRY_MS = 1_000
53
53
 
54
- /** the home answers that can wait on git: the activity page's first read of a window, and a repo's git page */
55
- export const readsGit = (path: string): boolean => path === '/api/home/activity.json' || path === '/api/home/git.json'
54
+ /** the home answers that can wait on git: the activity page's first read of a window, a repo's git page, and the insights page's read of every manifest */
55
+ export const readsGit = (path: string): boolean => path === '/api/home/activity.json' || path === '/api/home/git.json' || path === '/api/home/insights.json'
56
56
 
57
57
  /** a run or a check run streams for minutes, and a silent one is never cut */
58
58
  export const streams = (path: string): boolean => /\/api\/(run|check\/run)$/.test(path)
@@ -104,12 +104,12 @@ export class Widgets {
104
104
  }
105
105
 
106
106
  /** one render, bounded, its result or its error kept */
107
- async render(s: Slot): Promise<void> {
107
+ async render(s: Slot, force = false): Promise<void> {
108
108
  s.busy = true
109
109
  let timer: ReturnType<typeof setTimeout> | undefined
110
110
  try {
111
111
  const html = await Promise.race([
112
- Promise.resolve().then(() => s.w.def.render()),
112
+ Promise.resolve().then(() => s.w.def.render({ force })),
113
113
  new Promise<never>((_, no) => { timer = setTimeout(() => no(new Error(`render took more than ${WIDGET_RENDER_MS / 1000}s`)), WIDGET_RENDER_MS) }),
114
114
  ])
115
115
  if (typeof html !== 'string') throw new Error(`render returned ${typeof html}, not an html string`)
@@ -123,6 +123,16 @@ export class Widgets {
123
123
  }
124
124
  }
125
125
 
126
+ /** a reader's refresh: one widget rendered now with force, its view after
127
+ * it; null when no widget has that repo and id */
128
+ async refresh(repo: string, id: string): Promise<WidgetView | null> {
129
+ const s = this.slots.get(`${repo}/${id}`)
130
+ if (s == null) return null
131
+ this.readAt = this.now()
132
+ await this.render(s, true)
133
+ return s.view
134
+ }
135
+
126
136
  /** the last html of each; a read that keeps the clocks running */
127
137
  views(): WidgetView[] {
128
138
  this.readAt = this.now()
@@ -18,6 +18,7 @@ import { DockSt } from './dock-st.ts'
18
18
  import { PaletteSt } from './palette-st.ts'
19
19
  import { PlannedSt } from './planned-st.ts'
20
20
  import { ActivitySt } from './activity-st.ts'
21
+ import { InsightsSt } from './insights-st.ts'
21
22
  import { ProjectsSvc } from './projects-svc.ts'
22
23
  import { parseFilter, RepoSt } from './repo-st.ts'
23
24
  import { openSection, repoSections, sectionOfRepo } from './repo-sections.ts'
@@ -25,11 +26,11 @@ import { openSection, repoSections, sectionOfRepo } from './repo-sections.ts'
25
26
  /** the longest one repo's read ahead holds the next one back */
26
27
  export const PREFETCH_WAIT_MS = 10_000
27
28
 
28
- /** what the page shows: the machine overview, the plan, the activity, or one repo */
29
- export type Page = 'overview' | 'planned' | 'activity' | 'archived' | 'repo'
29
+ /** what the page shows: the machine overview, the plan, the activity, the insights, the archived repos, or one repo */
30
+ export type Page = 'overview' | 'planned' | 'activity' | 'insights' | 'archived' | 'repo'
30
31
 
31
32
  /** the address of each page of the home that is no repo's */
32
- export const PLACE_PATH = { overview: '/', planned: '/planned', activity: '/activity', archived: '/archived' } as const
33
+ export const PLACE_PATH = { overview: '/', planned: '/planned', activity: '/activity', insights: '/insights', archived: '/archived' } as const
33
34
 
34
35
  /** where a repo's hub answers on the home */
35
36
  export const repoBase = (name: string): string => `/r/${encodeURIComponent(name)}`
@@ -46,6 +47,7 @@ export function routeOf(pathname: string): { page: Page; repo: string | null } {
46
47
  if (m != null) return { page: 'repo', repo: decodeURIComponent(m[1]!) }
47
48
  if (/^\/planned\/?$/.test(pathname)) return { page: 'planned', repo: null }
48
49
  if (/^\/activity\/?$/.test(pathname)) return { page: 'activity', repo: null }
50
+ if (/^\/insights\/?$/.test(pathname)) return { page: 'insights', repo: null }
49
51
  if (/^\/archived\/?$/.test(pathname)) return { page: 'archived', repo: null }
50
52
  return { page: 'overview', repo: null }
51
53
  }
@@ -69,6 +71,7 @@ export class HomeSt {
69
71
  planned: PlannedSt
70
72
  /** what every repo committed and holds, polled while it is the page */
71
73
  activity: ActivitySt
74
+ insights: InsightsSt
72
75
  /** the one picker, cmd+j */
73
76
  palette: PaletteSt
74
77
  /** the widths of the project list and a repo's sidebar */
@@ -91,12 +94,13 @@ export class HomeSt {
91
94
  this.projects = new ProjectsSvc()
92
95
  this.planned = new PlannedSt(this)
93
96
  this.activity = new ActivitySt(this)
97
+ this.insights = new InsightsSt(this)
94
98
  this.palette = new PaletteSt(this)
95
99
  this.layout = new LayoutSt()
96
100
  this.dock = new DockSt(this)
97
101
  this.machine = o.home ? new MachineSt(this, o) : null
98
102
  this.page = o.home ? 'overview' : 'repo'
99
- makeAutoObservable<this, 'disposers' | 'onUrl' | 'prefetching'>(this, { machine: false, projects: false, planned: false, activity: false, palette: false, layout: false, dock: false, repos: observableShallow, disposers: false, onUrl: false, prefetching: false })
103
+ makeAutoObservable<this, 'disposers' | 'onUrl' | 'prefetching'>(this, { machine: false, projects: false, planned: false, activity: false, insights: false, palette: false, layout: false, dock: false, repos: observableShallow, disposers: false, onUrl: false, prefetching: false })
100
104
  const m = this.machine
101
105
  if (m != null) {
102
106
  this.projects.onSelect = (name) => this.select(name)
@@ -129,6 +133,7 @@ export class HomeSt {
129
133
  if (this.page === 'overview') return 'home'
130
134
  if (this.page === 'planned') return 'planned'
131
135
  if (this.page === 'activity') return 'activity'
136
+ if (this.page === 'insights') return 'insights'
132
137
  if (this.page === 'archived') return 'archived'
133
138
  const r = this.shown
134
139
  return r == null || r.name === '' ? 'hub' : `${r.name} · hub`
@@ -222,6 +227,7 @@ export class HomeSt {
222
227
  * everything; the one shown resumes once its hub answers */
223
228
  private show(page: Page, repo: string | null, how: 'push' | 'replace' | 'none'): void {
224
229
  const prev = this.shown
230
+ const prevPage = this.page
225
231
  const next = page === 'repo' && repo != null ? this.repo(repo) : null
226
232
  // the page already shown is never a second history entry
227
233
  if (how === 'push' && page === this.page && next === prev && page !== 'planned') how = 'none'
@@ -234,12 +240,13 @@ export class HomeSt {
234
240
  this.projects.away = next == null
235
241
  this.projects.place = page === 'repo' || page === 'archived' ? null : page
236
242
  if (how !== 'none') {
237
- const to = next != null ? `${repoPage(next.key)}${next.search}${next.hash}` : page === 'planned' ? this.planned.address : page === 'activity' || page === 'archived' ? PLACE_PATH[page] : '/'
243
+ const to = next != null ? `${repoPage(next.key)}${next.search}${next.hash}` : page === 'planned' ? this.planned.address : page === 'activity' || page === 'insights' || page === 'archived' ? PLACE_PATH[page] : '/'
238
244
  if (how === 'push') history.pushState(null, '', to)
239
245
  else history.replaceState(null, '', to)
240
246
  }
241
247
  if (page === 'planned') this.planned.start()
242
248
  else this.planned.stop()
249
+ if (page === 'insights' && prevPage !== 'insights') this.insights.start()
243
250
  if (page === 'activity') this.activity.start()
244
251
  else this.activity.stop()
245
252
  if (next != null && next !== prev && this.machine?.data != null) void this.machine.hub(next.key, 'ensure')