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 +10 -2
- package/package.json +1 -1
- package/src/cli/cmd_Insights.ts +78 -0
- package/src/cli.ts +2 -0
- package/src/core/trace-audit.ts +16 -7
- package/src/home/gui-app/ShipkitGui.swift +125 -41
- package/src/home/home-routes.ts +15 -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/shipkit-files.ts +3 -2
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.
|
|
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)
|
package/src/core/trace-audit.ts
CHANGED
|
@@ -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
|
|
51
|
-
* dir once more than three files
|
|
52
|
-
*
|
|
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
|
-
|
|
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
|
|
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,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
|
+
}
|
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')
|
|
@@ -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' }
|
|
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,
|
|
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
|
|
package/src/shipkit-files.ts
CHANGED
|
@@ -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
|
-
|
|
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' }
|