rvlib-shipkit 1.6.0 → 1.7.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 CHANGED
@@ -1,3 +1,13 @@
1
+ ## 1.7.0
2
+
3
+ - **Cmd-F in the shipkit app shows a find bar now.** Since the app shipped, the Find menu did nothing visible: NSTextFinder never shows its bar for a web view. The app now draws its own bar on WebKit's find API. Return and cmd-G go to the next match, shift-return and shift-cmd-G to the previous one, Esc closes the bar, and it says "no match" when the page does not hold the text. Rebuilt apps pick it up at their next launch.
4
+
5
+ - **A new cross-repo page, `/insights`, and its console twin `sk insights`.** It shows a grid of boxes, each with tags, and a tag row filters which boxes show. Dependency usage lists every package by how many repos use it. Click a row to list each repo and the range it asks. Version drift lists the packages asked at more than one range. Script names lists every package.json script with its commands. The scope is the house repos or every folder of `~/dev`, and one filter searches every box.
6
+
7
+ - **Every widget card has a refresh button.** It renders the widget now, off its clock, and passes `render({ force: true })`, so a widget that samples another machine can skip its cache or backoff. A widget that ignores the argument simply renders again.
8
+
9
+ - **The traced audit groups files under a dir the task listed.** A build that lists an asset folder and stats each image in it proposed one `ignoredInputs` line per image. The files now group under the outermost dir the task also listed, keyed by content once more than three fall under it.
10
+
1
11
  ## 1.6.0
2
12
 
3
13
  - **lint, typecheck, test and every task that declares nothing are cached now.** Such a task is keyed on the whole work tree, plus the gitignored paths the new `ignoredInputs` lists for it (`{ test: ['dist/**'] }`, `'*'` for every task; a path by content, `dir/**` by the names under it), and the lockfile, the installed packages, the bun and node versions and the toolkit code behind it. `sk trace-audit` runs each gate task once under a file tracer and names the gitignored paths it reads that its key misses. Tracing is on wherever it can run (Apple silicon macOS): SK031 audits about daily, `sk trace-audit --disable` opts a machine out and `SHIPKIT_TRACE=0` one run, and each finding joins the key of the task that read it at once, in every worktree of the repo. SK032 and the fix pass of `sk ci` then write the findings into their own block of `repo.config.ts`, once the repo pins 1.6.0 or later. The tracer never decides a verdict. It needs Apple silicon macOS: on Linux and in CI nothing audits, and SK030's daily sample is the only net there.
@@ -10,8 +20,6 @@
10
20
 
11
21
  - **SK030 audits the cache.** About once a day, while somebody works in the repo, it re-runs a sample of the checks the cache would answer right now, fresh, and compares. A cached green that a fresh run disagrees with fails, naming the check whose key misses a path its body reads, and the stale answer is dropped so it is never served again. `sk cache-audit` is the same audit on demand (`--sample <n>`, `--only <id>`, `--json`).
12
22
 
13
- ## Unreleased (patch)
14
-
15
23
  - **The hub tree groups assets by kit category.** Under media, each category is a row that folds, and its label opens the category page. An asset row reads by its kit label (`mon/brindon/front`), never its file id.
16
24
 
17
25
  - **SK001 no longer asks a comfy repo for a `comfy:up` script.** The box is a global process that `sk gp start comfyui` starts from any repo, so a per-repo script only copied that command. A `.shipkit/comfy/` folder now asks for nothing, and SK001 no longer probes it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rvlib-shipkit",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "funding": "https://github.com/sponsors/rvion",
5
5
  "description": "All-in-one repo companion: ci gate (task DAG), auto-discovered health checks, release-contract fulfillness, asks queue, md-wiki tools, asset atelier, live tui",
6
6
  "license": "MIT",
@@ -0,0 +1,78 @@
1
+ // shipkit insights: the insights page in the console. The boxes come from
2
+ // insightsOf, the one computation the daemon's route serves the page; this
3
+ // file only renders them as text. It reads the manifests directly and needs no
4
+ // daemon.
5
+ import { homedir } from 'os'
6
+ import { Command, Option } from 'clipanion'
7
+ import { devRepos, insightsOf, placeWords, rowMatches, SCOPES, tagsOf, type InsightsAnswer, type Scope } from '../home/insights.ts'
8
+
9
+ /** the boxes as text: a heading per box, then its rows, top `top` of each */
10
+ export function renderInsights(a: InsightsAnswer, o: { tag?: string | null; query?: string; top: number; uses: boolean }): string[] {
11
+ const out: string[] = []
12
+ for (const b of a.boxes) {
13
+ if (o.tag != null && !b.tags.includes(o.tag)) continue
14
+ const rows = b.rows.filter((r) => rowMatches(b, r, o.query ?? ''))
15
+ out.push(`${b.title} [${b.tags.join(', ')}] ${rows.length} ${b.noun}${rows.length === 1 ? '' : 's'}`)
16
+ const width = Math.max(0, ...rows.slice(0, o.top).map((r) => r.key.length))
17
+ for (const r of rows.slice(0, o.top)) {
18
+ out.push(` ${r.key.padEnd(width)} ${String(r.repos).padStart(3)} repos ${r.values.slice(0, 3).join(' ')}${r.values.length > 3 ? ` +${r.values.length - 3}` : ''}`)
19
+ if (o.uses) for (const u of r.uses) out.push(` ${placeWords(u)} ${u.value}${u.dev ? ' (dev)' : ''}`)
20
+ }
21
+ if (rows.length > o.top) out.push(` … ${rows.length - o.top} more`)
22
+ out.push('')
23
+ }
24
+ out.push(`${a.repos} repos, ${a.files} package.json, read in ${a.ms}ms`)
25
+ for (const e of a.errors) out.push(`WARN ${e}`)
26
+ return out
27
+ }
28
+
29
+ export class InsightsCommand extends Command {
30
+ static override paths = [['insights']]
31
+
32
+ static override usage = Command.Usage({
33
+ category: 'Home',
34
+ description: 'the insights page as text: boxes about every repo, like which packages they use and by how many',
35
+ details: 'every package.json the house repos track (or every folder of ~/dev with --scope dev), turned into boxes: dependency usage sorted by how many repos use each package, version drift, script names. The same computation the page reads, no daemon needed',
36
+ examples: [
37
+ ['every box, the house repos', 'sk insights'],
38
+ ['every repo of ~/dev', 'sk insights --scope dev'],
39
+ ['where a package is used, and at which range', 'sk insights --scope dev --tag deps --query zod --uses'],
40
+ ['the whole list of one tag', 'sk insights --tag deps --top 1000'],
41
+ ['the route\'s exact payload', 'sk insights --json'],
42
+ ],
43
+ })
44
+
45
+ scope = Option.String('--scope', 'house', { description: 'house (the project list) or dev (every folder of ~/dev)' })
46
+ tag = Option.String('--tag', { required: false, description: 'only the boxes carrying this tag' })
47
+ query = Option.String('--query', { required: false, description: 'only the rows whose key or a value holds this text' })
48
+ top = Option.String('--top', '20', { description: 'rows shown per box' })
49
+ uses = Option.Boolean('--uses', false, { description: 'list every use under each row' })
50
+ json = Option.Boolean('--json', false, { description: 'the daemon route\'s exact payload: { now, scope, repos, files, boxes, errors, ms }' })
51
+
52
+ async execute(): Promise<number> {
53
+ if (!(SCOPES as readonly string[]).includes(this.scope)) {
54
+ this.context.stderr.write(`--scope must be one of ${SCOPES.join(', ')}, got ${JSON.stringify(this.scope)}\n`)
55
+ return 2
56
+ }
57
+ const scope = this.scope as Scope
58
+ const top = Number(this.top)
59
+ if (!(top >= 1)) {
60
+ this.context.stderr.write(`--top must be a number from 1, got ${JSON.stringify(this.top)}\n`)
61
+ return 2
62
+ }
63
+ const home = process.env.HOME || homedir()
64
+ // the hub route module draws app icons, so it loads only when this runs
65
+ const repos = scope === 'dev' ? devRepos(home) : (await import('../serve/project-gutter-route.ts')).railRepos(process.cwd(), home)
66
+ const a = await insightsOf(repos, scope)
67
+ if (this.json) {
68
+ this.context.stdout.write(`${JSON.stringify(a, null, 2)}\n`)
69
+ return 0
70
+ }
71
+ if (this.tag != null && !tagsOf(a.boxes).includes(this.tag)) {
72
+ this.context.stderr.write(`--tag must be one of ${tagsOf(a.boxes).join(', ')}, got ${JSON.stringify(this.tag)}\n`)
73
+ return 2
74
+ }
75
+ for (const l of renderInsights(a, { tag: this.tag ?? null, query: this.query, top, uses: this.uses })) this.context.stdout.write(`${l}\n`)
76
+ return 0
77
+ }
78
+ }
package/src/cli.ts CHANGED
@@ -13,6 +13,7 @@ import { aliasArgs, CrossRepoActivityCommand } from './cli/cmd_CrossRepoActivity
13
13
  import { ConfigKeysCommand } from './cli/cmd_ConfigKeys.ts'
14
14
  import { GlobalProcessesCommand, GlobalProcessRestartCommand, GlobalProcessStartCommand, GlobalProcessStopCommand } from './cli/cmd_GlobalProcesses.ts'
15
15
  import { PlannedCommand } from './cli/cmd_Planned.ts'
16
+ import { InsightsCommand } from './cli/cmd_Insights.ts'
16
17
  import { OverviewCommand } from './cli/cmd_Overview.ts'
17
18
  import { CiCommand } from './cli/cmd_Ci.ts'
18
19
  import { ContractsCommand } from './cli/cmd_Contracts.ts'
@@ -99,6 +100,7 @@ export function buildCli(): Cli {
99
100
  cli.register(GlobalProcessRestartCommand)
100
101
  cli.register(CrossRepoActivityCommand)
101
102
  cli.register(PlannedCommand)
103
+ cli.register(InsightsCommand)
102
104
  cli.register(OverviewCommand)
103
105
  cli.register(ChecksCommand)
104
106
  cli.register(WhenCommand)
@@ -47,18 +47,27 @@ function covered(p: string, listing: boolean, extras: string[]): boolean {
47
47
  return extras.some((e) => (isNamesInput(e) ? listing && under(p, e.slice(0, -3)) : under(p, e)))
48
48
  }
49
49
 
50
- /** the fewest entries that cover what was read: each file by itself, or its
51
- * dir once more than three files there were read; each listed dir as
52
- * `dir/**`, the outermost one only */
50
+ /** the fewest entries that cover what was read: each file by itself, or a
51
+ * dir by content once more than three files under it were read (its own
52
+ * dir, or the outermost dir the task also listed); each listed dir as
53
+ * `dir/**`, the outermost one only, unless a content entry covers it */
53
54
  export function proposeInputs(content: string[], listed: string[]): string[] {
54
- const byDir = new Map<string, string[]>()
55
- for (const f of content) byDir.set(posix.dirname(f), [...(byDir.get(posix.dirname(f)) ?? []), f])
56
- const bytes = [...byDir].flatMap(([dir, files]) => (files.length > 3 && dir !== '.' ? [dir] : files))
57
55
  const minimal = (xs: string[]): string[] => {
58
56
  const sorted = [...new Set(xs)].sort()
59
57
  return sorted.filter((x) => !sorted.some((y) => y !== x && under(x, y)))
60
58
  }
61
- return [...minimal(bytes), ...minimal(listed).map((d) => `${d}/**`)].sort()
59
+ const tops = minimal(listed)
60
+ const byDir = new Map<string, string[]>()
61
+ for (const f of content) {
62
+ const d = tops.find((t) => under(f, t)) ?? posix.dirname(f)
63
+ byDir.set(d, [...(byDir.get(d) ?? []), f])
64
+ }
65
+ // a listed dir holding three or fewer: each file under its own dir
66
+ const regroup = [...byDir].flatMap(([dir, files]) => (tops.includes(dir) && files.length <= 3 ? files.map((f) => [posix.dirname(f), f] as const) : files.map((f) => [dir, f] as const)))
67
+ const again = new Map<string, string[]>()
68
+ for (const [d, f] of regroup) again.set(d, [...(again.get(d) ?? []), f])
69
+ const bytes = minimal([...again].flatMap(([dir, files]) => (files.length > 3 && dir !== '.' ? [dir] : files)))
70
+ return [...bytes, ...tops.filter((d) => !bytes.some((b) => under(d, b))).map((d) => `${d}/**`)].sort()
62
71
  }
63
72
 
64
73
  /** trace every gate task of the repo once and report what its key misses */
@@ -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,7 @@ 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'
20
21
  import { HouseRuns } from '../serve/house-runs.ts'
21
22
 
22
23
  /** how far back a repo's git page lists its commits */
@@ -111,6 +112,8 @@ export function homeRoutes(d: HomeDeps) {
111
112
  const modernizing = new Map<string, ModernizeView & { log: string }>()
112
113
  // read only while the activity page asks, never on the tick
113
114
  const activity = new ActivityCache({ declared: (name) => d.plan?.declared(name) ?? null, titles: (name) => d.plan?.titles(name) ?? null })
115
+ // read only while the insights page asks, one answer per scope
116
+ const insights = new InsightsCache()
114
117
  const modernizeLog = (repo: string): string => join(d.dir, 'modernize', `${repo}.log`)
115
118
  // the dock: every loaded repo's recent runs, read only while a page asks
116
119
  const house = new HouseRuns(() => repos)
@@ -176,6 +179,12 @@ export function homeRoutes(d: HomeDeps) {
176
179
  const q = new URL(req.url).searchParams
177
180
  return Response.json(await activity.get(repos, windowMinutes(q.get('m')), { fresh: q.get('fresh') === '1' }))
178
181
  }
182
+ if (path === '/api/home/insights.json') {
183
+ if (repos.length === 0) await refresh()
184
+ const q = new URL(req.url).searchParams
185
+ const scope: Scope = q.get('scope') === 'dev' ? 'dev' : 'house'
186
+ return Response.json(await insights.get(scope, () => (scope === 'dev' ? devRepos() : repos), { fresh: q.get('fresh') === '1' }))
187
+ }
179
188
  // one repo's git for its git page: a week of commits, and HEAD's last
180
189
  // one past that, whatever window the activity page shows
181
190
  if (path === '/api/home/git.json') {
@@ -209,6 +218,12 @@ export function homeRoutes(d: HomeDeps) {
209
218
  if (b.act === 'restart') return Response.json(await d.hubs.restart(r))
210
219
  return new Response(`act must be ensure, stop or restart`, { status: 400 })
211
220
  }
221
+ if (path === '/api/home/widgets/refresh') {
222
+ const b = await body(req)
223
+ const v = await d.widgets?.refresh(String(b.repo), String(b.id))
224
+ if (v == null) return new Response(`no widget ${String(b.id)} in ${String(b.repo)}`, { status: 404 })
225
+ return Response.json(v)
226
+ }
212
227
  if (path === '/api/home/planned/run') {
213
228
  const b = await body(req)
214
229
  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')
@@ -57,6 +57,8 @@
57
57
  .hwidget{flex:0 1 auto;max-width:100%;padding:var(--s0) var(--s2);border:1px solid var(--line);border-radius:3px;font-size:12px;line-height:1.4;overflow:auto}
58
58
  .hwidget h3{margin:0;font-size:12px;font-weight:650;white-space:nowrap}
59
59
  .hwidget h3 .hdim{font-weight:400}
60
+ .hwidget h3 .hwre{width:16px;height:16px;margin-left:var(--s1)}
61
+ .hwidget h3 .hwre .hico{width:12px;height:12px}
60
62
  /* meters in two columns: <table class="wmeters">, label · bar · value, twice a row */
61
63
  .wmeters td{padding-right:var(--s1)}
62
64
  .wmeters td:nth-child(3n){padding-right:var(--s3)}
@@ -211,6 +211,10 @@ const Monitor = observer(({ m }: { m: MachineSt }) => {
211
211
  {x.title}
212
212
  {head == null ? null : <span className="hdim"> · <span dangerouslySetInnerHTML={{ __html: head }} /></span>}
213
213
  {x.error != null && x.html != null ? <i className="gpwarn" title={x.error} /> : null}
214
+ <button className="gpi hwre" aria-label="refresh" disabled={m.refreshing.has(`${x.repo}/${x.id}`)} onClick={() => void m.refreshWidget(x.repo, x.id)}
215
+ title="render it again now, skipping any wait it keeps between samples">
216
+ {m.refreshing.has(`${x.repo}/${x.id}`) ? <span className="spin" /> : <Icon d={ICON.restart} />}
217
+ </button>
214
218
  </h3>
215
219
  {x.html != null ? <div dangerouslySetInnerHTML={{ __html: body }} />
216
220
  : x.error != null ? <div className="hwerr">{x.error}</div>
@@ -11,6 +11,7 @@
11
11
  <link rel="stylesheet" href="./planned.css" />
12
12
  <link rel="stylesheet" href="./when.css" />
13
13
  <link rel="stylesheet" href="./activity.css" />
14
+ <link rel="stylesheet" href="./insights.css" />
14
15
  <link rel="stylesheet" href="./readme.css" />
15
16
  <link rel="stylesheet" href="./dock.css" />
16
17
  <link rel="stylesheet" href="./controls.css" />
@@ -37,6 +37,7 @@ import { LIST, SIDEBAR } from './layout-st.ts'
37
37
  import { ArchivedView, HomeStrip, NotUp, Overview } from './home-strip.tsx'
38
38
  import { PlannedView } from './planned-view.tsx'
39
39
  import { ActivityView } from './activity-view.tsx'
40
+ import { InsightsView } from './insights-view.tsx'
40
41
  import { Palette } from './palette-view.tsx'
41
42
  import { SCREEN_WAIT_MS } from './screen-cache.ts'
42
43
  import { isSearchKey, pageKey, sectionKey } from './keys.ts'
@@ -320,6 +321,7 @@ const App = observer(() => {
320
321
  {home.page === 'overview' ? <Overview app={home} /> : null}
321
322
  {home.page === 'planned' ? <PlannedView app={home} /> : null}
322
323
  {home.page === 'activity' ? <ActivityView app={home} /> : null}
324
+ {home.page === 'insights' ? <InsightsView app={home} /> : null}
323
325
  {home.page === 'archived' ? <ArchivedView app={home} /> : null}
324
326
  {home.visited.map((r) => <RepoView key={r.key} app={r} />)}
325
327
  </Panel>
@@ -12,6 +12,7 @@ export const ICON = {
12
12
  reflections: 'M4 3.5h12v9H9l-4 3.5v-3.5H4zM7.5 7h5M7.5 9.5h3',
13
13
  monitor: 'M3 4h14v9H3zM7 16.5h6M10 13v3.5',
14
14
  archived: 'M2.5 4h15v3.5h-15zM4 7.5V16h12V7.5M8 10.5h4',
15
+ insights: 'M3 16.5h14M5 16.5V11M9 16.5V5M13 16.5V8.5M17 16.5V12',
15
16
  home: 'M3 9.5 10 3.5l7 6M5 8v8.5h10V8M8.5 16.5v-4.5h3v4.5',
16
17
  planned: 'M5.5 3h9M5.5 17h9M6.5 3c0 4 7 4 7 7s-7 3-7 7M13.5 3c0 4-7 4-7 7s7 3 7 7',
17
18
  browser: 'M10 2.5a7.5 7.5 0 1 0 0 15a7.5 7.5 0 1 0 0-15M2.5 10h15M10 2.5c-3 3-3 12 0 15M10 2.5c3 3 3 12 0 15',
@@ -11,6 +11,7 @@
11
11
  <link rel="stylesheet" href="./planned.css" />
12
12
  <link rel="stylesheet" href="./when.css" />
13
13
  <link rel="stylesheet" href="./activity.css" />
14
+ <link rel="stylesheet" href="./insights.css" />
14
15
  <link rel="stylesheet" href="./readme.css" />
15
16
  <link rel="stylesheet" href="./dock.css" />
16
17
  <link rel="stylesheet" href="./controls.css" />
@@ -0,0 +1,111 @@
1
+ // the insights page's state: the daemon's boxes for one scope, the tag that
2
+ // filters which boxes show, and one query that filters every box's rows. It
3
+ // reads once when the page shows and on the refresh button, never on a clock:
4
+ // manifests change far slower than anyone reads them. The scope and the tag
5
+ // are remembered per viewer.
6
+ import { makeAutoObservable, observableRef, runInAction } from 'mobx'
7
+ import { rowMatches, SCOPES, tagsOf, type InsightBox, type InsightRow, type InsightsAnswer, type Scope } from '../../home/insights-shape.ts'
8
+ import type { HomeSt } from './home-st.ts'
9
+ import { ownTrouble, reach } from './reach.ts'
10
+
11
+ /** the rows a box shows before its "show all" */
12
+ export const BOX_ROWS = 25
13
+ const SCOPE_KEY = 'home:insights:scope'
14
+ const TAG_KEY = 'home:insights:tag'
15
+
16
+ function kept(key: string): string | null {
17
+ try {
18
+ return globalThis.localStorage?.getItem(key) ?? null
19
+ } catch {
20
+ return null
21
+ }
22
+ }
23
+ function keep(key: string, v: string): void {
24
+ try { globalThis.localStorage?.setItem(key, v) } catch { /* private window: the pick lasts the tab */ }
25
+ }
26
+
27
+ export class InsightsSt {
28
+ data: InsightsAnswer | null = null
29
+ error = ''
30
+ loading = false
31
+ scope: Scope = (SCOPES as readonly string[]).includes(kept(SCOPE_KEY) ?? '') ? (kept(SCOPE_KEY) as Scope) : 'house'
32
+ /** the tag whose boxes show, null for every box */
33
+ tag: string | null = kept(TAG_KEY) || null
34
+ query = ''
35
+ /** boxes showing every row, past BOX_ROWS */
36
+ full = new Set<string>()
37
+ /** `box/key` of each row whose uses show under it */
38
+ open = new Set<string>()
39
+ private gen = 0
40
+
41
+ constructor(private st: HomeSt) {
42
+ makeAutoObservable<this, 'st' | 'gen'>(this, { st: false, gen: false, data: observableRef })
43
+ }
44
+
45
+ get tags(): string[] {
46
+ return tagsOf(this.data?.boxes ?? [])
47
+ }
48
+
49
+ /** the boxes the tag lets through, each with the rows the query lets through */
50
+ get boxes(): { box: InsightBox; rows: InsightRow[] }[] {
51
+ return (this.data?.boxes ?? [])
52
+ .filter((b) => this.tag == null || !this.tags.includes(this.tag) || b.tags.includes(this.tag))
53
+ .map((box) => ({ box, rows: box.rows.filter((r) => rowMatches(box, r, this.query)) }))
54
+ }
55
+
56
+ setScope(s: Scope): void {
57
+ if (s === this.scope) return
58
+ this.scope = s
59
+ keep(SCOPE_KEY, s)
60
+ void this.load()
61
+ }
62
+
63
+ setTag(t: string | null): void {
64
+ this.tag = t
65
+ keep(TAG_KEY, t ?? '')
66
+ }
67
+
68
+ setQuery(q: string): void {
69
+ this.query = q
70
+ }
71
+
72
+ toggleFull(box: string): void {
73
+ if (this.full.has(box)) this.full.delete(box)
74
+ else this.full.add(box)
75
+ }
76
+
77
+ toggleRow(box: string, key: string): void {
78
+ const k = `${box}/${key}`
79
+ if (this.open.has(k)) this.open.delete(k)
80
+ else this.open.add(k)
81
+ }
82
+
83
+ openRepo(name: string): void {
84
+ this.st.select(name)
85
+ }
86
+
87
+ /** the refresh button: the daemon reads every manifest again */
88
+ refresh(): void {
89
+ if (!this.loading) void this.load(true)
90
+ }
91
+
92
+ async load(fresh = false): Promise<void> {
93
+ const id = ++this.gen
94
+ this.loading = true
95
+ try {
96
+ const r = await reach(`/api/home/insights.json?scope=${this.scope}${fresh ? '&fresh=1' : ''}`, { signal: AbortSignal.timeout(60_000) })
97
+ if (!r.ok) throw new Error(`${r.status} ${await r.text()}`)
98
+ const j = (await r.json()) as InsightsAnswer
99
+ runInAction(() => { if (id === this.gen) { this.data = j; this.error = '' } })
100
+ } catch (e) {
101
+ runInAction(() => { if (id === this.gen) this.error = ownTrouble(e, this.st.machine) })
102
+ } finally {
103
+ runInAction(() => { if (id === this.gen) this.loading = false })
104
+ }
105
+ }
106
+
107
+ /** reads when the page shows */
108
+ start(): void {
109
+ void this.load()
110
+ }
111
+ }
@@ -0,0 +1,112 @@
1
+ // `/insights`: boxes about every repo of a scope, each carrying tags. On top:
2
+ // the scope (house repos or all of ~/dev), the tag row that filters the boxes,
3
+ // one query that filters every box's rows, and the refresh. A box lists its
4
+ // rows by how many repos use each key; a click on a row shows every use, the
5
+ // house repos as links to their page.
6
+ import { observer } from 'mobx-react-lite'
7
+ import { placeWords, type InsightBox, type InsightRow, type Scope } from '../../home/insights-shape.ts'
8
+ import type { HomeSt } from './home-st.ts'
9
+ import { plainClick, repoPage } from './home-st.ts'
10
+ import { BOX_ROWS } from './insights-st.ts'
11
+
12
+ const SCOPE_WORDS: Record<Scope, { label: string; title: string }> = {
13
+ house: { label: 'house', title: 'the repos on the project list' },
14
+ dev: { label: '~/dev', title: 'every folder of ~/dev with a package.json or a .git, wired or not' },
15
+ }
16
+
17
+ /** the values a row shows inline before "+n" */
18
+ const VALUES_SHOWN = 3
19
+
20
+ const Row = observer(({ app, box, r }: { app: HomeSt; box: InsightBox; r: InsightRow }) => {
21
+ const st = app.insights
22
+ const open = st.open.has(`${box.id}/${r.key}`)
23
+ const house = new Set(app.projects.list.map((p) => p.name))
24
+ return (
25
+ <>
26
+ <tr className={`inrow${open ? ' open' : ''}`} onClick={() => st.toggleRow(box.id, r.key)} title={`${r.uses.length} use${r.uses.length === 1 ? '' : 's'}: click to list them`}>
27
+ <td className="inkey">{r.key}</td>
28
+ <td className="incount">{r.repos}</td>
29
+ <td className="invals">
30
+ {r.values.slice(0, VALUES_SHOWN).map((v) => <code key={v}>{v}</code>)}
31
+ {r.values.length > VALUES_SHOWN ? <span className="acdim"> +{r.values.length - VALUES_SHOWN}</span> : null}
32
+ </td>
33
+ </tr>
34
+ {!open ? null : r.uses.map((u, i) => (
35
+ <tr key={i} className="inuse">
36
+ <td className="inplace">
37
+ {house.has(u.repo) ? (
38
+ <a href={repoPage(u.repo)} onClick={(e) => {
39
+ if (!plainClick(e)) return
40
+ e.preventDefault()
41
+ st.openRepo(u.repo)
42
+ }}>{placeWords(u)}</a>
43
+ ) : placeWords(u)}
44
+ </td>
45
+ <td colSpan={2} className="invals"><code>{u.value}</code>{u.dev ? <span className="acdim"> dev</span> : null}</td>
46
+ </tr>
47
+ ))}
48
+ </>
49
+ )
50
+ })
51
+
52
+ const Box = observer(({ app, box, rows }: { app: HomeSt; box: InsightBox; rows: InsightRow[] }) => {
53
+ const st = app.insights
54
+ const full = st.full.has(box.id)
55
+ const shown = full ? rows : rows.slice(0, BOX_ROWS)
56
+ return (
57
+ <section className="inbox">
58
+ <h3>
59
+ <span>{box.title}</span>
60
+ {box.tags.map((t) => <button key={t} className={`intag${st.tag === t ? ' on' : ''}`} onClick={() => st.setTag(st.tag === t ? null : t)}>{t}</button>)}
61
+ <span className="sp" />
62
+ <span className="acdim">{rows.length === box.rows.length ? rows.length : `${rows.length} of ${box.rows.length}`}</span>
63
+ </h3>
64
+ <p className="acdim">{box.hint}</p>
65
+ {rows.length === 0 ? <p className="acdim">no {box.noun} matches</p> : (
66
+ <table className="intable">
67
+ <colgroup><col /><col className="c-count" /><col /></colgroup>
68
+ <thead><tr><th>{box.noun}</th><th>repos</th><th>values</th></tr></thead>
69
+ <tbody>{shown.map((r) => <Row key={r.key} app={app} box={box} r={r} />)}</tbody>
70
+ </table>
71
+ )}
72
+ {rows.length > BOX_ROWS ? <button className="ghost hsm inmore" onClick={() => st.toggleFull(box.id)}>{full ? `show the first ${BOX_ROWS}` : `show all ${rows.length}`}</button> : null}
73
+ </section>
74
+ )
75
+ })
76
+
77
+ export const InsightsView = observer(({ app }: { app: HomeSt }) => {
78
+ const st = app.insights
79
+ const d = st.data
80
+ return (
81
+ <div className="hall activity insights">
82
+ <div className="actop">
83
+ <div className="acwin" role="radiogroup" aria-label="which repos">
84
+ {(Object.keys(SCOPE_WORDS) as Scope[]).map((s) => (
85
+ <button key={s} role="radio" aria-checked={st.scope === s} className={st.scope === s ? 'on' : ''} title={SCOPE_WORDS[s].title} onClick={() => st.setScope(s)}>{SCOPE_WORDS[s].label}</button>
86
+ ))}
87
+ </div>
88
+ <div className="acwin" role="radiogroup" aria-label="which boxes">
89
+ <button role="radio" aria-checked={st.tag == null} className={st.tag == null ? 'on' : ''} onClick={() => st.setTag(null)}>all</button>
90
+ {st.tags.map((t) => <button key={t} role="radio" aria-checked={st.tag === t} className={st.tag === t ? 'on' : ''} onClick={() => st.setTag(t)}>{t}</button>)}
91
+ </div>
92
+ <input className="inquery" type="search" placeholder="filter: a package, a script…" aria-label="filter every box" value={st.query} onChange={(e) => st.setQuery(e.currentTarget.value)} />
93
+ <button type="button" className="acpill" disabled={st.loading} onClick={() => st.refresh()} title="read every package.json again now · an answer is kept 5 minutes">
94
+ <span className={`acring${st.loading ? ' on' : ''}`}>↻</span>
95
+ </button>
96
+ {d == null ? <span className="acdim">reading every package.json…</span> : (
97
+ <>
98
+ <span className="acpill">{d.repos} repos</span>
99
+ <span className="acpill">{d.files} package.json</span>
100
+ <span className="acpill">read in {d.ms}ms</span>
101
+ </>
102
+ )}
103
+ </div>
104
+ {st.error !== '' ? <div className="hnote">{st.error}</div> : null}
105
+ {d != null && d.errors.length > 0 ? <div className="hnote" title={d.errors.join('\n')}>{d.errors.length} package.json would not parse</div> : null}
106
+ <div className={`ingrid${d != null && d.scope !== st.scope ? ' stale' : ''}`}>
107
+ {st.boxes.map(({ box, rows }) => <Box key={box.id} app={app} box={box} rows={rows} />)}
108
+ </div>
109
+ <p className="hdim">in the console: shipkit insights</p>
110
+ </div>
111
+ )
112
+ })
@@ -0,0 +1,24 @@
1
+ /* the insights page (insights-view.tsx): the activity page's top bar, then a
2
+ grid of boxes. A row opens in place to list its uses. */
3
+ .ingrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,460px),1fr));gap:var(--s3);align-items:start}
4
+ .ingrid.stale{opacity:.5}
5
+ .inbox{border:1px solid var(--line);border-radius:4px;padding:var(--s2) var(--s3);min-width:0}
6
+ .inbox h3{display:flex;align-items:center;gap:var(--s2);margin:0;font-size:13px;font-weight:650}
7
+ .inbox > p{margin:var(--s0) 0 var(--s2);font-size:12px}
8
+ .intag{border:1px solid var(--line);border-radius:999px;padding:0 var(--s2);background:none;color:var(--dim);font-size:11px;font-weight:400;line-height:18px;cursor:pointer}
9
+ .intag:hover,.intag.on{color:var(--accent);border-color:var(--accent)}
10
+ .inquery{height:22px;min-width:200px;box-sizing:border-box;background:var(--bg);color:var(--ink);border:1px solid var(--line);border-radius:4px;padding:0 var(--s2);font:inherit;font-size:12px}
11
+ .inquery:focus{outline:none;border-color:var(--accent)}
12
+ .intable{width:100%;table-layout:fixed;border-collapse:collapse}
13
+ .intable col.c-count{width:56px}
14
+ .intable th{text-align:left;font-weight:400;color:var(--dim);font-size:11.5px;padding:var(--s0) var(--s1);border-bottom:1px solid var(--line)}
15
+ .intable td{padding:0 var(--s1);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;vertical-align:top}
16
+ .inrow{cursor:pointer}
17
+ .inrow:hover td,.inrow.open td{background:var(--panel2)}
18
+ .inkey{font-weight:600}
19
+ .incount{font-variant-numeric:tabular-nums;text-align:right;padding-right:var(--s3) !important}
20
+ .invals code{margin-right:var(--s1);font-size:11.5px;color:var(--dim)}
21
+ .inuse td{font-size:12px}
22
+ .inuse .inplace{padding-left:var(--s3)}
23
+ .inuse .inplace a{color:var(--accent);text-decoration:none}
24
+ .inmore{margin-top:var(--s1)}
@@ -27,7 +27,7 @@ export function pageKey(e: KeyLike): PageKey | null {
27
27
  }
28
28
 
29
29
  /** the shortcut each place of the home carries on its button */
30
- export const PLACE_KEY = { overview: '⌘K', planned: "⌘'", activity: '⌘L' } as const
30
+ export const PLACE_KEY: Record<'overview' | 'planned' | 'activity', string> & Partial<Record<string, string>> = { overview: '⌘K', planned: "⌘'", activity: '⌘L' }
31
31
  /** the picker's shortcut, on the first button, before home */
32
32
  export const PICKER_KEY = '⌘J'
33
33
  /** the repo above and below, hinted at the project list's foot */
@@ -7,6 +7,7 @@
7
7
  // of this (HomeSt.machine is null).
8
8
  import { makeAutoObservable, observableRef, runInAction } from 'mobx'
9
9
  import type { HomeAnswer, WidgetsAnswer } from '../../home/home-routes.ts'
10
+ import type { WidgetView } from '../../home/widgets.ts'
10
11
  import type { WorkerView } from '../../home/hubs.ts'
11
12
  import type { OpenHow, ResourceView } from '../../home/resources.ts'
12
13
  import type { PmAnswer, Role } from '../../home/pm.ts'
@@ -51,6 +52,8 @@ export class MachineSt {
51
52
  pm: PmAnswer | null = null
52
53
  /** every house repo's widgets, as the daemon last rendered them */
53
54
  widgets: WidgetsAnswer | null = null
55
+ /** `repo/id` of each widget a refresh is rendering */
56
+ refreshing = new Set<string>()
54
57
  /** why pm did not answer the last poll, or '' while it does */
55
58
  pmDown = ''
56
59
  /** the last action that failed */
@@ -261,6 +264,26 @@ export class MachineSt {
261
264
  }
262
265
  }
263
266
 
267
+ /** one widget rendered now by the daemon, with force (its cache or
268
+ * backoff skipped), its new view swapped in */
269
+ async refreshWidget(repo: string, id: string): Promise<void> {
270
+ const k = `${repo}/${id}`
271
+ if (this.refreshing.has(k)) return
272
+ this.refreshing.add(k)
273
+ try {
274
+ const r = await reach('/api/home/widgets/refresh', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ repo, id }) })
275
+ if (!r.ok) throw new Error(await r.text())
276
+ const v = (await r.json()) as WidgetView
277
+ runInAction(() => {
278
+ if (this.widgets != null) this.widgets = { ...this.widgets, widgets: this.widgets.widgets.map((w) => (w.repo === repo && w.id === id ? v : w)) }
279
+ })
280
+ } catch (e) {
281
+ runInAction(() => { this.error = ownTrouble(e, this) })
282
+ } finally {
283
+ runInAction(() => { this.refreshing.delete(k) })
284
+ }
285
+ }
286
+
264
287
  hub(name: string, act: 'ensure' | 'restart'): Promise<boolean> {
265
288
  return this.post('/api/home/hub', { name, act })
266
289
  }
@@ -1,5 +1,5 @@
1
1
  // the one picker of the page (cmd+j, keys.ts), a node of the root: the
2
- // home's places (home, activity, planned, in the list's order) first, then the shown repo's own jump
2
+ // home's places (home, activity, planned, insights, archived) first, then the shown repo's own jump
3
3
  // targets, then every other house repo as a place to go. Another repo's
4
4
  // resources are never listed: a jump to a repo selects it in place, and its
5
5
  // own targets are one more cmd+j away.
@@ -27,6 +27,7 @@ export class PaletteSt {
27
27
  { id: 'place:home', label: 'home', section: 'place', face: { place: 'home' }, run: () => st.navigate('/') },
28
28
  { id: 'place:activity', label: 'activity', section: 'place', face: { place: 'activity' }, run: () => st.navigate('/activity') },
29
29
  { id: 'place:planned', label: 'planned', section: 'place', face: { place: 'planned' }, run: () => st.navigate('/planned') },
30
+ { id: 'place:insights', label: 'insights', section: 'place', face: { place: 'insights' }, run: () => st.navigate('/insights') },
30
31
  { id: 'place:archived', label: 'archived', section: 'place', face: { place: 'archived' }, run: () => st.navigate('/archived') },
31
32
  ]
32
33
  }
@@ -37,6 +37,7 @@ const PLACES: { id: Place | 'picker'; label: string; title: string }[] = [
37
37
  { id: 'overview', label: 'overview', title: 'the overview: shipkit, every global process and every repo on this machine' },
38
38
  { id: 'activity', label: 'activity', title: 'what every repo committed lately, what it holds uncommitted, its branch and whether it is pushed' },
39
39
  { id: 'planned', label: 'planned', title: 'every check the machine runs on its own: now, next, late, and why' },
40
+ { id: 'insights', label: 'insights', title: 'boxes about every repo: the packages they use and by how many, version drift, script names' },
40
41
  ]
41
42
 
42
43
  /** the icon's size on a row, in px, as attributes and in the sheet */
@@ -165,10 +166,10 @@ export const ProjectList = observer(({ app }: { app: ListHost }) => {
165
166
  const on = id === 'picker' ? s.pickerOpen() : s.place === id
166
167
  const key = id === 'picker' ? PICKER_KEY : PLACE_KEY[id]
167
168
  return (
168
- <button key={id} className={`pjpl${on ? ' cur' : ''}`} aria-label={pl.label} title={`${pl.title} (${key})`} onClick={run}>
169
+ <button key={id} className={`pjpl${on ? ' cur' : ''}`} aria-label={pl.label} title={key == null ? pl.title : `${pl.title} (${key})`} onClick={run}>
169
170
  <Icon d={ICON[id === 'picker' ? 'search' : id === 'overview' ? 'home' : id]} />
170
171
  <span className="pjname">{pl.label}</span>
171
- <kbd className="pjkey">{key}</kbd>
172
+ {key == null ? null : <kbd className="pjkey">{key}</kbd>}
172
173
  </button>
173
174
  )
174
175
  })}
@@ -178,7 +178,7 @@ export function cardModel(p: Project, now: number): CardModel {
178
178
  }
179
179
 
180
180
  /** a page of the home that is no repo's */
181
- export type Place = 'overview' | 'planned' | 'activity'
181
+ export type Place = 'overview' | 'planned' | 'activity' | 'insights'
182
182
 
183
183
  export class ProjectsSvc {
184
184
  /** the last list this browser saw until the first poll answers, so the
@@ -30,7 +30,7 @@ export type Sel = HashSel | null
30
30
 
31
31
  /** one picker target (cmd+j): run() fires the SAME action clicking would */
32
32
  /** what a picker entry wears: a repo's own icon, or a place's button icon */
33
- export type JumpFace = { repo: string } | { place: 'home' | 'planned' | 'activity' | 'archived' }
33
+ export type JumpFace = { repo: string } | { place: 'home' | 'planned' | 'activity' | 'insights' | 'archived' }
34
34
  /** `key`: the shortcut that does the same, printed at the entry's end */
35
35
  export type Jump = { id: string; label: string; section: string; run: () => void; face?: JumpFace; key?: string }
36
36
 
@@ -61,8 +61,9 @@ export type WidgetDef = {
61
61
  title?: string
62
62
  /** how often the daemon renders it again (default 5000, at least 1000) */
63
63
  everyMs?: number
64
- /** the html it shows, rendered in the daemon. A throw shows as its error */
65
- render: () => string | Promise<string>
64
+ /** the html it shows, rendered in the daemon. A throw shows as its error.
65
+ * `force`: a reader pressed refresh, so skip any cache or backoff */
66
+ render: (o: { force: boolean }) => string | Promise<string>
66
67
  }
67
68
 
68
69
  export type GlobalProcessUnit = GlobalProcessDef & { kind: 'globalProcess' }