okf-tui 1.0.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.
- checksums.yaml +7 -0
- data/.okf/decisions/index.md +12 -0
- data/.okf/decisions/invents-no-analysis.md +53 -0
- data/.okf/decisions/no-version-ceilings.md +69 -0
- data/.okf/decisions/okf-capability-drift.md +120 -0
- data/.okf/decisions/one-door-the-plugin-seam.md +131 -0
- data/.okf/decisions/registry-write-boundary.md +175 -0
- data/.okf/decisions/ruby-floor.md +59 -0
- data/.okf/decisions/search-facade-coupling.md +146 -0
- data/.okf/decisions/undeclared-width-dependency.md +73 -0
- data/.okf/index.md +28 -0
- data/.okf/interaction/cross-bundle-scope.md +61 -0
- data/.okf/interaction/deferred-search.md +49 -0
- data/.okf/interaction/esc-peels-one-layer.md +70 -0
- data/.okf/interaction/filter-escalates-to-search.md +57 -0
- data/.okf/interaction/following-links.md +82 -0
- data/.okf/interaction/index.md +12 -0
- data/.okf/interaction/key-routing.md +84 -0
- data/.okf/interaction/which-registry.md +85 -0
- data/.okf/log.md +38 -0
- data/.okf/rendering/ansi-aware-width.md +74 -0
- data/.okf/rendering/index.md +8 -0
- data/.okf/rendering/markdown-rendering-trap.md +63 -0
- data/.okf/rendering/status-vocabulary.md +45 -0
- data/.okf/rendering/whole-frame-painting.md +52 -0
- data/.okf/testing/ci-matrix.md +80 -0
- data/.okf/testing/headless-frames.md +74 -0
- data/.okf/testing/index.md +8 -0
- data/.okf/testing/pty-test.md +73 -0
- data/CHANGELOG.md +239 -0
- data/LICENSE.txt +201 -0
- data/NOTICE +10 -0
- data/README.md +194 -0
- data/lib/okf/plugin.rb +63 -0
- data/lib/okf/tui/app.rb +1908 -0
- data/lib/okf/tui/cli.rb +154 -0
- data/lib/okf/tui/model.rb +410 -0
- data/lib/okf/tui/refs.rb +63 -0
- data/lib/okf/tui/ui.rb +308 -0
- data/lib/okf/tui/version.rb +7 -0
- data/lib/okf/tui/views.rb +1648 -0
- data/lib/okf/tui/workspace.rb +527 -0
- data/lib/okf/tui.rb +76 -0
- metadata +229 -0
|
@@ -0,0 +1,1648 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tty-markdown"
|
|
4
|
+
require_relative "ui"
|
|
5
|
+
|
|
6
|
+
module OKF::TUI
|
|
7
|
+
# The screens. Each renders into a fixed rectangle and returns plain rows —
|
|
8
|
+
# no view writes to the terminal itself, so the app can compose and repaint
|
|
9
|
+
# them as one frame.
|
|
10
|
+
module Views
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
TYPE_COLOURS = {
|
|
14
|
+
"Overview" => :magenta,
|
|
15
|
+
"Capability" => :cyan,
|
|
16
|
+
"Component" => :green,
|
|
17
|
+
"Constraint" => :yellow,
|
|
18
|
+
"Format" => :blue
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
def type_colour(type)
|
|
22
|
+
TYPE_COLOURS.fetch(type.to_s, :white)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# A bundle's standing, in the order that matters: conformance is a hard
|
|
26
|
+
# failure (§9), lint is advisory. Asking forces a validate and a lint, which
|
|
27
|
+
# Model memoizes, so a bundle is judged once however many places show it.
|
|
28
|
+
def health_status(model)
|
|
29
|
+
return :unknown if model.nil?
|
|
30
|
+
return :error unless model.validation.valid?
|
|
31
|
+
return :warn unless model.lint.warnings.empty?
|
|
32
|
+
|
|
33
|
+
:ok
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# One vocabulary for that standing, so the name in the header, the badge in
|
|
37
|
+
# the footer, the row in the registry and the health tab cannot disagree.
|
|
38
|
+
STATUS = {
|
|
39
|
+
error: { fg: :red, bg: :on_red, mark: "✗" },
|
|
40
|
+
warn: { fg: :yellow, bg: :on_yellow, mark: "▲" },
|
|
41
|
+
ok: { fg: :green, bg: :on_green, mark: "✓" },
|
|
42
|
+
unknown: { fg: :bright_black, bg: :on_bright_black, mark: "·" }
|
|
43
|
+
}.freeze
|
|
44
|
+
|
|
45
|
+
def status_style(model)
|
|
46
|
+
STATUS.fetch(health_status(model))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# ── header ───────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
def header(app, width)
|
|
52
|
+
model = app.model
|
|
53
|
+
return workspace_header(app, width) if model.nil?
|
|
54
|
+
|
|
55
|
+
health = model.validation.valid?
|
|
56
|
+
warnings = model.lint.warnings.length
|
|
57
|
+
|
|
58
|
+
title = Ui.line(width) do |row|
|
|
59
|
+
row.add(" okf ", :black, :on_cyan, :bold)
|
|
60
|
+
# The active bundle is reversed, not just bold: with several open, which
|
|
61
|
+
# one the other views are about is the single most important thing on
|
|
62
|
+
# screen, and it has to survive a glance after switching.
|
|
63
|
+
row.add(" @#{model.name} ", :black, status_style(model)[:bg], :bold)
|
|
64
|
+
row.add(" #{model.dir} ", :bright_black)
|
|
65
|
+
unless app.workspace.entries.length < 2
|
|
66
|
+
row.add(" · #{app.workspace.entries.length} bundles ", :cyan)
|
|
67
|
+
row.add("· #{app.workspace.scope.length} in scope ", :bright_black)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
stats = Ui.line(width) do |row|
|
|
72
|
+
row.add(" #{model.concept_count} concepts", :white)
|
|
73
|
+
row.add(" #{model.edge_count} links", :white)
|
|
74
|
+
row.add(" #{model.dirs.length} dirs", :white)
|
|
75
|
+
row.space(2)
|
|
76
|
+
if health
|
|
77
|
+
row.add("✓ conformant", :green)
|
|
78
|
+
else
|
|
79
|
+
row.add("✗ #{model.validation.errors.length} errors", :red, :bold)
|
|
80
|
+
end
|
|
81
|
+
row.space(2)
|
|
82
|
+
if warnings.zero?
|
|
83
|
+
row.add("✓ lint clean", :green)
|
|
84
|
+
else
|
|
85
|
+
row.add("▲ #{warnings} lint warnings", :yellow)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Reads are best-effort: a file the reader could not use is skipped
|
|
89
|
+
# rather than aborting the bundle. Say so — a silently shorter list is
|
|
90
|
+
# the one failure mode that reads as success.
|
|
91
|
+
skipped = model.bundle.unparseable.length
|
|
92
|
+
unless skipped.zero?
|
|
93
|
+
row.space(2)
|
|
94
|
+
row.add("⊘ #{skipped} unreadable", :red)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
[ title, stats, tabs(app, width) ]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Shown when no bundle could be read — an empty registry, or one whose
|
|
102
|
+
# directories have all gone. There is nothing to say about a bundle, so the
|
|
103
|
+
# header talks about the workspace instead.
|
|
104
|
+
def workspace_header(app, width)
|
|
105
|
+
title = Ui.line(width) do |row|
|
|
106
|
+
row.add(" okf ", :black, :on_cyan, :bold)
|
|
107
|
+
row.add(" no bundle open ", :bright_white, :bold)
|
|
108
|
+
row.add("· #{app.workspace.registry_path} ", :bright_black) if app.workspace.registry_backed?
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
stats = Ui.line(width) do |row|
|
|
112
|
+
if app.workspace.empty?
|
|
113
|
+
row.add(" the registry is empty — press a to register a bundle", :yellow)
|
|
114
|
+
else
|
|
115
|
+
row.add(" #{app.workspace.entries.length} bundles, none readable", :red)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
[ title, stats, tabs(app, width) ]
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def tabs(app, width)
|
|
123
|
+
Ui.line(width) do |row|
|
|
124
|
+
row.space
|
|
125
|
+
style = status_style(app.model)
|
|
126
|
+
attention = %i[error warn].include?(health_status(app.model))
|
|
127
|
+
|
|
128
|
+
app.class::TABS.each_with_index do |(key, label), index|
|
|
129
|
+
# The health tab wears the verdict. Nothing else on a browse or graph
|
|
130
|
+
# screen says the bundle is broken, and "go look at tab 5" is exactly
|
|
131
|
+
# what a user needs told before they think to ask.
|
|
132
|
+
flagged = key == :health && attention
|
|
133
|
+
text = " #{index + 1} #{label}#{" #{style[:mark]}" if flagged} "
|
|
134
|
+
|
|
135
|
+
if app.view == key
|
|
136
|
+
row.add(text, :black, flagged ? style[:bg] : :on_bright_white, :bold)
|
|
137
|
+
elsif flagged
|
|
138
|
+
row.add(text, style[:fg], :bold)
|
|
139
|
+
else
|
|
140
|
+
row.add(text, :bright_black)
|
|
141
|
+
end
|
|
142
|
+
row.space
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Keys on the left, the active bundle pinned to the right. The header names
|
|
148
|
+
# it too, but the footer is the line the eye is already on while working,
|
|
149
|
+
# and after switching bundles "which one am I in" should never need a look
|
|
150
|
+
# away from it.
|
|
151
|
+
def footer(app, width)
|
|
152
|
+
badge = active_badge(app)
|
|
153
|
+
badge_width = Ui.width(badge)
|
|
154
|
+
|
|
155
|
+
keys = Ui.line([ width - badge_width, 0 ].max) do |row|
|
|
156
|
+
row.space
|
|
157
|
+
app.status_hints.each do |key, meaning|
|
|
158
|
+
row.add(" #{key} ", :black, :on_bright_black)
|
|
159
|
+
row.add(" #{meaning} ", :bright_black)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
keys + badge
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def active_badge(app)
|
|
167
|
+
model = app.model
|
|
168
|
+
return "" if model.nil? || app.workspace.entries.length < 2
|
|
169
|
+
|
|
170
|
+
Ui.line(Ui.width(" bundle @#{model.name} ")) do |row|
|
|
171
|
+
row.add(" bundle ", :bright_black)
|
|
172
|
+
row.add("@#{model.name} ", :black, status_style(model)[:bg], :bold)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# ── bundles: the workspace — switch, scope, configure ───────────────────
|
|
177
|
+
|
|
178
|
+
# Three panes: the registry's bundles and its groups stacked down the left, and
|
|
179
|
+
# the detail of whichever of the two has focus on the right.
|
|
180
|
+
#
|
|
181
|
+
# The groups get a box rather than a heading inside the bundle list, because a
|
|
182
|
+
# heading scrolls away — a registry of thirteen bundles put the groups below the
|
|
183
|
+
# fold on a short terminal, which is no way to show a thing you are meant to
|
|
184
|
+
# select. Boxed, they are always on screen, and Tab is the jump to them.
|
|
185
|
+
def bundles(app, width, height)
|
|
186
|
+
left_width = [ [ (width * 0.44).to_i, 38 ].max, width - 32 ].min
|
|
187
|
+
groups_height = groups_box_height(app, height)
|
|
188
|
+
|
|
189
|
+
left = Ui.box(
|
|
190
|
+
bundle_list(app, left_width - 2, height - groups_height - 2),
|
|
191
|
+
width: left_width, height: height - groups_height,
|
|
192
|
+
title: bundles_title(app), active: app.bundles_pane?
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
# Vertical stacking is concatenation: both boxes are already rectangles of the
|
|
196
|
+
# same width, which is the invariant Ui.box guarantees.
|
|
197
|
+
left += Ui.box(
|
|
198
|
+
group_list(app, left_width - 2, groups_height - 2),
|
|
199
|
+
width: left_width, height: groups_height,
|
|
200
|
+
title: groups_title(app), active: app.groups_pane?
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
right = Ui.box(
|
|
204
|
+
bundle_detail(app, width - left_width - 2, height - 2),
|
|
205
|
+
width: width - left_width, height: height,
|
|
206
|
+
title: detail_title(app), active: app.member_pane?
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
Ui.hjoin(left, right)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Sized to the groups it holds, so a registry with none gives its rows to the
|
|
213
|
+
# bundles instead of framing an empty box — but never more than half the column,
|
|
214
|
+
# since the bundles are the longer list and the one opened by default.
|
|
215
|
+
def groups_box_height(app, height)
|
|
216
|
+
wanted = app.visible_groups.length + 2
|
|
217
|
+
floor = app.workspace.groups.empty? ? 3 : 4
|
|
218
|
+
[ [ wanted, floor ].max, [ height / 2, 3 ].max ].min
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def groups_title(app)
|
|
222
|
+
count = app.workspace.groups.length
|
|
223
|
+
return "groups" if count == app.visible_groups.length
|
|
224
|
+
|
|
225
|
+
"groups #{app.visible_groups.length}/#{count}"
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def detail_title(app)
|
|
229
|
+
return "members of @#{app.selected_group.slug}" if app.member_pane?
|
|
230
|
+
return "group" if app.detailing_group?
|
|
231
|
+
|
|
232
|
+
"bundle"
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def bundles_title(app)
|
|
236
|
+
base = app.workspace.registry_backed? ? "registry" : "bundles (ad-hoc)"
|
|
237
|
+
app.filter.empty? ? base : "#{base} /#{app.filter}"
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def bundle_list(app, width, height)
|
|
241
|
+
return no_bundles(app, width) if app.workspace.entries.empty?
|
|
242
|
+
|
|
243
|
+
entries = app.visible_entries
|
|
244
|
+
if entries.empty?
|
|
245
|
+
rows = [ Ui.line(width) { |r| r.add(" no bundle matches “#{app.filter}”", :yellow) } ]
|
|
246
|
+
# The way out, on the row under the dead end rather than in the footer
|
|
247
|
+
# alone: this pane is wide, and the offer is the whole reason Enter still
|
|
248
|
+
# does something here.
|
|
249
|
+
if app.filter_found_nothing?
|
|
250
|
+
rows << Ui.blank_line(width)
|
|
251
|
+
rows << Ui.line(width) do |r|
|
|
252
|
+
r.add(" ↵ searches every bundle for “#{app.filter}” instead", :bright_black)
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
return rows
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# One pad across both left panes, so the two lists line up: a group is
|
|
259
|
+
# addressed by an @slug exactly as a bundle is, and staggered columns would
|
|
260
|
+
# suggest otherwise.
|
|
261
|
+
pad = registry_pad(app)
|
|
262
|
+
window = app.window(entries.length, height)
|
|
263
|
+
|
|
264
|
+
entries[window, height].to_a.each_with_index.map do |entry, offset|
|
|
265
|
+
bundle_row(app, entry, window + offset == app.cursor && app.bundles_pane?, width, pad)
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def registry_pad(app)
|
|
270
|
+
(app.visible_entries.map { |entry| entry.slug.to_s.length } +
|
|
271
|
+
app.visible_groups.map { |group| group.slug.to_s.length }).max.to_i
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def group_list(app, width, height)
|
|
275
|
+
groups = app.visible_groups
|
|
276
|
+
if groups.empty?
|
|
277
|
+
return [ Ui.line(width) do |r|
|
|
278
|
+
r.add(app.workspace.registry_backed? ? " none — c names the scoped bundles as one" : " none",
|
|
279
|
+
:bright_black)
|
|
280
|
+
end ]
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
pad = registry_pad(app)
|
|
284
|
+
# Its own window: a pane with its own cursor needs its own scroll, or a long
|
|
285
|
+
# group list would be clipped rather than scrolled.
|
|
286
|
+
window = app.group_window(groups.length, height)
|
|
287
|
+
|
|
288
|
+
groups[window, height].to_a.each_with_index.map do |group, offset|
|
|
289
|
+
group_row(app, group, window + offset == app.group_cursor, width, pad)
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# A group row reads as what it is for: a named search scope. It carries no
|
|
294
|
+
# active dot and no default flag — a group is never the bundle the other views
|
|
295
|
+
# are about — and the ◉ is filled only when the scope in force *is* this
|
|
296
|
+
# group's set, which is the one thing a glance wants to know.
|
|
297
|
+
def group_row(app, group, selected, width, pad)
|
|
298
|
+
Ui.line(width) do |row|
|
|
299
|
+
# The cursor shows even when this pane has no focus, dimmed. `+` in the
|
|
300
|
+
# bundles pane acts on *this* row, and a key that reaches across panes needs
|
|
301
|
+
# a row the reader can see it pointing at — the footer used to spell the slug
|
|
302
|
+
# out instead, which claimed a selection nothing on screen agreed with.
|
|
303
|
+
focused = app.groups_pane?
|
|
304
|
+
row.add(selected ? "▸ " : " ", focused ? :cyan : :bright_black, *(focused ? [ :bold ] : []))
|
|
305
|
+
in_force = app.group_in_scope?(group)
|
|
306
|
+
row.add(in_force ? "◉ " : "○ ", in_force ? :cyan : :bright_black)
|
|
307
|
+
row.add(" ", :bright_black) # where a bundle carries its active dot
|
|
308
|
+
row.add("@#{group.slug}".ljust(pad + 2), group.cyclic? ? :red : :blue, :bold)
|
|
309
|
+
|
|
310
|
+
if group.cyclic?
|
|
311
|
+
row.add(" unresolvable — a cycle in the registry", :red)
|
|
312
|
+
else
|
|
313
|
+
row.add(group.size.to_s.rjust(4), :bright_black)
|
|
314
|
+
row.add(group.size == 1 ? " bundle" : " bundles", :bright_black)
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def no_bundles(_app, width)
|
|
320
|
+
[
|
|
321
|
+
Ui.line(width) { |r| r.add(" nothing registered", :yellow) },
|
|
322
|
+
Ui.blank_line(width),
|
|
323
|
+
Ui.line(width) { |r| r.add(" press a to register a bundle directory", :bright_black) }
|
|
324
|
+
]
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def bundle_row(app, entry, selected, width, pad)
|
|
328
|
+
scoped = app.scoped?(entry.slug)
|
|
329
|
+
active = entry.slug == app.workspace.active_slug
|
|
330
|
+
|
|
331
|
+
Ui.line(width) do |row|
|
|
332
|
+
row.add(selected ? "▸ " : " ", :cyan, :bold)
|
|
333
|
+
# Two different states, so two different marks: ◉ is "a search covers
|
|
334
|
+
# this", ● is "this is the bundle the other views are about".
|
|
335
|
+
row.add(scoped ? "◉ " : "○ ", scoped ? :cyan : :bright_black)
|
|
336
|
+
row.add(active ? "● " : " ", :green, :bold)
|
|
337
|
+
# Red for a non-conformant bundle, amber for lint warnings — the point
|
|
338
|
+
# of the list is spotting which one needs attention.
|
|
339
|
+
slug_colour = entry.loaded? ? status_style(entry.model)[:fg] : :red
|
|
340
|
+
row.add("@#{entry.slug}".ljust(pad + 2), slug_colour, :bold)
|
|
341
|
+
|
|
342
|
+
if entry.loaded?
|
|
343
|
+
row.add(entry.concepts.to_s.rjust(4), :bright_black)
|
|
344
|
+
row.add(entry.concepts == 1 ? " concept" : " concepts", :bright_black)
|
|
345
|
+
else
|
|
346
|
+
row.add(" #{entry.error}", :red)
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
row.add(" default", :magenta) if entry.default?
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def bundle_detail(app, width, height)
|
|
354
|
+
group = bundle_detail_subject(app)
|
|
355
|
+
return group_detail(app, group, width) if group
|
|
356
|
+
|
|
357
|
+
entry = app.selected_entry
|
|
358
|
+
return [ Ui.line(width) { |r| r.add(" nothing selected", :bright_black) } ] if entry.nil?
|
|
359
|
+
|
|
360
|
+
bundle_entry_detail(app, entry, width, height)
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def bundle_detail_subject(app)
|
|
364
|
+
app.detailing_group? ? app.selected_group : nil
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# What a group is: the members as the registry file records them, and the
|
|
368
|
+
# bundles they resolve to. Both, because for a nested group they differ — a
|
|
369
|
+
# member can itself be a group — and the resolved list is what a search would
|
|
370
|
+
# actually cover.
|
|
371
|
+
def group_detail(app, group, width)
|
|
372
|
+
rows = []
|
|
373
|
+
rows << Ui.line(width) do |r|
|
|
374
|
+
r.add("@#{group.slug}", group.cyclic? ? :red : :blue, :bold)
|
|
375
|
+
r.add(" group", :bright_black)
|
|
376
|
+
r.add(" in scope", :cyan, :bold) if app.group_in_scope?(group)
|
|
377
|
+
end
|
|
378
|
+
rows << Ui.blank_line(width)
|
|
379
|
+
|
|
380
|
+
if group.cyclic?
|
|
381
|
+
rows << Ui.line(width) { |r| r.add(" ⚠ its members form a cycle, so okf cannot resolve it", :red) }
|
|
382
|
+
rows << Ui.blank_line(width)
|
|
383
|
+
rows << Ui.line(width) { |r| r.add(" edit #{app.workspace.registry.path} to break it", :bright_black) }
|
|
384
|
+
return rows
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
rows << pair(width, "members", group.members.length)
|
|
388
|
+
rows << pair(width, "bundles", group.size)
|
|
389
|
+
rows << Ui.blank_line(width)
|
|
390
|
+
|
|
391
|
+
rows.concat(group_member_rows(app, group, width))
|
|
392
|
+
rows << Ui.blank_line(width)
|
|
393
|
+
rows << Ui.blank_line(width)
|
|
394
|
+
if app.member_pane?
|
|
395
|
+
rows << Ui.line(width) { |r| r.add(" - removes the member under the cursor (asks first)", :bright_black) }
|
|
396
|
+
rows << Ui.line(width) { |r| r.add(" Esc steps back to the groups", :bright_black) }
|
|
397
|
+
else
|
|
398
|
+
rows << Ui.line(width) { |r| r.add(" ↵ scopes the search to it · n renames · x deletes", :bright_black) }
|
|
399
|
+
rows << Ui.line(width) { |r| r.add(" Tab steps into the members, where - removes one", :bright_black) }
|
|
400
|
+
rows << Ui.line(width) do |r|
|
|
401
|
+
r.add(" + in the bundles pane adds the bundle under that cursor", :bright_black)
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
rows
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
# A member is named as the registry names it, with what it turned out to be
|
|
408
|
+
# beside it: a bundle's concept count, or "group" for a nested one. A member
|
|
409
|
+
# naming nothing registered is called out rather than dropped — that is a
|
|
410
|
+
# registry to fix, and a silently shorter list is how it stays unfixed.
|
|
411
|
+
def group_member_rows(app, group, width)
|
|
412
|
+
focused = app.member_pane?
|
|
413
|
+
|
|
414
|
+
group.members.each_with_index.map do |member, index|
|
|
415
|
+
entry = app.workspace.entry(member)
|
|
416
|
+
nested = app.workspace.group(member)
|
|
417
|
+
on = focused && index == app.member_cursor
|
|
418
|
+
|
|
419
|
+
Ui.line(width) do |r|
|
|
420
|
+
r.add(on ? " ▸ " : " ", :cyan, :bold)
|
|
421
|
+
r.add("@#{member}", entry || nested ? :bright_white : :red, *(on ? [ :bold ] : []))
|
|
422
|
+
if entry
|
|
423
|
+
r.add(" #{entry.concepts} #{entry.concepts == 1 ? "concept" : "concepts"}", :bright_black) if entry.loaded?
|
|
424
|
+
r.add(" #{entry.error}", :red) unless entry.loaded?
|
|
425
|
+
elsif nested
|
|
426
|
+
r.add(" group of #{nested.size}", :blue)
|
|
427
|
+
else
|
|
428
|
+
r.add(" not registered", :red)
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
end
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
def bundle_entry_detail(app, entry, width, _height)
|
|
435
|
+
rows = []
|
|
436
|
+
rows << Ui.line(width) do |r|
|
|
437
|
+
r.add("@#{entry.slug}", entry.loaded? ? status_style(entry.model)[:fg] : :red, :bold)
|
|
438
|
+
r.add(" active", :green, :bold) if entry.slug == app.workspace.active_slug
|
|
439
|
+
r.add(" default", :magenta) if entry.default?
|
|
440
|
+
end
|
|
441
|
+
rows << Ui.line(width) { |r| r.add(entry.dir.to_s, :bright_black) }
|
|
442
|
+
rows.concat(membership_rows(app, entry, width))
|
|
443
|
+
rows << Ui.blank_line(width)
|
|
444
|
+
|
|
445
|
+
unless entry.loaded?
|
|
446
|
+
rows << Ui.line(width) { |r| r.add(" ⚠ #{entry.error}", :red) }
|
|
447
|
+
rows << Ui.blank_line(width)
|
|
448
|
+
rows << Ui.line(width) { |r| r.add(" x removes it from the registry", :bright_black) }
|
|
449
|
+
return rows
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
model = entry.model
|
|
453
|
+
rows << pair(width, "concepts", model.concept_count)
|
|
454
|
+
rows << pair(width, "links", model.edge_count)
|
|
455
|
+
rows << pair(width, "dirs", model.dirs.length)
|
|
456
|
+
rows << pair(width, "orphans", model.orphan_ids.length, model.orphan_ids.empty? ? :green : :yellow)
|
|
457
|
+
rows << Ui.blank_line(width)
|
|
458
|
+
|
|
459
|
+
rows << Ui.line(width) do |r|
|
|
460
|
+
r.add(" conformance ", :bright_black)
|
|
461
|
+
if model.validation.valid?
|
|
462
|
+
version = model.okf_version
|
|
463
|
+
r.add(version.to_s.empty? ? "✓ conformant" : "✓ legal OKF v#{version}", :green)
|
|
464
|
+
else
|
|
465
|
+
r.add("✗ #{model.validation.errors.length} errors", :red, :bold)
|
|
466
|
+
end
|
|
467
|
+
end
|
|
468
|
+
rows << Ui.line(width) do |r|
|
|
469
|
+
r.add(" curation ", :bright_black)
|
|
470
|
+
warnings = model.lint.warnings.length
|
|
471
|
+
if warnings.zero?
|
|
472
|
+
r.add("✓ lint clean", :green)
|
|
473
|
+
else
|
|
474
|
+
r.add("▲ #{warnings} warnings", :yellow)
|
|
475
|
+
end
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
skipped = model.bundle.unparseable.length
|
|
479
|
+
unless skipped.zero?
|
|
480
|
+
rows << Ui.line(width) do |r|
|
|
481
|
+
r.add(" files ", :bright_black)
|
|
482
|
+
r.add("⊘ #{skipped} unreadable", :red)
|
|
483
|
+
end
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
rows << Ui.blank_line(width)
|
|
487
|
+
rows << Ui.line(width) { |r| r.add(" ↵ open it · space toggles search scope", :bright_black) }
|
|
488
|
+
|
|
489
|
+
# Only the offer, and only when there is one to make: a bundle already in the
|
|
490
|
+
# group says so on its own row and in the `in @…` line above.
|
|
491
|
+
group = app.selected_group
|
|
492
|
+
if group && !app.member_group(entry.slug)
|
|
493
|
+
rows << Ui.line(width) { |r| r.add(" + puts it in @#{group.slug}", :bright_black) }
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
rows
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
# Every group that names this bundle, not only the one selected below: the row
|
|
500
|
+
# can show one membership, and a bundle can have several. This is the line that
|
|
501
|
+
# answers "did that + land" when the group being edited is scrolled out of the
|
|
502
|
+
# groups pane.
|
|
503
|
+
def membership_rows(app, entry, width)
|
|
504
|
+
groups = app.workspace.groups.select { |group| group.members.include?(entry.slug) }
|
|
505
|
+
return [] if groups.empty?
|
|
506
|
+
|
|
507
|
+
[ Ui.line(width) do |r|
|
|
508
|
+
r.add("in ", :bright_black)
|
|
509
|
+
groups.each { |group| r.add("@#{group.slug} ", :blue) }
|
|
510
|
+
end ]
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
def pair(width, label, value, colour = :bright_white)
|
|
514
|
+
Ui.line(width) do |r|
|
|
515
|
+
r.add(" #{label.ljust(12)} ", :bright_black)
|
|
516
|
+
r.add(value.to_s, colour, :bold)
|
|
517
|
+
end
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
# ── browse: concept list + detail ────────────────────────────────────────
|
|
521
|
+
|
|
522
|
+
def browse(app, width, height)
|
|
523
|
+
left_width = [ [ (width * 0.38).to_i, 34 ].max, width - 30 ].min
|
|
524
|
+
right_width = width - left_width
|
|
525
|
+
|
|
526
|
+
left = Ui.box(
|
|
527
|
+
concept_list(app, left_width - 2, height - 2),
|
|
528
|
+
width: left_width, height: height,
|
|
529
|
+
title: browse_list_title(app), active: app.pane == :list
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
right = Ui.box(
|
|
533
|
+
concept_detail(app, right_width - 2, height - 2),
|
|
534
|
+
width: right_width, height: height,
|
|
535
|
+
title: "detail", active: app.pane == :detail
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
Ui.hjoin(left, right)
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
def browse_list_title(app)
|
|
542
|
+
app.filter.empty? ? "concepts" : "concepts /#{app.filter}"
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
# The list, grouped under directory headings, windowed around the cursor.
|
|
546
|
+
def concept_list(app, width, height)
|
|
547
|
+
entries = app.list_entries
|
|
548
|
+
return empty_list(app, width) if entries.empty?
|
|
549
|
+
|
|
550
|
+
window = app.window(entries.length, height)
|
|
551
|
+
|
|
552
|
+
entries[window, height].to_a.each_with_index.map do |entry, offset|
|
|
553
|
+
index = window + offset
|
|
554
|
+
if entry[:kind] == :dir
|
|
555
|
+
Ui.line(width) do |row|
|
|
556
|
+
row.space
|
|
557
|
+
row.add(entry[:label].upcase, :bright_black, :bold)
|
|
558
|
+
row.add(" (#{entry[:count]})", :bright_black)
|
|
559
|
+
end
|
|
560
|
+
elsif entry[:kind] == :reserved
|
|
561
|
+
reserved_row(entry, index == app.cursor, width)
|
|
562
|
+
else
|
|
563
|
+
concept_row(app, entry[:row], index == app.cursor, width)
|
|
564
|
+
end
|
|
565
|
+
end
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
# An empty list is a dead end unless it says where to go next. A filter that
|
|
569
|
+
# found nothing is usually not "it does not exist" but "it is not in the
|
|
570
|
+
# part of this bundle a filter can see", so the way out is offered here.
|
|
571
|
+
def empty_list(app, width)
|
|
572
|
+
return [ Ui.line(width) { |r| r.add(" no concepts here", :bright_black) } ] unless app.filter_found_nothing?
|
|
573
|
+
|
|
574
|
+
# Short here — the pane is narrow. The offer itself goes in the detail
|
|
575
|
+
# pane, which is wide and otherwise saying "nothing selected".
|
|
576
|
+
[ Ui.line(width) { |r| r.add(" no matches in this bundle", :yellow) } ]
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
# index.md and log.md are structure, not concepts, so they read differently
|
|
580
|
+
# in the list: a file name rather than a title, and no type dot.
|
|
581
|
+
def reserved_row(entry, selected, width)
|
|
582
|
+
name = File.basename(entry[:path])
|
|
583
|
+
|
|
584
|
+
Ui.line(width) do |row|
|
|
585
|
+
row.add(selected ? "▸ " : " ", :cyan, :bold)
|
|
586
|
+
row.add("▪ ", :bright_black)
|
|
587
|
+
row.add(name, selected ? :bright_white : :bright_black, :bold)
|
|
588
|
+
row.add(name == "index.md" ? " the way in" : " the log", :bright_black)
|
|
589
|
+
end
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
def concept_row(app, item, selected, width)
|
|
593
|
+
Ui.line(width) do |row|
|
|
594
|
+
row.add(selected ? "▸ " : " ", :cyan, :bold)
|
|
595
|
+
row.add("● ", type_colour(item[:type]))
|
|
596
|
+
|
|
597
|
+
label = item[:title].to_s.empty? ? item[:id] : item[:title]
|
|
598
|
+
if selected
|
|
599
|
+
row.add(label, :bright_white, :bold)
|
|
600
|
+
else
|
|
601
|
+
row.add(label, :white)
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
findings = app.model.findings_for(item)
|
|
605
|
+
row.add(" ▲#{findings.length}", :yellow) unless findings.empty?
|
|
606
|
+
end
|
|
607
|
+
end
|
|
608
|
+
|
|
609
|
+
# The right-hand pane: frontmatter, then the body rendered as markdown.
|
|
610
|
+
def concept_detail(app, width, height)
|
|
611
|
+
return escalation_panel(app, width) if app.filter_found_nothing?
|
|
612
|
+
|
|
613
|
+
entry = app.selected_browse_entry
|
|
614
|
+
return reserved_detail(app, entry, width, height) if entry && entry[:kind] == :reserved
|
|
615
|
+
|
|
616
|
+
item = app.selected_row
|
|
617
|
+
return [ Ui.line(width) { |r| r.add(" nothing selected", :bright_black) } ] if item.nil?
|
|
618
|
+
|
|
619
|
+
rows = []
|
|
620
|
+
rows << Ui.line(width) { |r| r.add(item[:title].to_s, :bright_white, :bold) }
|
|
621
|
+
rows << Ui.line(width) do |r|
|
|
622
|
+
r.add(item[:type].to_s, type_colour(item[:type]), :bold)
|
|
623
|
+
r.add(" #{item[:path]}", :bright_black)
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
unless item[:description].to_s.empty?
|
|
627
|
+
rows << Ui.blank_line(width)
|
|
628
|
+
wrap(item[:description], width - 2).each do |text|
|
|
629
|
+
rows << Ui.line(width) { |r| r.add(" #{text}", :white) }
|
|
630
|
+
end
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
rows << Ui.blank_line(width)
|
|
634
|
+
rows << Ui.line(width) do |r|
|
|
635
|
+
r.add(" links ", :bright_black)
|
|
636
|
+
r.add("→#{item[:links_out]} ", :green)
|
|
637
|
+
r.add("←#{item[:links_in]}", :blue)
|
|
638
|
+
unless Array(item[:tags]).empty?
|
|
639
|
+
r.add(" tags ", :bright_black)
|
|
640
|
+
r.add(Array(item[:tags]).join(" · "), :cyan)
|
|
641
|
+
end
|
|
642
|
+
end
|
|
643
|
+
|
|
644
|
+
rows.concat(provenance_rows(item, width))
|
|
645
|
+
|
|
646
|
+
findings = app.model.findings_for(item)
|
|
647
|
+
unless findings.empty?
|
|
648
|
+
rows << Ui.blank_line(width)
|
|
649
|
+
findings.first(4).each do |finding|
|
|
650
|
+
rows << Ui.line(width) do |r|
|
|
651
|
+
r.add(" ▲ ", :yellow)
|
|
652
|
+
r.add("#{finding[:check]} ", :yellow, :bold)
|
|
653
|
+
r.add(finding[:message].to_s, :white)
|
|
654
|
+
end
|
|
655
|
+
end
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
rows << Ui.blank_line(width)
|
|
659
|
+
rows << Ui.line(width) { |r| r.add("─" * width, :bright_black) }
|
|
660
|
+
|
|
661
|
+
rows.concat(document_block(app, width, height - rows.length) { app.rendered_body(item, width - 2) })
|
|
662
|
+
rows
|
|
663
|
+
end
|
|
664
|
+
|
|
665
|
+
# §5's provenance families, on the rows that declared them.
|
|
666
|
+
#
|
|
667
|
+
# Everything here is conditional on the concept having *said* something, which
|
|
668
|
+
# is the v0.1 half of okf's own rule: v0.2 only added optional keys, so a
|
|
669
|
+
# bundle that adopted none of them must not read as deficient — it reads as it
|
|
670
|
+
# always did, with no rows at all. §13.1 does the rest: a v0.1 `timestamp:`
|
|
671
|
+
# arrives as `generated_at` with no actor invented for it, so the "updated"
|
|
672
|
+
# line an unmigrated bundle has always shown still shows.
|
|
673
|
+
#
|
|
674
|
+
# The trust tier is the one that needs a rule rather than a presence check,
|
|
675
|
+
# because §5.3 *derives* `unverified` for every concept that verified nothing.
|
|
676
|
+
# Printing that would paint a provenance verdict onto a document that never
|
|
677
|
+
# made one. `Model.shows_trust?` is okf's predicate, shared with its server and
|
|
678
|
+
# its graph page, and the facet in the graph view gates on the same call.
|
|
679
|
+
def provenance_rows(item, width)
|
|
680
|
+
rows = []
|
|
681
|
+
|
|
682
|
+
unless item[:generated_at].to_s.empty?
|
|
683
|
+
rows << Ui.line(width) do |r|
|
|
684
|
+
r.add(" updated ", :bright_black)
|
|
685
|
+
r.add(item[:generated_at].to_s, :white)
|
|
686
|
+
# No actor means §13.1 lifted this from a v0.1 `timestamp`, which
|
|
687
|
+
# recorded none. Naming one would be the false provenance §5 prevents.
|
|
688
|
+
unless item[:generated_by].to_s.empty?
|
|
689
|
+
r.add(" by ", :bright_black)
|
|
690
|
+
r.add(item[:generated_by].to_s, :white)
|
|
691
|
+
end
|
|
692
|
+
end
|
|
693
|
+
end
|
|
694
|
+
|
|
695
|
+
marks = []
|
|
696
|
+
marks << [ item[:trust].to_s, trust_colour(item[:trust]) ] if Model.shows_trust?(item)
|
|
697
|
+
# The producer's own spelling, and only when it is not the §5.4 default:
|
|
698
|
+
# `stable` is what an undeclared status already means, so a row saying it
|
|
699
|
+
# carries no information the absence did not.
|
|
700
|
+
unless item[:status].to_s.empty? || OKF::Concept.effective_status(item[:status]) == "stable"
|
|
701
|
+
marks << [ item[:status].to_s, :yellow ]
|
|
702
|
+
end
|
|
703
|
+
marks << [ "expires #{item[:stale_after]}", :bright_black ] unless item[:stale_after].to_s.empty?
|
|
704
|
+
marks << [ "#{item[:sources]} sources", :bright_black ] if item[:sources].to_i.positive?
|
|
705
|
+
|
|
706
|
+
unless marks.empty?
|
|
707
|
+
rows << Ui.line(width) do |r|
|
|
708
|
+
r.add(" ", :bright_black)
|
|
709
|
+
marks.each_with_index do |(text, colour), index|
|
|
710
|
+
r.add(" ", :bright_black) if index.positive?
|
|
711
|
+
r.add(text, colour)
|
|
712
|
+
end
|
|
713
|
+
end
|
|
714
|
+
end
|
|
715
|
+
|
|
716
|
+
rows
|
|
717
|
+
end
|
|
718
|
+
|
|
719
|
+
# §5.3's three tiers, warm for the one a human signed off. Unverified stays
|
|
720
|
+
# neutral: where it is shown at all the concept declared §5 and simply has no
|
|
721
|
+
# verification yet, which is a state rather than a fault.
|
|
722
|
+
def trust_colour(trust)
|
|
723
|
+
case trust.to_s
|
|
724
|
+
when "human-reviewed" then :green
|
|
725
|
+
when "machine-confirmed" then :cyan
|
|
726
|
+
else :bright_black
|
|
727
|
+
end
|
|
728
|
+
end
|
|
729
|
+
|
|
730
|
+
# A filter that found nothing is usually not "it does not exist" but "it is
|
|
731
|
+
# not in the part of this bundle a filter can see" — the filter reads
|
|
732
|
+
# metadata only, and only this bundle. So the dead end offers the way on.
|
|
733
|
+
def escalation_panel(app, width)
|
|
734
|
+
rows = []
|
|
735
|
+
rows << Ui.line(width) { |r| r.add("nothing matched “#{app.filter}” here", :yellow, :bold) }
|
|
736
|
+
rows << Ui.blank_line(width)
|
|
737
|
+
|
|
738
|
+
wrap("The filter reads titles, ids, types and tags, in this bundle only.", width - 4).each do |text|
|
|
739
|
+
rows << Ui.line(width) { |r| r.add(" #{text}", :bright_black) }
|
|
740
|
+
end
|
|
741
|
+
|
|
742
|
+
rows << Ui.blank_line(width)
|
|
743
|
+
rows << Ui.line(width) do |r|
|
|
744
|
+
r.add(" ↵ ", :black, :on_cyan, :bold)
|
|
745
|
+
r.add(" #{app.escalation_offer}", :cyan, :bold)
|
|
746
|
+
end
|
|
747
|
+
rows << Ui.blank_line(width)
|
|
748
|
+
|
|
749
|
+
wrap("That reads every concept body too, ranked, and merges the bundles into one result list.", width - 4).each do |text|
|
|
750
|
+
rows << Ui.line(width) { |r| r.add(" #{text}", :bright_black) }
|
|
751
|
+
end
|
|
752
|
+
|
|
753
|
+
rows
|
|
754
|
+
end
|
|
755
|
+
|
|
756
|
+
# A reserved file has no frontmatter to summarise — it is all body.
|
|
757
|
+
def reserved_detail(app, entry, width, height)
|
|
758
|
+
rows = []
|
|
759
|
+
rows << Ui.line(width) { |r| r.add(File.basename(entry[:path]), :bright_white, :bold) }
|
|
760
|
+
rows << Ui.line(width) do |r|
|
|
761
|
+
r.add(entry[:path], :bright_black)
|
|
762
|
+
r.add(File.basename(entry[:path]) == "index.md" ? " · progressive disclosure (§6)" : " · the bundle log", :bright_black)
|
|
763
|
+
end
|
|
764
|
+
rows << Ui.blank_line(width)
|
|
765
|
+
rows << Ui.line(width) { |r| r.add("─" * width, :bright_black) }
|
|
766
|
+
|
|
767
|
+
rows.concat(document_block(app, width, height - rows.length) { app.rendered_reserved(entry[:path], width - 2) })
|
|
768
|
+
rows
|
|
769
|
+
end
|
|
770
|
+
|
|
771
|
+
# The detail pane's document area — the body, or the link picker in its
|
|
772
|
+
# place. Replacing rather than overlaying keeps the header above it, and
|
|
773
|
+
# means Esc puts the body back exactly where it was: nothing here touches
|
|
774
|
+
# the detail scroll. The body is yielded rather than passed so the picker
|
|
775
|
+
# does not pay tty-markdown to render a page it is covering.
|
|
776
|
+
def document_block(app, width, visible)
|
|
777
|
+
return links_block(app, width, visible) if app.following?
|
|
778
|
+
|
|
779
|
+
body_block(app, yield, width, visible)
|
|
780
|
+
end
|
|
781
|
+
|
|
782
|
+
# Where this document points. The list comes from okf — the same extraction
|
|
783
|
+
# the graph builds edges with — so these rows are a value a check can assert
|
|
784
|
+
# on, rather than a pattern matched against rendered markdown.
|
|
785
|
+
def links_block(app, width, visible)
|
|
786
|
+
links = app.follow_links
|
|
787
|
+
|
|
788
|
+
rows = []
|
|
789
|
+
rows << Ui.line(width) do |r|
|
|
790
|
+
r.add(" links in this document ", :bright_white, :bold)
|
|
791
|
+
r.add("(#{links.length})", :bright_black)
|
|
792
|
+
end
|
|
793
|
+
rows << Ui.blank_line(width)
|
|
794
|
+
|
|
795
|
+
room = [ visible - rows.length, 1 ].max
|
|
796
|
+
body = links.each_with_index.map { |link, index| link_row(app, link, index, width) }
|
|
797
|
+
offset = app.follow_window(body.length, room)
|
|
798
|
+
rows.concat(body[offset, room].to_a)
|
|
799
|
+
rows
|
|
800
|
+
end
|
|
801
|
+
|
|
802
|
+
# A link reads as whatever is at the far end of it, not as the path it was
|
|
803
|
+
# written with: a concept by its title and type colour, a nested index by its
|
|
804
|
+
# area, and a target with nothing behind it as exactly that — not-yet-written
|
|
805
|
+
# knowledge is what a broken cross-link usually is.
|
|
806
|
+
def link_row(app, link, index, width)
|
|
807
|
+
selected = index == app.follow_cursor
|
|
808
|
+
|
|
809
|
+
Ui.line(width) do |r|
|
|
810
|
+
r.add(selected ? "▸ " : " ", :cyan, :bold)
|
|
811
|
+
r.add(index < 9 ? "#{index + 1} " : " ", :bright_black)
|
|
812
|
+
|
|
813
|
+
case link[:kind]
|
|
814
|
+
when :concept
|
|
815
|
+
r.add("● ", type_colour(link[:type]))
|
|
816
|
+
r.add(link[:label], selected ? :bright_white : :white, :bold)
|
|
817
|
+
r.add(" #{link[:target]}", :bright_black)
|
|
818
|
+
when :reserved
|
|
819
|
+
r.add("▪ ", :bright_black)
|
|
820
|
+
r.add(link[:label], selected ? :bright_white : :bright_black, :bold)
|
|
821
|
+
r.add(" #{link[:target]}", :bright_black)
|
|
822
|
+
else
|
|
823
|
+
r.add("○ ", :yellow)
|
|
824
|
+
r.add(link[:target], :yellow)
|
|
825
|
+
r.add(" not written yet", :bright_black)
|
|
826
|
+
end
|
|
827
|
+
end
|
|
828
|
+
end
|
|
829
|
+
|
|
830
|
+
# The scrolling window over a rendered document, with the find matches
|
|
831
|
+
# marked. The body is already coloured by tty-markdown, so these rows bypass
|
|
832
|
+
# Line and are squared off by fit_block on the way into the box.
|
|
833
|
+
def body_block(app, body, _width, visible)
|
|
834
|
+
matches = app.find_matches(body)
|
|
835
|
+
offset = app.detail_offset(body.length, visible, matches)
|
|
836
|
+
|
|
837
|
+
body[offset, visible].to_a.each_with_index.map do |text, index|
|
|
838
|
+
line = offset + index
|
|
839
|
+
next " #{text}" unless matches.include?(line)
|
|
840
|
+
|
|
841
|
+
# The mark goes in the gutter the other rows leave empty, so a hit is
|
|
842
|
+
# visible without touching the rendered markdown itself.
|
|
843
|
+
current = matches[app.find_index % [ matches.length, 1 ].max] == line
|
|
844
|
+
Ui.pastel.decorate(current ? "▶ " : "· ", current ? :black : :yellow, *(current ? %i[on_yellow bold] : [])) + text
|
|
845
|
+
end
|
|
846
|
+
end
|
|
847
|
+
|
|
848
|
+
def wrap(text, limit)
|
|
849
|
+
words = text.to_s.split(/\s+/)
|
|
850
|
+
lines = []
|
|
851
|
+
current = +""
|
|
852
|
+
words.each do |word|
|
|
853
|
+
candidate = current.empty? ? word : "#{current} #{word}"
|
|
854
|
+
if Ui.width(candidate) > limit
|
|
855
|
+
lines << current unless current.empty?
|
|
856
|
+
current = word.dup
|
|
857
|
+
else
|
|
858
|
+
current = candidate
|
|
859
|
+
end
|
|
860
|
+
end
|
|
861
|
+
lines << current unless current.empty?
|
|
862
|
+
lines
|
|
863
|
+
end
|
|
864
|
+
|
|
865
|
+
# ── search ───────────────────────────────────────────────────────────────
|
|
866
|
+
|
|
867
|
+
def search(app, outer_width, height)
|
|
868
|
+
width = outer_width - 2
|
|
869
|
+
rows = []
|
|
870
|
+
editing = app.editing_query?
|
|
871
|
+
|
|
872
|
+
rows << Ui.line(width) do |r|
|
|
873
|
+
r.add(" search ", :bright_black)
|
|
874
|
+
r.add(app.query.empty? && !editing ? "(no query)" : app.query, :bright_white, :bold)
|
|
875
|
+
# The caret is the focus indicator: it is only in the field while the
|
|
876
|
+
# field is where the typing goes.
|
|
877
|
+
r.add(editing ? "▏" : " ", :cyan, :bold)
|
|
878
|
+
|
|
879
|
+
# Until Enter, whatever is listed below answers the *previous* query.
|
|
880
|
+
# Saying so is the difference between "not searched yet" and a wrong
|
|
881
|
+
# answer sitting under a query it does not belong to.
|
|
882
|
+
if app.search_pending?
|
|
883
|
+
r.add(" ↵ to search", :black, :on_yellow, :bold)
|
|
884
|
+
elsif !editing
|
|
885
|
+
r.add(app.query.empty? ? " / to search" : " / to edit", :bright_black)
|
|
886
|
+
end
|
|
887
|
+
end
|
|
888
|
+
rows << Ui.line(width) do |r|
|
|
889
|
+
r.add(" mode ", :bright_black)
|
|
890
|
+
r.add(app.search_mode_label, :cyan, :bold)
|
|
891
|
+
r.add(" (e) ", :bright_black)
|
|
892
|
+
r.add(" #{app.search_mode_note}", :bright_black)
|
|
893
|
+
end
|
|
894
|
+
rows << Ui.line(width) do |r|
|
|
895
|
+
error = app.workspace.search_error
|
|
896
|
+
next r.add(" #{error}", :yellow) if error
|
|
897
|
+
|
|
898
|
+
r.add(" one corpus over every scoped bundle, so the scores compare", :bright_black)
|
|
899
|
+
end
|
|
900
|
+
rows << Ui.blank_line(width)
|
|
901
|
+
|
|
902
|
+
scope = app.workspace.scope
|
|
903
|
+
hits = app.search_hits
|
|
904
|
+
|
|
905
|
+
if scope.empty?
|
|
906
|
+
rows << Ui.line(width) { |r| r.add(" no bundles in scope — press 1, then space to pick some", :yellow) }
|
|
907
|
+
elsif app.searched.empty?
|
|
908
|
+
# Nothing has been searched yet, so there are no results to describe —
|
|
909
|
+
# and saying "no matches" here would blame the query for an answer that
|
|
910
|
+
# was never asked for.
|
|
911
|
+
rows << Ui.line(width) do |r|
|
|
912
|
+
if app.query.empty?
|
|
913
|
+
r.add(" press / to type a query — titles, ids, tags, types, descriptions, bodies", :bright_black)
|
|
914
|
+
else
|
|
915
|
+
r.add(" press ↵ to search for “#{app.query}”", :yellow)
|
|
916
|
+
end
|
|
917
|
+
end
|
|
918
|
+
elsif hits.empty?
|
|
919
|
+
rows << Ui.line(width) { |r| r.add(" no matches for “#{app.searched}”", :yellow) }
|
|
920
|
+
else
|
|
921
|
+
rows << Ui.line(width) do |r|
|
|
922
|
+
r.add(" #{count(hits.length, "match", "matches")} across #{count(scope.length, "bundle")}", :bright_black)
|
|
923
|
+
# While the field is being edited the list still answers the old query.
|
|
924
|
+
r.add(" for “#{app.searched}”", :yellow) if app.search_pending?
|
|
925
|
+
end
|
|
926
|
+
rows << Ui.blank_line(width)
|
|
927
|
+
|
|
928
|
+
visible = height - rows.length - 2
|
|
929
|
+
window = app.window(hits.length, visible / 3)
|
|
930
|
+
hits[window, [ visible / 3, 1 ].max].to_a.each_with_index do |hit, offset|
|
|
931
|
+
rows.concat(search_hit(app, hit, window + offset == app.cursor, width))
|
|
932
|
+
end
|
|
933
|
+
end
|
|
934
|
+
|
|
935
|
+
Ui.box(rows, width: outer_width, height: height, title: search_title(app), active: true)
|
|
936
|
+
end
|
|
937
|
+
|
|
938
|
+
# The scope, named the way the equivalent CLI invocation would name it.
|
|
939
|
+
def search_title(app)
|
|
940
|
+
scope = app.workspace.scope
|
|
941
|
+
return "search — nothing in scope" if scope.empty?
|
|
942
|
+
return "search" if app.workspace.entries.length < 2
|
|
943
|
+
|
|
944
|
+
label = scope.length == app.workspace.entries.length ? "@all" : scope.map { |slug| "@#{slug}" }.join(" ")
|
|
945
|
+
"search #{label}"
|
|
946
|
+
end
|
|
947
|
+
|
|
948
|
+
def count(number, singular, plural = nil)
|
|
949
|
+
"#{number} #{number == 1 ? singular : plural || "#{singular}s"}"
|
|
950
|
+
end
|
|
951
|
+
|
|
952
|
+
def search_hit(app, hit, selected, inner)
|
|
953
|
+
head = Ui.line(inner) do |r|
|
|
954
|
+
r.add(selected ? "▸ " : " ", :cyan, :bold)
|
|
955
|
+
# The slug only earns its space when a search can span bundles.
|
|
956
|
+
r.add("@#{hit[:slug]} ", :cyan) if hit[:slug] && app.workspace.entries.length > 1
|
|
957
|
+
r.add(hit[:title].to_s, selected ? :bright_white : :white, :bold)
|
|
958
|
+
r.add(" #{hit[:type]}", type_colour(hit[:type]))
|
|
959
|
+
r.add(" #{format("%.2f", hit[:score])}", :bright_black)
|
|
960
|
+
end
|
|
961
|
+
|
|
962
|
+
meta = Ui.line(inner) do |r|
|
|
963
|
+
r.add(" #{hit[:id]}", :bright_black)
|
|
964
|
+
r.add(" matched: ", :bright_black)
|
|
965
|
+
r.add(Array(hit[:matched]).join(", "), :magenta)
|
|
966
|
+
end
|
|
967
|
+
|
|
968
|
+
snippet = Ui.line(inner) do |r|
|
|
969
|
+
r.add(" #{hit[:snippet]}", :bright_black)
|
|
970
|
+
end
|
|
971
|
+
|
|
972
|
+
[ head, meta, snippet ]
|
|
973
|
+
end
|
|
974
|
+
|
|
975
|
+
# ── health: validate + lint ──────────────────────────────────────────────
|
|
976
|
+
|
|
977
|
+
# Two panes: what is wrong on the left, how it stands on the right.
|
|
978
|
+
#
|
|
979
|
+
# One page mixed them, and the findings are the unbounded half — a bundle with
|
|
980
|
+
# two hundred lint findings pushed the dir traffic and the stats off the bottom,
|
|
981
|
+
# which is exactly the bundle whose structure you opened this view to look at.
|
|
982
|
+
# The summary is bounded by construction, so given a pane of its own it can
|
|
983
|
+
# never be pushed away, and the two scroll apart.
|
|
984
|
+
#
|
|
985
|
+
# The right pane carries verdicts and numbers, never a path: every row in it is
|
|
986
|
+
# short by design, which is what lets it keep a fixed width. The findings that
|
|
987
|
+
# go with those verdicts are on the left, where the columns are.
|
|
988
|
+
HEALTH_SUMMARY_WIDTH = 56
|
|
989
|
+
|
|
990
|
+
# Below this the two panes would each be too narrow for the paths a finding is
|
|
991
|
+
# *about*, so the same Tab shows one at a time instead. Splitting anyway and
|
|
992
|
+
# letting both clip would be the layout lying about fitting.
|
|
993
|
+
HEALTH_SPLIT_WIDTH = 112
|
|
994
|
+
|
|
995
|
+
def health(app, outer_width, height)
|
|
996
|
+
return health_page(app, outer_width, height) if outer_width < HEALTH_SPLIT_WIDTH
|
|
997
|
+
|
|
998
|
+
right_width = HEALTH_SUMMARY_WIDTH
|
|
999
|
+
left_width = outer_width - right_width
|
|
1000
|
+
findings = app.pane != :detail
|
|
1001
|
+
|
|
1002
|
+
left = scrollable(app, health_findings(app.model, left_width - 2), left_width, height,
|
|
1003
|
+
"findings", scroll: :content, focused: findings)
|
|
1004
|
+
right = scrollable(app, health_summary(app.model, right_width - 2), right_width, height,
|
|
1005
|
+
"standing", scroll: :health, focused: !findings)
|
|
1006
|
+
|
|
1007
|
+
Ui.hjoin(left, right)
|
|
1008
|
+
end
|
|
1009
|
+
|
|
1010
|
+
# Narrow: one pane at a time, chosen by the same Tab that puts them side by side
|
|
1011
|
+
# when there is room. The title says which, since only one is on screen.
|
|
1012
|
+
def health_page(app, outer_width, height)
|
|
1013
|
+
if app.pane == :detail
|
|
1014
|
+
scrollable(app, health_summary(app.model, outer_width - 2), outer_width, height,
|
|
1015
|
+
"health · standing", scroll: :health)
|
|
1016
|
+
else
|
|
1017
|
+
scrollable(app, health_findings(app.model, outer_width - 2), outer_width, height,
|
|
1018
|
+
"health · findings")
|
|
1019
|
+
end
|
|
1020
|
+
end
|
|
1021
|
+
|
|
1022
|
+
# The left pane: every row that names a file. Conformance errors lead, because
|
|
1023
|
+
# a bundle that is not legal has a different first job than one that is merely
|
|
1024
|
+
# untidy.
|
|
1025
|
+
def health_findings(model, width)
|
|
1026
|
+
rows = []
|
|
1027
|
+
|
|
1028
|
+
unless model.validation.valid?
|
|
1029
|
+
rows << section(width, "conformance — spec §9", :red)
|
|
1030
|
+
model.validation.errors.first(8).each do |error|
|
|
1031
|
+
rows << detail_line(width, "✗", "#{error[:path]} — #{error[:message]}", :red)
|
|
1032
|
+
end
|
|
1033
|
+
rows << Ui.blank_line(width)
|
|
1034
|
+
end
|
|
1035
|
+
|
|
1036
|
+
model.validation.warnings.first(5).each do |warning|
|
|
1037
|
+
rows << detail_line(width, "▲", "#{warning[:path]} — #{warning[:message]}", :yellow)
|
|
1038
|
+
end
|
|
1039
|
+
rows << Ui.blank_line(width) unless model.validation.warnings.empty?
|
|
1040
|
+
|
|
1041
|
+
rows << section(width, "curation — lint", model.lint.warnings.empty? ? :green : :yellow)
|
|
1042
|
+
if model.lint.findings.empty?
|
|
1043
|
+
rows << detail_line(width, "✓", "no curation findings", :green)
|
|
1044
|
+
else
|
|
1045
|
+
rows.concat(lint_block(model, width))
|
|
1046
|
+
end
|
|
1047
|
+
|
|
1048
|
+
rows.concat(hubs_block(model, width))
|
|
1049
|
+
rows
|
|
1050
|
+
end
|
|
1051
|
+
|
|
1052
|
+
# The right pane: the verdicts, then the shape. It says how many errors there
|
|
1053
|
+
# are rather than listing them — the list is beside it, and a path in a column
|
|
1054
|
+
# this narrow would be clipped into a different path.
|
|
1055
|
+
def health_summary(model, width)
|
|
1056
|
+
valid = model.validation.valid?
|
|
1057
|
+
rows = [ section(width, "conformance — spec §9", valid ? :green : :red) ]
|
|
1058
|
+
rows <<
|
|
1059
|
+
if valid
|
|
1060
|
+
detail_line(width, "✓", conformance_line(model), :green)
|
|
1061
|
+
else
|
|
1062
|
+
detail_line(width, "✗", "#{count(model.validation.errors.length, "error")} — in the findings", :red)
|
|
1063
|
+
end
|
|
1064
|
+
|
|
1065
|
+
rows << Ui.blank_line(width)
|
|
1066
|
+
warnings = model.lint.warnings.length
|
|
1067
|
+
rows << section(width, "curation — lint", warnings.zero? ? :green : :yellow)
|
|
1068
|
+
if model.lint.findings.empty?
|
|
1069
|
+
rows << detail_line(width, "✓", "no curation findings", :green)
|
|
1070
|
+
else
|
|
1071
|
+
info = model.lint.findings.length - warnings
|
|
1072
|
+
note = [ warnings.zero? ? nil : count(warnings, "warning"),
|
|
1073
|
+
info.zero? ? nil : "#{info} info" ].compact.join(" · ")
|
|
1074
|
+
rows << detail_line(width, warnings.zero? ? "ℹ" : "▲", "#{note} — in the findings",
|
|
1075
|
+
warnings.zero? ? :blue : :yellow)
|
|
1076
|
+
end
|
|
1077
|
+
rows.concat(skipped_block(model, width))
|
|
1078
|
+
|
|
1079
|
+
rows.concat(posture_block(model, width))
|
|
1080
|
+
rows.concat(traffic_block(model, width))
|
|
1081
|
+
rows.concat(stats_block(model, width))
|
|
1082
|
+
rows
|
|
1083
|
+
end
|
|
1084
|
+
|
|
1085
|
+
# What the bundle declares itself to be (§12), not what this screen assumes.
|
|
1086
|
+
# It said "legal OKF v0.1" for a release — about every bundle equally,
|
|
1087
|
+
# including one that had migrated — which is the whole failure mode of reading
|
|
1088
|
+
# a fact off a literal instead of off okf. §12 lets a bundle declare nothing,
|
|
1089
|
+
# and there the honest answer names no version: `validate` backs conformance,
|
|
1090
|
+
# never a version claim.
|
|
1091
|
+
def conformance_line(model)
|
|
1092
|
+
version = model.okf_version
|
|
1093
|
+
version.to_s.empty? ? "a conformant bundle" : "a legal OKF v#{version} bundle"
|
|
1094
|
+
end
|
|
1095
|
+
|
|
1096
|
+
# A verdict is only as good as the checks behind it, so a check that did not
|
|
1097
|
+
# run is said out loud. §5.5's freshness pair is clock-gated and the pure
|
|
1098
|
+
# library runs neither unless handed a clock — and "✓ lint clean" over a check
|
|
1099
|
+
# that never ran is worse than no verdict at all. okf's own CLI confesses the
|
|
1100
|
+
# same thing in the same words.
|
|
1101
|
+
def skipped_block(model, width)
|
|
1102
|
+
skipped = model.skipped_checks
|
|
1103
|
+
return [] if skipped.empty?
|
|
1104
|
+
|
|
1105
|
+
[ detail_line(width, "·", "#{skipped.join(", ")} not run — no clock supplied", :bright_black) ]
|
|
1106
|
+
end
|
|
1107
|
+
|
|
1108
|
+
# The bundle's trust and status posture — the two distributions okf's own lint
|
|
1109
|
+
# prints on its summary line, and the two `stats_block` cannot show because it
|
|
1110
|
+
# keeps scalars only.
|
|
1111
|
+
#
|
|
1112
|
+
# Each is shown only where the bundle has something to say, on the same rule
|
|
1113
|
+
# the graph page's facets use: status needs one *declared* value, since a
|
|
1114
|
+
# column of `stable` is what an undeclared status already means; trust needs
|
|
1115
|
+
# one tier okf is willing to claim, or every v0.1 bundle would wear a row
|
|
1116
|
+
# reading only "unverified" about a family it never adopted.
|
|
1117
|
+
def posture_block(model, width)
|
|
1118
|
+
claims_trust = model.rows.any? { |row| Model.shows_trust?(row) }
|
|
1119
|
+
declared_status = model.rows.any? { |row| !row[:status].to_s.empty? }
|
|
1120
|
+
return [] unless claims_trust || declared_status
|
|
1121
|
+
|
|
1122
|
+
rows = [ Ui.blank_line(width), section(width, "posture — §5 trust and status", :blue) ]
|
|
1123
|
+
# okf's own numbers, unedited — this is the line `okf lint` prints. Only the
|
|
1124
|
+
# *gate* is this view's, and it is a gate on the bundle rather than on the
|
|
1125
|
+
# tiers: once one concept claims a tier the whole distribution is the answer,
|
|
1126
|
+
# including the unverified ones it is measured against.
|
|
1127
|
+
rows << posture_row(width, "trust", model.trust_posture) if claims_trust
|
|
1128
|
+
rows << posture_row(width, "status", model.status_posture) if declared_status
|
|
1129
|
+
rows
|
|
1130
|
+
end
|
|
1131
|
+
|
|
1132
|
+
# Tiers and statuses the bundle has none of are dropped. okf's lint line
|
|
1133
|
+
# prints all three tiers because a terminal line is as wide as it needs to be;
|
|
1134
|
+
# this pane holds a fixed width, and it holds it because every row in it is
|
|
1135
|
+
# short *by construction*. `trust unverified 31 machine-confirmed 0
|
|
1136
|
+
# human-reviewed 0` is not, and clipped to fit it read "human-rev…".
|
|
1137
|
+
def posture_row(width, label, counts)
|
|
1138
|
+
counts = posture_counts(counts)
|
|
1139
|
+
|
|
1140
|
+
Ui.line(width) do |r|
|
|
1141
|
+
r.add(" #{label} ", :bright_black)
|
|
1142
|
+
counts.each_with_index do |(value, count), index|
|
|
1143
|
+
r.add(" ", :bright_black) if index.positive?
|
|
1144
|
+
r.add(value.to_s, :white)
|
|
1145
|
+
r.add(" #{count}", :bright_white, :bold)
|
|
1146
|
+
end
|
|
1147
|
+
end
|
|
1148
|
+
end
|
|
1149
|
+
|
|
1150
|
+
def posture_counts(counts)
|
|
1151
|
+
counts.reject { |_, count| count.to_i.zero? }
|
|
1152
|
+
end
|
|
1153
|
+
|
|
1154
|
+
# Findings grouped by check, warnings before info. Each group lists a few
|
|
1155
|
+
# examples and says how many it withheld, so a long list stays readable
|
|
1156
|
+
# without the page pretending it showed everything.
|
|
1157
|
+
def lint_block(model, width)
|
|
1158
|
+
grouped = model.lint.findings.group_by { |finding| finding[:check] }
|
|
1159
|
+
ordered = grouped.sort_by do |check, list|
|
|
1160
|
+
[ list.first[:severity] == :warn ? 0 : 1, -list.length, check.to_s ]
|
|
1161
|
+
end
|
|
1162
|
+
|
|
1163
|
+
# Examples shown per check. The page scrolls, so this is about keeping one
|
|
1164
|
+
# noisy check from burying the others, not about fitting the screen.
|
|
1165
|
+
examples = 3
|
|
1166
|
+
rows = []
|
|
1167
|
+
|
|
1168
|
+
ordered.each do |check, list|
|
|
1169
|
+
warn = list.first[:severity] == :warn
|
|
1170
|
+
rows << Ui.line(width) do |r|
|
|
1171
|
+
r.add(" #{warn ? "▲" : "ℹ"} ", warn ? :yellow : :blue)
|
|
1172
|
+
r.add(check.to_s, :bright_white, :bold)
|
|
1173
|
+
r.add(" ×#{list.length}", :bright_black)
|
|
1174
|
+
end
|
|
1175
|
+
|
|
1176
|
+
list.first(examples).each do |finding|
|
|
1177
|
+
rows << Ui.line(width) do |r|
|
|
1178
|
+
r.add(" #{finding[:path]}", :bright_black)
|
|
1179
|
+
r.add(" #{finding[:message]}", :white)
|
|
1180
|
+
end
|
|
1181
|
+
end
|
|
1182
|
+
|
|
1183
|
+
hidden = list.length - examples
|
|
1184
|
+
rows << Ui.line(width) { |r| r.add(" … #{hidden} more", :bright_black) } if hidden.positive?
|
|
1185
|
+
end
|
|
1186
|
+
|
|
1187
|
+
rows
|
|
1188
|
+
end
|
|
1189
|
+
|
|
1190
|
+
# Hubs, and whether each is well homed — okf's `graph --hubs`.
|
|
1191
|
+
#
|
|
1192
|
+
# The number alone is already in the graph view; what earns a place here is the
|
|
1193
|
+
# *judgement*, which needs the inbound breakdown beside it. A hub drawing most
|
|
1194
|
+
# of its links from outside its own top-level dir is flagged, and when a single
|
|
1195
|
+
# foreign dir dominates, that dir is named — it is the better home the concept
|
|
1196
|
+
# already has.
|
|
1197
|
+
HUBS_SHOWN = 6
|
|
1198
|
+
|
|
1199
|
+
def hubs_block(model, width)
|
|
1200
|
+
hubs = model.hubs
|
|
1201
|
+
rows = [ Ui.blank_line(width), section(width, "hubs — where their inbound links come from", :blue) ]
|
|
1202
|
+
|
|
1203
|
+
if hubs.empty?
|
|
1204
|
+
rows << detail_line(width, "·", "nothing is linked to yet", :bright_black)
|
|
1205
|
+
return rows
|
|
1206
|
+
end
|
|
1207
|
+
|
|
1208
|
+
hubs.first(HUBS_SHOWN).each { |hub| rows << hub_row(hub, width) }
|
|
1209
|
+
hidden = hubs.length - HUBS_SHOWN
|
|
1210
|
+
rows << Ui.line(width) { |r| r.add(" … #{hidden} more", :bright_black) } if hidden.positive?
|
|
1211
|
+
rows
|
|
1212
|
+
end
|
|
1213
|
+
|
|
1214
|
+
def hub_row(hub, width)
|
|
1215
|
+
home = hub[:top_dir].to_s
|
|
1216
|
+
foreign = hub[:by_top_dir].reject { |dir, _| dir.to_s == home }
|
|
1217
|
+
foreign_total = foreign.values.reduce(0, :+)
|
|
1218
|
+
away = foreign_total * 2 > hub[:inbound]
|
|
1219
|
+
|
|
1220
|
+
Ui.line(width) do |r|
|
|
1221
|
+
r.add(away ? " ▲ " : " · ", away ? :yellow : :bright_black)
|
|
1222
|
+
r.add(hub[:id].to_s, :bright_white, :bold)
|
|
1223
|
+
r.add(" ←#{hub[:inbound]}", :blue)
|
|
1224
|
+
r.add(" in #{home}", :bright_black)
|
|
1225
|
+
# Named only when one foreign dir carries the majority on its own: that is
|
|
1226
|
+
# the case where okf says the hub has already named its better home. A
|
|
1227
|
+
# scattered inbound majority is a different finding, and saying "move it
|
|
1228
|
+
# to X" on the strength of a plurality would be the wrong advice.
|
|
1229
|
+
top_dir, top_count = foreign.max_by { |_, count| count }
|
|
1230
|
+
if away && top_count && top_count * 2 > hub[:inbound]
|
|
1231
|
+
r.add(" → mostly from #{top_dir}", :yellow)
|
|
1232
|
+
elsif away
|
|
1233
|
+
r.add(" → #{foreign_total} of #{hub[:inbound]} from elsewhere", :yellow)
|
|
1234
|
+
end
|
|
1235
|
+
end
|
|
1236
|
+
end
|
|
1237
|
+
|
|
1238
|
+
# Cohesion versus coupling, one row per directory — okf's `graph --traffic`.
|
|
1239
|
+
#
|
|
1240
|
+
# This is the measurement okf added because the refine playbook's directory
|
|
1241
|
+
# judgements ("does this directory prune? a concern, or a container?") had
|
|
1242
|
+
# nothing at their own grain: `--hubs` measures concepts. Near-zero cohesion
|
|
1243
|
+
# under heavy inbound is a shared vocabulary doing its job; heavy outbound with
|
|
1244
|
+
# nothing coming back is a projection wearing a directory. Which of those it is
|
|
1245
|
+
# remains the reader's call — the row supplies the evidence, not the verdict.
|
|
1246
|
+
#
|
|
1247
|
+
# Model#dir_traffic has already sorted by cohesion ascending, so the directories
|
|
1248
|
+
# with a case to answer are the ones on screen first.
|
|
1249
|
+
TRAFFIC_SHOWN = 8
|
|
1250
|
+
|
|
1251
|
+
def traffic_block(model, width)
|
|
1252
|
+
traffic = model.dir_traffic
|
|
1253
|
+
rows = [ Ui.blank_line(width), section(width, "dir traffic — internal share of each dir's links", :yellow) ]
|
|
1254
|
+
|
|
1255
|
+
# One directory is the whole bundle: there is no traffic *between*
|
|
1256
|
+
# directories to weigh, so every row would be a tautology.
|
|
1257
|
+
if traffic.length < 2
|
|
1258
|
+
rows << detail_line(width, "·", "one directory — nothing to weigh it against", :bright_black)
|
|
1259
|
+
return rows
|
|
1260
|
+
end
|
|
1261
|
+
|
|
1262
|
+
rows << Ui.line(width) do |r|
|
|
1263
|
+
r.add(" #{"dir".ljust(traffic_pad(traffic))}", :bright_black)
|
|
1264
|
+
r.add(" internal out in cohesion", :bright_black)
|
|
1265
|
+
end
|
|
1266
|
+
traffic.first(TRAFFIC_SHOWN).each { |row| rows << traffic_row(row, width, traffic_pad(traffic)) }
|
|
1267
|
+
hidden = traffic.length - TRAFFIC_SHOWN
|
|
1268
|
+
rows << Ui.line(width) { |r| r.add(" … #{hidden} more", :bright_black) } if hidden.positive?
|
|
1269
|
+
rows.concat(arc_rows(model, width))
|
|
1270
|
+
rows
|
|
1271
|
+
end
|
|
1272
|
+
|
|
1273
|
+
# Which directories actually talk to which. The table above says how much of a
|
|
1274
|
+
# directory's traffic stays home; these say where the rest of it goes, which is
|
|
1275
|
+
# the other half of `graph --traffic` and the half that names a pair.
|
|
1276
|
+
ARCS_SHOWN = 6
|
|
1277
|
+
|
|
1278
|
+
def arc_rows(model, width)
|
|
1279
|
+
arcs, cut, total = model.dir_arcs
|
|
1280
|
+
return [] if arcs.empty?
|
|
1281
|
+
|
|
1282
|
+
rows = [ Ui.blank_line(width) ]
|
|
1283
|
+
rows << Ui.line(width) do |r|
|
|
1284
|
+
r.add(" #{arcs.length} of #{total} arcs", :bright_black)
|
|
1285
|
+
# Say the cut, because the list is narrowed and a silently shortened list
|
|
1286
|
+
# reads as a complete one. It is fitted to this bundle by okf, not fixed.
|
|
1287
|
+
r.add(" at weight #{cut} or more", :bright_black)
|
|
1288
|
+
end
|
|
1289
|
+
|
|
1290
|
+
pad = arcs.first(ARCS_SHOWN).map { |arc| Ui.width(traffic_label(dir: arc[:source])) }.max.to_i
|
|
1291
|
+
arcs.first(ARCS_SHOWN).each do |arc|
|
|
1292
|
+
rows << Ui.line(width) do |r|
|
|
1293
|
+
r.add(" #{traffic_label(dir: arc[:source]).ljust(pad)}", :white)
|
|
1294
|
+
r.add(" → ", :bright_black)
|
|
1295
|
+
r.add(traffic_label(dir: arc[:target]), :white)
|
|
1296
|
+
r.add(" ×#{arc[:weight]}", :bright_black)
|
|
1297
|
+
end
|
|
1298
|
+
end
|
|
1299
|
+
|
|
1300
|
+
hidden = arcs.length - ARCS_SHOWN
|
|
1301
|
+
rows << Ui.line(width) { |r| r.add(" … #{hidden} more", :bright_black) } if hidden.positive?
|
|
1302
|
+
rows
|
|
1303
|
+
end
|
|
1304
|
+
|
|
1305
|
+
def traffic_pad(traffic)
|
|
1306
|
+
traffic.map { |row| Ui.width(traffic_label(row)) }.max.to_i
|
|
1307
|
+
end
|
|
1308
|
+
|
|
1309
|
+
# `(root)` for a reader, as everywhere else — `.` is the stored spelling.
|
|
1310
|
+
def traffic_label(row)
|
|
1311
|
+
row[:dir] == "." ? "(root)" : row[:dir].to_s
|
|
1312
|
+
end
|
|
1313
|
+
|
|
1314
|
+
def traffic_row(row, width, pad)
|
|
1315
|
+
cohesion = row[:cohesion]
|
|
1316
|
+
|
|
1317
|
+
Ui.line(width) do |r|
|
|
1318
|
+
r.add(" #{traffic_label(row).ljust(pad)}", :white)
|
|
1319
|
+
r.add(" #{row[:internal].to_s.rjust(8)}", :bright_black)
|
|
1320
|
+
r.add(" #{row[:out].to_s.rjust(4)}", :bright_black)
|
|
1321
|
+
r.add(" #{row[:in].to_s.rjust(4)}", :bright_black)
|
|
1322
|
+
# A dash, not 0% — a directory with no traffic at all has not earned a
|
|
1323
|
+
# number, which is the distinction okf's own view is careful to draw.
|
|
1324
|
+
if cohesion.nil?
|
|
1325
|
+
r.add(" —", :bright_black)
|
|
1326
|
+
else
|
|
1327
|
+
r.add(" #{"#{cohesion}%".rjust(4)}", cohesion_colour(cohesion))
|
|
1328
|
+
end
|
|
1329
|
+
end
|
|
1330
|
+
end
|
|
1331
|
+
|
|
1332
|
+
# Low cohesion is the thing worth looking at, so it is the thing that is
|
|
1333
|
+
# coloured. Not a verdict — okf is explicit that near-zero cohesion can be a
|
|
1334
|
+
# shared vocabulary doing exactly its job — which is why this is a shade of
|
|
1335
|
+
# attention rather than the ▲ the lint findings wear.
|
|
1336
|
+
def cohesion_colour(cohesion)
|
|
1337
|
+
return :yellow if cohesion < 25
|
|
1338
|
+
return :white if cohesion < 60
|
|
1339
|
+
|
|
1340
|
+
:green
|
|
1341
|
+
end
|
|
1342
|
+
|
|
1343
|
+
def stats_block(model, width)
|
|
1344
|
+
# Scalars only. The linter also reports structured stats (`hubs`, `tags`)
|
|
1345
|
+
# whose #to_s is a Ruby literal — the graph view already draws those
|
|
1346
|
+
# properly, so printing them here as inspect output would be noise.
|
|
1347
|
+
scalars = model.lint.stats.reject { |_, value| value.is_a?(Enumerable) }
|
|
1348
|
+
|
|
1349
|
+
rows = [ Ui.blank_line(width), section(width, "stats", :cyan) ]
|
|
1350
|
+
scalars.each_slice(4) do |slice|
|
|
1351
|
+
rows << Ui.line(width) do |r|
|
|
1352
|
+
slice.each do |key, value|
|
|
1353
|
+
r.add(" #{key} ", :bright_black)
|
|
1354
|
+
r.add(value.to_s, :bright_white, :bold)
|
|
1355
|
+
end
|
|
1356
|
+
end
|
|
1357
|
+
end
|
|
1358
|
+
rows
|
|
1359
|
+
end
|
|
1360
|
+
|
|
1361
|
+
# Draw `rows` as a box showing only the slice the scroll offset selects, and
|
|
1362
|
+
# say so in the title when there is more than fits. A pane that silently
|
|
1363
|
+
# shows the first N rows of a longer list reads as a complete one.
|
|
1364
|
+
def scrollable(app, rows, outer_width, height, title, offset: nil, scroll: :content, focused: true)
|
|
1365
|
+
inner_height = height - 2
|
|
1366
|
+
# `/` looks through the pane with focus. Matching both halves of a split view
|
|
1367
|
+
# would put the find cursor on a page the keys are not moving.
|
|
1368
|
+
live = offset.nil? && focused
|
|
1369
|
+
matches = live ? app.find_matches(rows) : []
|
|
1370
|
+
app.remember_page(rows) if live
|
|
1371
|
+
# A shared offset is passed in; otherwise the page owns its own, and a pending
|
|
1372
|
+
# find jump moves it.
|
|
1373
|
+
offset ||= app.content_offset(rows.length, inner_height, matches, scroll)
|
|
1374
|
+
|
|
1375
|
+
# Clamp to this pane's own end. A shared offset (the graph view's two
|
|
1376
|
+
# panes) can otherwise run past the shorter one and blank it.
|
|
1377
|
+
offset = [ [ offset, [ rows.length - inner_height, 0 ].max ].min, 0 ].max
|
|
1378
|
+
visible = (rows[offset, inner_height] || []).each_with_index.map do |row, index|
|
|
1379
|
+
line = offset + index
|
|
1380
|
+
next row unless matches.include?(line)
|
|
1381
|
+
|
|
1382
|
+
# Marked in the gutter rather than restyled: these rows are already
|
|
1383
|
+
# coloured. The marker costs a column, so one of the rows own trailing
|
|
1384
|
+
# pad spaces pays for it — clipping the tail instead would ellipsise a
|
|
1385
|
+
# line that actually fits.
|
|
1386
|
+
current = matches[app.find_index % [ matches.length, 1 ].max] == line
|
|
1387
|
+
mark = Ui.pastel.decorate(current ? "▶" : "·", current ? :black : :yellow, *(current ? %i[on_yellow bold] : []))
|
|
1388
|
+
mark + row.sub(/ \z/, "")
|
|
1389
|
+
end
|
|
1390
|
+
|
|
1391
|
+
title = "#{title} · “#{app.find}”" unless app.find.to_s.empty? || matches.empty?
|
|
1392
|
+
|
|
1393
|
+
label =
|
|
1394
|
+
if rows.length > inner_height
|
|
1395
|
+
last = [ offset + inner_height, rows.length ].min
|
|
1396
|
+
"#{title} #{offset + 1}-#{last}/#{rows.length}"
|
|
1397
|
+
else
|
|
1398
|
+
title
|
|
1399
|
+
end
|
|
1400
|
+
|
|
1401
|
+
Ui.box(visible, width: outer_width, height: height, title: label, active: focused)
|
|
1402
|
+
end
|
|
1403
|
+
|
|
1404
|
+
def section(width, label, colour)
|
|
1405
|
+
Ui.line(width) do |r|
|
|
1406
|
+
r.add(" ▌", colour, :bold)
|
|
1407
|
+
r.add(" #{label} ", :bright_white, :bold)
|
|
1408
|
+
end
|
|
1409
|
+
end
|
|
1410
|
+
|
|
1411
|
+
def detail_line(width, glyph, text, colour)
|
|
1412
|
+
Ui.line(width) do |r|
|
|
1413
|
+
r.add(" #{glyph} ", colour)
|
|
1414
|
+
r.add(text, :white)
|
|
1415
|
+
end
|
|
1416
|
+
end
|
|
1417
|
+
|
|
1418
|
+
# ── graph: shape of the knowledge graph ──────────────────────────────────
|
|
1419
|
+
|
|
1420
|
+
def graph(app, width, height)
|
|
1421
|
+
half = width / 2
|
|
1422
|
+
|
|
1423
|
+
left = graph_pane(app, app.graph_facet_entries, half, height, "distribution", :list)
|
|
1424
|
+
right = graph_pane(app, app.graph_concept_entries, width - half, height, "connectivity", :detail)
|
|
1425
|
+
|
|
1426
|
+
Ui.hjoin(left, right)
|
|
1427
|
+
end
|
|
1428
|
+
|
|
1429
|
+
# One pane of the graph: its rows, windowed around the cursor when it is the
|
|
1430
|
+
# focused one. Only the focused pane carries a cursor, so the two lists never
|
|
1431
|
+
# both look selected.
|
|
1432
|
+
def graph_pane(app, entries, width, height, title, pane)
|
|
1433
|
+
focused = app.pane == pane
|
|
1434
|
+
inner_height = height - 2
|
|
1435
|
+
inner_width = width - 2
|
|
1436
|
+
|
|
1437
|
+
window = focused ? app.window(entries.length, inner_height) : 0
|
|
1438
|
+
rows = entries[window, inner_height].to_a.each_with_index.map do |entry, offset|
|
|
1439
|
+
graph_row(app, entry, inner_width, focused && window + offset == app.cursor)
|
|
1440
|
+
end
|
|
1441
|
+
|
|
1442
|
+
Ui.box(rows, width: width, height: height, title: graph_title(app, title, entries, window, inner_height), active: focused)
|
|
1443
|
+
end
|
|
1444
|
+
|
|
1445
|
+
def graph_title(app, title, entries, window, inner_height)
|
|
1446
|
+
title = "#{title} /#{app.filter}" unless app.filter.empty?
|
|
1447
|
+
title = "#{title} · #{app.graph_facet[:value]}" if app.graph_facet
|
|
1448
|
+
return title if entries.length <= inner_height
|
|
1449
|
+
|
|
1450
|
+
"#{title} #{window + 1}-#{[ window + inner_height, entries.length ].min}/#{entries.length}"
|
|
1451
|
+
end
|
|
1452
|
+
|
|
1453
|
+
# A row is a heading, a blank, a note, or something selectable — a facet to
|
|
1454
|
+
# narrow by or a concept to go read.
|
|
1455
|
+
def graph_row(app, entry, width, selected)
|
|
1456
|
+
case entry[:kind]
|
|
1457
|
+
when :blank then Ui.blank_line(width)
|
|
1458
|
+
when :heading then section(width, entry[:label], entry[:colour])
|
|
1459
|
+
when :note then detail_line(width, "✓", entry[:label], entry[:colour])
|
|
1460
|
+
when :facet then facet_row(app, entry, width, selected)
|
|
1461
|
+
else concept_bar(entry, width, selected)
|
|
1462
|
+
end
|
|
1463
|
+
end
|
|
1464
|
+
|
|
1465
|
+
FACET_COLOURS = { type: :cyan, tag: :magenta, dir: :yellow }.freeze
|
|
1466
|
+
|
|
1467
|
+
# `.` is how okf stores the bundle root and `(root)` is what it calls it for a
|
|
1468
|
+
# reader — the same pair `okf dirs` prints. The stored spelling stays in
|
|
1469
|
+
# `entry[:value]`, which is what the facet matches on.
|
|
1470
|
+
def facet_label(entry)
|
|
1471
|
+
value = entry[:value].to_s
|
|
1472
|
+
entry[:field] == :dir && value == "." ? "(root)" : value
|
|
1473
|
+
end
|
|
1474
|
+
|
|
1475
|
+
def facet_row(app, entry, width, selected)
|
|
1476
|
+
on = app.facet_active?(entry[:field], entry[:value])
|
|
1477
|
+
colour = FACET_COLOURS.fetch(entry[:field], :magenta)
|
|
1478
|
+
|
|
1479
|
+
bar_row(width, selected,
|
|
1480
|
+
label: facet_label(entry),
|
|
1481
|
+
count: entry[:count],
|
|
1482
|
+
peak: entry[:peak],
|
|
1483
|
+
pad: entry[:pad],
|
|
1484
|
+
colour: colour,
|
|
1485
|
+
mark: on ? "◉ " : " ",
|
|
1486
|
+
label_colour: if on
|
|
1487
|
+
colour
|
|
1488
|
+
else
|
|
1489
|
+
(selected ? :bright_white : :white)
|
|
1490
|
+
end)
|
|
1491
|
+
end
|
|
1492
|
+
|
|
1493
|
+
def concept_bar(entry, width, selected)
|
|
1494
|
+
bar_row(width, selected,
|
|
1495
|
+
label: entry[:id].to_s,
|
|
1496
|
+
count: entry[:count],
|
|
1497
|
+
peak: entry[:peak],
|
|
1498
|
+
pad: entry[:pad],
|
|
1499
|
+
colour: entry[:colour] || :blue,
|
|
1500
|
+
mark: "",
|
|
1501
|
+
label_colour: selected ? :bright_white : :white)
|
|
1502
|
+
end
|
|
1503
|
+
|
|
1504
|
+
# A selectable row that still draws its bar. Making the graph navigable is
|
|
1505
|
+
# no reason to stop it being a graph — the bar is what makes a distribution
|
|
1506
|
+
# readable at a glance, and the cursor rides in front of it.
|
|
1507
|
+
def bar_row(width, selected, label:, count:, peak:, pad:, colour:, mark:, label_colour:)
|
|
1508
|
+
Ui.line(width) do |r|
|
|
1509
|
+
r.add(selected ? "▸ " : " ", :cyan, :bold)
|
|
1510
|
+
r.add(mark, colour, :bold) unless mark.empty?
|
|
1511
|
+
|
|
1512
|
+
r.add(label.ljust(pad.to_i), label_colour, :bold)
|
|
1513
|
+
r.add(" #{count.to_s.rjust(3)} ", :bright_black)
|
|
1514
|
+
|
|
1515
|
+
# Whatever is left after the label and the count belongs to the bar.
|
|
1516
|
+
room = [ width - 4 - mark.length - pad.to_i - 5, 2 ].max
|
|
1517
|
+
top = [ peak.to_i, 1 ].max
|
|
1518
|
+
blocks = count.to_i.zero? ? 0 : [ (count.to_f / top * room).round, 1 ].max
|
|
1519
|
+
r.add("█" * blocks, colour)
|
|
1520
|
+
end
|
|
1521
|
+
end
|
|
1522
|
+
|
|
1523
|
+
# The filter narrows every list on the page by label — a type, a tag, or a
|
|
1524
|
+
# concept id. Counts are the ones already computed, so a filtered bar still
|
|
1525
|
+
# says how many there are in the bundle, not how many survived the filter.
|
|
1526
|
+
def narrow_rows(rows, filter)
|
|
1527
|
+
return rows if filter.to_s.empty?
|
|
1528
|
+
|
|
1529
|
+
needle = filter.downcase
|
|
1530
|
+
rows.select { |row| row[:id].to_s.downcase.include?(needle) }
|
|
1531
|
+
end
|
|
1532
|
+
|
|
1533
|
+
def narrow(pairs, filter)
|
|
1534
|
+
return pairs if filter.to_s.empty?
|
|
1535
|
+
|
|
1536
|
+
needle = filter.downcase
|
|
1537
|
+
pairs.select { |label, _| label.to_s.downcase.include?(needle) }
|
|
1538
|
+
end
|
|
1539
|
+
|
|
1540
|
+
# ── help ─────────────────────────────────────────────────────────────────
|
|
1541
|
+
|
|
1542
|
+
KEYS = [
|
|
1543
|
+
[ "Navigation", [
|
|
1544
|
+
[ "j / ↓", "next item" ],
|
|
1545
|
+
[ "k / ↑", "previous item" ],
|
|
1546
|
+
[ "g / G", "first / last item" ],
|
|
1547
|
+
[ "Ctrl-d / Ctrl-u", "half page down / up" ]
|
|
1548
|
+
] ],
|
|
1549
|
+
[ "Views", [
|
|
1550
|
+
[ "1 … 6", "bundles · browse · search · graph · health · help" ],
|
|
1551
|
+
[ "Tab", "switch pane — browse's body, a group's members, the graph's lists," ],
|
|
1552
|
+
[ "", "health's findings and its standing" ],
|
|
1553
|
+
[ "J / K", "scroll the concept body" ]
|
|
1554
|
+
] ],
|
|
1555
|
+
[ "Bundles (view 1)", [
|
|
1556
|
+
[ "Enter", "open that bundle — browse/health/graph follow it" ],
|
|
1557
|
+
[ "Enter", "on a group: scope the search to exactly its bundles" ],
|
|
1558
|
+
[ "space", "put it in, or out of, the search scope" ],
|
|
1559
|
+
[ "A / N", "scope all bundles / none — N is how you start a fresh selection" ],
|
|
1560
|
+
[ "G", "jump to the end of the list, where the groups are" ],
|
|
1561
|
+
[ "d", "make it the registry default" ],
|
|
1562
|
+
[ "a", "register a directory — the one bundles key the footer leaves to here" ],
|
|
1563
|
+
[ "n", "rename its slug — or a group's" ],
|
|
1564
|
+
[ "x", "remove it from the registry (asks first) — or delete a group" ],
|
|
1565
|
+
[ "c", "name the bundles now in scope as a group" ]
|
|
1566
|
+
] ],
|
|
1567
|
+
[ "Groups (view 1)", [
|
|
1568
|
+
[ "Tab", "cycle the three panes: bundles → groups → members" ],
|
|
1569
|
+
[ "Esc", "step back out, one pane at a time" ],
|
|
1570
|
+
[ "Enter", "on a group: make a search cover exactly its bundles" ],
|
|
1571
|
+
[ "+", "in the bundles pane: the bundle under the cursor joins the group" ],
|
|
1572
|
+
[ "", "selected below — the detail pane lists what a bundle is in" ],
|
|
1573
|
+
[ "-", "in the members: remove the one under the cursor (asks first)" ],
|
|
1574
|
+
[ "", "removing the last member deletes the group, and the question says so" ],
|
|
1575
|
+
[ "n / x", "rename or delete a group; okf cascades through every member list" ],
|
|
1576
|
+
[ "c", "name the bundles now in scope as a new group" ]
|
|
1577
|
+
] ],
|
|
1578
|
+
[ "Graph (view 4)", [
|
|
1579
|
+
[ "↑ ↓", "move over the facets, or the concepts" ],
|
|
1580
|
+
[ "Tab", "switch between the two lists" ],
|
|
1581
|
+
[ "Enter", "on a type, tag or dir: narrow the whole graph by it" ],
|
|
1582
|
+
[ "", "a dir reaches everything beneath it, as okf's --dir does" ],
|
|
1583
|
+
[ "Enter", "on a concept: open it in browse" ],
|
|
1584
|
+
[ "Esc", "clear the facet" ]
|
|
1585
|
+
] ],
|
|
1586
|
+
[ "Following links (view 2)", [
|
|
1587
|
+
[ "f", "list what this document links to — a concept body, an index, the log" ],
|
|
1588
|
+
[ "1 … 9", "follow that link straight away" ],
|
|
1589
|
+
[ "Enter", "follow the one under the cursor" ],
|
|
1590
|
+
[ "Esc", "put the body back, where you left it" ],
|
|
1591
|
+
[ "Backspace", "back to wherever you jumped from — search hits and the graph too" ]
|
|
1592
|
+
] ],
|
|
1593
|
+
[ "Finding things", [
|
|
1594
|
+
[ "/", "look through what has focus — the list, or the open document" ],
|
|
1595
|
+
[ "n / N", "next / previous match, while reading a body" ],
|
|
1596
|
+
[ "Enter", "when a filter matches nothing: search every bundle for it" ],
|
|
1597
|
+
[ "", "the registry filter offers the same escalation, from view 1" ],
|
|
1598
|
+
[ "s", "jump to search — it covers every scoped bundle" ],
|
|
1599
|
+
[ "e", "how the query is asked: fuzzy · text · regexp (Esc out of the field first)" ],
|
|
1600
|
+
[ "", "fuzzy ranks and forgives typos; text is raw substring, so it finds" ],
|
|
1601
|
+
[ "", "$OKF_HOME and `minifts`, which the index tokenizer splits apart" ],
|
|
1602
|
+
[ "Enter", "search; press it again to open the selected hit" ],
|
|
1603
|
+
[ "↑ ↓", "pick a hit — works while typing or after Esc" ],
|
|
1604
|
+
[ "Esc", "stop editing and stay put; drops an unsearched query" ],
|
|
1605
|
+
[ "/ or i", "start or resume editing the query" ],
|
|
1606
|
+
[ "Ctrl-u", "clear the query while editing" ]
|
|
1607
|
+
] ],
|
|
1608
|
+
[ "Session", [
|
|
1609
|
+
[ "r", "reload every bundle from disk" ],
|
|
1610
|
+
[ "q q", "quit — the second press confirms, so a stray q costs nothing" ]
|
|
1611
|
+
] ]
|
|
1612
|
+
].freeze
|
|
1613
|
+
|
|
1614
|
+
def help(app, outer_width, height)
|
|
1615
|
+
width = outer_width - 2
|
|
1616
|
+
rows = []
|
|
1617
|
+
rows << Ui.blank_line(width)
|
|
1618
|
+
KEYS.each do |group, bindings|
|
|
1619
|
+
rows << section(width, group, :cyan)
|
|
1620
|
+
bindings.each do |key, meaning|
|
|
1621
|
+
rows << Ui.line(width) do |r|
|
|
1622
|
+
r.add(" #{key.rjust(16)}", :bright_white, :bold)
|
|
1623
|
+
r.add(" #{meaning}", :white)
|
|
1624
|
+
end
|
|
1625
|
+
end
|
|
1626
|
+
rows << Ui.blank_line(width)
|
|
1627
|
+
end
|
|
1628
|
+
|
|
1629
|
+
rows << section(width, "about", :magenta)
|
|
1630
|
+
[
|
|
1631
|
+
"A terminal UI over the okf gem, built with the TTY toolkit.",
|
|
1632
|
+
"It reads bundles through OKF::Bundle::Reader and derives every",
|
|
1633
|
+
"answer from the pure core — the same catalog, graph, validator,",
|
|
1634
|
+
"linter and MiniFTS search the CLI and the graph server use.",
|
|
1635
|
+
"",
|
|
1636
|
+
"A search spans every bundle in scope through one shared index,",
|
|
1637
|
+
"which is what makes the scores comparable between them — the",
|
|
1638
|
+
"same thing `okf search @all` does.",
|
|
1639
|
+
"",
|
|
1640
|
+
"Nothing here is authored twice: the TUI is one more shell."
|
|
1641
|
+
].each do |text|
|
|
1642
|
+
rows << Ui.line(width) { |r| r.add(" #{text}", :bright_black) }
|
|
1643
|
+
end
|
|
1644
|
+
|
|
1645
|
+
scrollable(app, rows, outer_width, height, "help")
|
|
1646
|
+
end
|
|
1647
|
+
end
|
|
1648
|
+
end
|