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.
- package/CHANGELOG.md +26 -2
- package/package.json +1 -1
- package/src/checks/SK027-agent-wiki-links.check.ts +6 -2
- package/src/checks/SK033-global-contribution.check.ts +47 -0
- package/src/cli/cmd_GlobalContext.ts +47 -0
- package/src/cli/cmd_GlobalProcesses.ts +38 -1
- package/src/cli/cmd_Insights.ts +78 -0
- package/src/cli.ts +6 -1
- package/src/core/global-contribution.ts +42 -0
- package/src/core/trace-audit.ts +16 -7
- package/src/home/global-context.ts +145 -0
- package/src/home/gui-app/ShipkitGui.swift +125 -41
- package/src/home/home-routes.ts +25 -0
- package/src/home/insights-shape.ts +124 -0
- package/src/home/insights.ts +90 -0
- package/src/home/serve-web.ts +3 -3
- package/src/home/widgets.ts +12 -2
- package/src/serve/hub-app/home-st.ts +12 -5
- package/src/serve/hub-app/home-strip.css +2 -0
- package/src/serve/hub-app/home-strip.tsx +4 -0
- package/src/serve/hub-app/home.html +1 -0
- package/src/serve/hub-app/hub-app.tsx +2 -0
- package/src/serve/hub-app/icons.tsx +1 -0
- package/src/serve/hub-app/index.html +1 -0
- package/src/serve/hub-app/insights-st.ts +111 -0
- package/src/serve/hub-app/insights-view.tsx +112 -0
- package/src/serve/hub-app/insights.css +24 -0
- package/src/serve/hub-app/keys.ts +1 -1
- package/src/serve/hub-app/machine-st.ts +23 -0
- package/src/serve/hub-app/palette-st.ts +2 -1
- package/src/serve/hub-app/project-list.tsx +3 -2
- package/src/serve/hub-app/projects-svc.ts +1 -1
- package/src/serve/hub-app/repo-st.ts +1 -1
- package/src/serve/hub-runtime.ts +31 -21
- package/src/serve/run-lanes.ts +134 -0
- package/src/shipkit-files.ts +3 -2
- package/src/suite/judge-suites.ts +27 -7
- package/src/suite/reads-env.ts +8 -0
- package/src/suite/select.ts +510 -0
- package/src/suite/suite-cli.ts +196 -14
- 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
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
final class Pane: NSView,
|
|
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
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
59
|
+
var isFindBarVisible = false {
|
|
42
60
|
didSet {
|
|
43
|
-
|
|
44
|
-
if let bar = findBarView { addSubview(bar) }
|
|
61
|
+
bar.isHidden = !isFindBarVisible
|
|
45
62
|
needsLayout = true
|
|
46
63
|
}
|
|
47
64
|
}
|
|
48
65
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
|
|
111
|
+
func controlTextDidChange(_ note: Notification) { fromTop() }
|
|
59
112
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
132
|
-
@objc func
|
|
133
|
-
|
|
134
|
-
|
|
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(
|
|
155
|
-
|
|
156
|
-
|
|
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(
|
|
246
|
-
item(find, "Find Next", #selector(
|
|
247
|
-
item(find, "Find Previous", #selector(
|
|
248
|
-
item(find, "Hide Find Bar", #selector(
|
|
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()
|
package/src/home/home-routes.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/home/serve-web.ts
CHANGED
|
@@ -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,
|
|
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)
|
package/src/home/widgets.ts
CHANGED
|
@@ -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')
|