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,527 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "okf"
|
|
4
|
+
require "okf/registry"
|
|
5
|
+
|
|
6
|
+
module OKF::TUI
|
|
7
|
+
# Every bundle the session can see, which one is active, and which ones a
|
|
8
|
+
# search covers.
|
|
9
|
+
#
|
|
10
|
+
# Two ways in, mirroring what `okf server` accepts: named directories are
|
|
11
|
+
# ad-hoc and never touch the registry, and with no directories the registry
|
|
12
|
+
# itself is the workspace. Registering stays an explicit act — an ad-hoc look
|
|
13
|
+
# at two bundles should not enrol them in the user's durable list.
|
|
14
|
+
class Workspace
|
|
15
|
+
# One bundle in the workspace. `model` is nil when the directory is missing
|
|
16
|
+
# or unreadable, and `error` says which — so a row explains itself instead of
|
|
17
|
+
# silently vanishing from the list.
|
|
18
|
+
class Entry
|
|
19
|
+
attr_reader :slug, :dir
|
|
20
|
+
attr_accessor :model, :error
|
|
21
|
+
|
|
22
|
+
def initialize(slug:, dir:, default:, registered:)
|
|
23
|
+
@slug = slug
|
|
24
|
+
@dir = dir
|
|
25
|
+
@default = default
|
|
26
|
+
@registered = registered
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def default?
|
|
30
|
+
@default
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def registered?
|
|
34
|
+
@registered
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def loaded?
|
|
38
|
+
!model.nil?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def concepts
|
|
42
|
+
loaded? ? model.concept_count : 0
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# One registry group (okf 1.12): a slug naming a list of members — bundle or
|
|
47
|
+
# group slugs, so they nest — which resolves recursively, path-deduped, to
|
|
48
|
+
# bundle leaves.
|
|
49
|
+
#
|
|
50
|
+
# `members` is what the registry file says, verbatim, and `bundles` is what
|
|
51
|
+
# okf resolved it to. Both are shown, because they answer different questions:
|
|
52
|
+
# the members are what an edit would change, the leaves are what a search
|
|
53
|
+
# would cover, and for a nested group those are not the same list.
|
|
54
|
+
class Group
|
|
55
|
+
attr_reader :slug, :members, :bundles
|
|
56
|
+
|
|
57
|
+
def initialize(slug:, members:, bundles:, cyclic:)
|
|
58
|
+
@slug = slug
|
|
59
|
+
@members = members
|
|
60
|
+
@bundles = bundles
|
|
61
|
+
@cyclic = cyclic
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# A hand-edited registry can name a cycle, which okf reports by declining to
|
|
65
|
+
# resolve rather than by looping. Nothing here can repair it, so the row says
|
|
66
|
+
# so and refuses to be scoped — the alternative is a group that silently
|
|
67
|
+
# covers no bundles.
|
|
68
|
+
def cyclic?
|
|
69
|
+
@cyclic
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def size
|
|
73
|
+
bundles.length
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
attr_reader :entries, :registry, :home, :active_slug
|
|
78
|
+
|
|
79
|
+
# +cwd+ is what opts this workspace into registry discovery, and it is
|
|
80
|
+
# deliberately not defaulted to Dir.pwd: okf draws the same line, where only
|
|
81
|
+
# its CLI passes a cwd and a library caller stays on the global registry. A
|
|
82
|
+
# default here would make the registry an embedding app reads depend on the
|
|
83
|
+
# directory its process happens to be in, and would let the suite discover a
|
|
84
|
+
# `.okf-registry.json` from wherever `rake` was run.
|
|
85
|
+
#
|
|
86
|
+
# +ref_slugs+ maps a resolved directory to the slug the @ref named it by, so a
|
|
87
|
+
# session built from refs keeps the names the user typed. See Refs#slugs.
|
|
88
|
+
def initialize(dirs: [], ref_slugs: {}, home: nil, cwd: nil)
|
|
89
|
+
@home = home
|
|
90
|
+
@cwd = cwd
|
|
91
|
+
@dirs = Array(dirs)
|
|
92
|
+
@ref_slugs = ref_slugs || {}
|
|
93
|
+
load_entries
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# A registry-backed workspace is the one that can be configured; a workspace
|
|
97
|
+
# of named directories has no registry to write to.
|
|
98
|
+
def registry_backed?
|
|
99
|
+
@dirs.empty?
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# The registry file this session is on — the discovered project-local one when
|
|
103
|
+
# there is one, else the global $OKF_HOME one. Asked of the registry rather
|
|
104
|
+
# than recomputed from `home`, because Registry.path only ever names the global
|
|
105
|
+
# file and would report the wrong one out of a project directory: the header
|
|
106
|
+
# would print a path the session is not reading.
|
|
107
|
+
def registry_path
|
|
108
|
+
(registry || open_registry).path
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def empty?
|
|
112
|
+
entries.empty?
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def entry(slug)
|
|
116
|
+
entries.find { |candidate| candidate.slug == slug }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# The registry's groups. Empty for an ad-hoc workspace, which has no registry
|
|
120
|
+
# to have groups in.
|
|
121
|
+
#
|
|
122
|
+
# Read from okf rather than derived: `groups_listing` is what `okf registry
|
|
123
|
+
# list` prints and `expand` is what `okf search @group` resolves, so a group
|
|
124
|
+
# means the same set here as it does at the command line.
|
|
125
|
+
def groups
|
|
126
|
+
@groups ||= registry_backed? ? registry_groups : []
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def group(slug)
|
|
130
|
+
groups.find { |candidate| candidate.slug == slug }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def active
|
|
134
|
+
entry(@active_slug)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# The active bundle's Model, or nil when nothing is loadable. Every
|
|
138
|
+
# single-bundle view reads through this.
|
|
139
|
+
def model
|
|
140
|
+
active&.model
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def switch(slug)
|
|
144
|
+
return false unless entry(slug)&.loaded?
|
|
145
|
+
|
|
146
|
+
@active_slug = slug
|
|
147
|
+
true
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# ── scope: which bundles a search covers ────────────────────────────────
|
|
151
|
+
|
|
152
|
+
# Kept in workspace order and filtered to slugs that still exist, so a
|
|
153
|
+
# reload after a remove cannot leave a stale slug in the scope.
|
|
154
|
+
def scope
|
|
155
|
+
entries.map(&:slug).select { |slug| @scope.include?(slug) }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def scoped?(slug)
|
|
159
|
+
@scope.include?(slug)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def toggle_scope(slug)
|
|
163
|
+
@scope.include?(slug) ? @scope.delete(slug) : @scope << slug
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def scope_all
|
|
167
|
+
@scope = entries.map(&:slug)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def scope_none
|
|
171
|
+
@scope = []
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def scope_only(slug)
|
|
175
|
+
@scope = [ slug ]
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Scope the search to a group — the reason a group is worth showing here at
|
|
179
|
+
# all. `okf search @mkt` merges exactly these bundles into one ranking; this is
|
|
180
|
+
# the same set, named the same way, reached with one key instead of retyping
|
|
181
|
+
# the members.
|
|
182
|
+
#
|
|
183
|
+
# Returns a message for the status line, like the config writes do, because
|
|
184
|
+
# each of the three ways this can decline to do anything needs saying: a
|
|
185
|
+
# scope that silently did not change reads as a broken key.
|
|
186
|
+
def scope_group(slug)
|
|
187
|
+
group = group(slug)
|
|
188
|
+
return "no such group: @#{slug}" if group.nil?
|
|
189
|
+
return "@#{slug} names a cycle in #{registry.path} — okf cannot resolve it" if group.cyclic?
|
|
190
|
+
return "@#{slug} resolves to no bundle here" if group.bundles.empty?
|
|
191
|
+
|
|
192
|
+
# A copy: `toggle_scope` mutates the scope in place, and the group is a
|
|
193
|
+
# description of the registry, not a scratch list.
|
|
194
|
+
@scope = group.bundles.dup
|
|
195
|
+
"search scope: @#{slug} — #{group.size} #{group.size == 1 ? "bundle" : "bundles"}"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Ranked search across every scoped bundle, merged into one ordered list.
|
|
199
|
+
# They share one index on purpose: BM25 weighs a term by how rare it is in
|
|
200
|
+
# the corpus, so per-bundle indexes would score the same match differently
|
|
201
|
+
# depending on which bundle it came from. One index makes one corpus — the
|
|
202
|
+
# same thing `okf search @all` does.
|
|
203
|
+
#
|
|
204
|
+
# The mode is what chooses okf's engine, by declaring the capability the query
|
|
205
|
+
# needs rather than by naming an engine:
|
|
206
|
+
#
|
|
207
|
+
# :fuzzy `fuzzy: true` → the full-text index. Ranked BM25, typo-tolerant.
|
|
208
|
+
# :text nothing → the scan, okf's own default. Raw substring.
|
|
209
|
+
# :regexp `regexp: true` → the scan, as a pattern.
|
|
210
|
+
#
|
|
211
|
+
# All three are reachable because the index and the scan disagree *by design*,
|
|
212
|
+
# and each is wrong for what the other is right for. okf documents the index's
|
|
213
|
+
# limits precisely: its tokenizer splits on punctuation, so `7.2.0` indexes as
|
|
214
|
+
# `7`, `2`, `0`, and a backtick is not punctuation, so a word inside a code span
|
|
215
|
+
# indexes as `` `minifts` `` and the query `minifts` does not match it. Measured
|
|
216
|
+
# on okf's own bundle, the index finds three of the five concepts that say
|
|
217
|
+
# minifts, and returns fourteen for OKF_HOME where the scan returns five.
|
|
218
|
+
# Offering only the index left the terms glued to symbols — a constant, an env
|
|
219
|
+
# var, a version — unfindable, with nothing on screen saying so.
|
|
220
|
+
def search(query, mode: :fuzzy)
|
|
221
|
+
@search_error = nil
|
|
222
|
+
terms = query.to_s.split(/\s+/).reject(&:empty?)
|
|
223
|
+
return [] if terms.empty?
|
|
224
|
+
|
|
225
|
+
corpus = search_corpus
|
|
226
|
+
return [] if corpus.nil?
|
|
227
|
+
|
|
228
|
+
run_search(corpus, terms, mode)
|
|
229
|
+
rescue RegexpError => e
|
|
230
|
+
# A bad pattern is the user's typo, not a broken install, and it must not
|
|
231
|
+
# land in the blanket rescue below — "no matches" for an unparseable regexp
|
|
232
|
+
# is the silent-wrong-answer shape this view keeps having to avoid.
|
|
233
|
+
@search_error = "bad pattern: #{e.message}"
|
|
234
|
+
[]
|
|
235
|
+
rescue OKF::Bundle::Search::UnsupportedQuery => e
|
|
236
|
+
@search_error = e.message
|
|
237
|
+
[]
|
|
238
|
+
rescue StandardError
|
|
239
|
+
[]
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Set when a query could not be answered *and the reason is worth showing*.
|
|
243
|
+
# nil after any search that ran, so it never outlives the query it describes.
|
|
244
|
+
attr_reader :search_error
|
|
245
|
+
|
|
246
|
+
# ── config: every registry write lands here ─────────────────────────────
|
|
247
|
+
#
|
|
248
|
+
# Each returns a message for the status line and reloads, so what the screen
|
|
249
|
+
# shows next is what the file now says rather than what the in-memory list
|
|
250
|
+
# was talked into believing.
|
|
251
|
+
|
|
252
|
+
def add(dir)
|
|
253
|
+
return not_registry_backed unless registry_backed?
|
|
254
|
+
|
|
255
|
+
added = registry.add(File.expand_path(dir.to_s.strip))
|
|
256
|
+
reload
|
|
257
|
+
"registered @#{added.slug} → #{added.path}"
|
|
258
|
+
rescue OKF::Error => e
|
|
259
|
+
"could not add: #{e.message}"
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def remove(slug)
|
|
263
|
+
return not_registry_backed unless registry_backed?
|
|
264
|
+
|
|
265
|
+
registry.remove(slug)
|
|
266
|
+
reload
|
|
267
|
+
"removed @#{slug} from the registry (the bundle itself is untouched)"
|
|
268
|
+
rescue OKF::Error => e
|
|
269
|
+
"could not remove: #{e.message}"
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def make_default(slug)
|
|
273
|
+
return not_registry_backed unless registry_backed?
|
|
274
|
+
|
|
275
|
+
registry.default = slug
|
|
276
|
+
reload
|
|
277
|
+
"@#{slug} is now the default"
|
|
278
|
+
rescue OKF::Error => e
|
|
279
|
+
"could not set default: #{e.message}"
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def rename(old_slug, new_slug)
|
|
283
|
+
return not_registry_backed unless registry_backed?
|
|
284
|
+
|
|
285
|
+
new_slug = new_slug.to_s.strip
|
|
286
|
+
return "rename cancelled: no name given" if new_slug.empty?
|
|
287
|
+
|
|
288
|
+
renamed = registry.rename(old_slug, new_slug)
|
|
289
|
+
reload
|
|
290
|
+
"@#{old_slug} is now @#{renamed.slug}"
|
|
291
|
+
rescue OKF::Error => e
|
|
292
|
+
"could not rename: #{e.message}"
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# ── groups: the writes okf's `registry group` / `ungroup` make ──────────
|
|
296
|
+
#
|
|
297
|
+
# A group is registry configuration in exactly the sense a slug rename is, so
|
|
298
|
+
# it belongs on the same side of the line as the writes above — see
|
|
299
|
+
# [registry-write-boundary]. Members come from the *scope* rather than from a
|
|
300
|
+
# typed list: `◉` already means "these bundles" in this view, so the gesture is
|
|
301
|
+
# toggle what you want, then name it.
|
|
302
|
+
#
|
|
303
|
+
# okf owns the cascades. `set_group` creates-or-adds and refuses a cycle,
|
|
304
|
+
# `unset_group_members` deletes a group it empties, and `rename`/`remove` span
|
|
305
|
+
# a group slug and cascade through every member list — none of which is
|
|
306
|
+
# reimplemented here.
|
|
307
|
+
|
|
308
|
+
def create_group(slug, members)
|
|
309
|
+
return not_registry_backed unless registry_backed?
|
|
310
|
+
|
|
311
|
+
slug = slug.to_s.strip
|
|
312
|
+
return "group cancelled: no name given" if slug.empty?
|
|
313
|
+
return "group cancelled: no bundles in scope to name" if members.empty?
|
|
314
|
+
|
|
315
|
+
group = registry.set_group(slug, refs(members))
|
|
316
|
+
reload
|
|
317
|
+
"@#{group.slug} names #{count(members.length)}"
|
|
318
|
+
rescue OKF::Error => e
|
|
319
|
+
"could not create the group: #{e.message}"
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def add_to_group(slug, members)
|
|
323
|
+
return not_registry_backed unless registry_backed?
|
|
324
|
+
return "nothing in scope to add" if members.empty?
|
|
325
|
+
|
|
326
|
+
registry.set_group(slug, refs(members))
|
|
327
|
+
reload
|
|
328
|
+
# One member named, several counted: `+` adds the row under a cursor and the
|
|
329
|
+
# line has to say *which* row landed, while `c` adds a whole scope and naming
|
|
330
|
+
# every slug would be a list, not a message.
|
|
331
|
+
total = count(group(slug)&.size.to_i)
|
|
332
|
+
members.length == 1 ? "@#{members.first} joined @#{slug} — #{total}" : "@#{slug} now names #{total}"
|
|
333
|
+
rescue OKF::Error => e
|
|
334
|
+
"could not add to @#{slug}: #{e.message}"
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def remove_from_group(slug, members)
|
|
338
|
+
return not_registry_backed unless registry_backed?
|
|
339
|
+
return "nothing in scope to remove" if members.empty?
|
|
340
|
+
|
|
341
|
+
registry.unset_group_members(slug, refs(members))
|
|
342
|
+
reload
|
|
343
|
+
# okf deletes a group its last member left, so say so rather than leave the
|
|
344
|
+
# user looking for a row that is gone.
|
|
345
|
+
return "@#{slug} had nothing left, so it is gone" if group(slug).nil?
|
|
346
|
+
|
|
347
|
+
total = count(group(slug).size)
|
|
348
|
+
members.length == 1 ? "@#{members.first} left @#{slug} — #{total}" : "@#{slug} now names #{total}"
|
|
349
|
+
rescue OKF::Error => e
|
|
350
|
+
"could not remove from @#{slug}: #{e.message}"
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# Re-read from disk, keeping the active bundle and the scope wherever they
|
|
354
|
+
# still resolve.
|
|
355
|
+
def reload
|
|
356
|
+
previous_active = @active_slug
|
|
357
|
+
previous_scope = @scope
|
|
358
|
+
|
|
359
|
+
load_entries
|
|
360
|
+
|
|
361
|
+
@active_slug = previous_active if entry(previous_active)&.loaded?
|
|
362
|
+
@active_slug ||= first_loaded_slug
|
|
363
|
+
kept = previous_scope.select { |slug| entry(slug) }
|
|
364
|
+
@scope = kept.empty? ? entries.map(&:slug) : kept
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
private
|
|
368
|
+
|
|
369
|
+
def not_registry_backed
|
|
370
|
+
"these bundles were named on the command line — there is no registry to change"
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# Slugs as okf's group verbs take them. `@` is the ref grammar's own marker,
|
|
374
|
+
# and passing it keeps these calls readable as the CLI commands they mirror.
|
|
375
|
+
def refs(slugs)
|
|
376
|
+
slugs.map { |slug| "@#{slug}" }
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def count(total)
|
|
380
|
+
"#{total} #{total == 1 ? "bundle" : "bundles"}"
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# Explicit branches rather than a splatted options hash: three named calls read
|
|
384
|
+
# as the three engines they select, and the floor is Ruby 2.4.
|
|
385
|
+
def run_search(corpus, terms, mode)
|
|
386
|
+
case mode
|
|
387
|
+
when :regexp then OKF::Bundle::Search.with(corpus, terms, regexp: true)
|
|
388
|
+
when :text then OKF::Bundle::Search.with(corpus, terms)
|
|
389
|
+
else OKF::Bundle::Search.with(corpus, terms, fuzzy: true)
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def scoped_pairs
|
|
394
|
+
entries.select { |entry| entry.loaded? && scoped?(entry.slug) }
|
|
395
|
+
.map { |entry| [ entry.slug, entry.model.bundle ] }
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# The corpus behind the search view, held across queries.
|
|
399
|
+
#
|
|
400
|
+
# `Search.across` rebuilds everything per call — the documents *and* the
|
|
401
|
+
# full-text index — which is the right trade for the CLI it was measured for:
|
|
402
|
+
# one question, then the process exits. A TUI is the other case, the one okf
|
|
403
|
+
# 1.11.0 added `prepare`/`with` for and `okf server` already uses. Measured
|
|
404
|
+
# over five registered bundles, 129 concepts between them: **392 ms** for the
|
|
405
|
+
# first query, which builds the corpus, then **12–16 ms** for every one after.
|
|
406
|
+
# Rebuilding, every query cost the 392 ms.
|
|
407
|
+
#
|
|
408
|
+
# Built on first use rather than at load: with a whole registry open, a session
|
|
409
|
+
# that never searches should not pay to index every bundle nobody looked at —
|
|
410
|
+
# the same reason Model memoizes its analysis instead of computing it eagerly.
|
|
411
|
+
# No `engine:` for the same reason; that argument only moves the index build
|
|
412
|
+
# earlier, and there is no boot here to move it into.
|
|
413
|
+
#
|
|
414
|
+
# Keyed on the scoped slugs, and dropped outright by #load_entries. Both
|
|
415
|
+
# matter, and for different reasons: the key catches a scope the user changed,
|
|
416
|
+
# while the drop catches a reload, after which the models are freshly read
|
|
417
|
+
# objects and a corpus built from the old ones describes a bundle that may no
|
|
418
|
+
# longer be on disk. okf takes the same care in its hub, and for the stated
|
|
419
|
+
# reason — a held index outliving the set it was built from is a wrong answer
|
|
420
|
+
# rather than a slow one.
|
|
421
|
+
def search_corpus
|
|
422
|
+
pairs = scoped_pairs
|
|
423
|
+
return nil if pairs.empty?
|
|
424
|
+
|
|
425
|
+
key = pairs.map { |slug, _bundle| slug }
|
|
426
|
+
return @corpus if @corpus && @corpus_key == key
|
|
427
|
+
|
|
428
|
+
# Assigned only on success, so a failed build is retried rather than
|
|
429
|
+
# memoized as an empty result — #search rescues into "no matches", which is
|
|
430
|
+
# the wrong lasting answer for a corpus that could not be built.
|
|
431
|
+
corpus = OKF::Bundle::Search.prepare(pairs)
|
|
432
|
+
@corpus_key = key
|
|
433
|
+
@corpus = corpus
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
# Discovery runs once, on the first load; every reload after a write goes
|
|
437
|
+
# through Registry#reopen instead. That is okf's own instruction, and the
|
|
438
|
+
# reason is the relative paths a local registry stores: `Registry.new(path)`
|
|
439
|
+
# drops the +relative_base+ a discovered file carries, and then every in-tree
|
|
440
|
+
# bundle reads as "folder is gone". Re-discovering would find the same file,
|
|
441
|
+
# but reopening says what is meant — re-read this registry, anchored as it was.
|
|
442
|
+
def open_registry
|
|
443
|
+
OKF::Registry.load(home: home, cwd: @cwd)
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def load_entries
|
|
447
|
+
@registry = (registry ? registry.reopen : open_registry) if registry_backed?
|
|
448
|
+
@entries = registry_backed? ? registry_entries : ad_hoc_entries
|
|
449
|
+
# See #search_corpus: the entries about to be built are freshly read, so a
|
|
450
|
+
# corpus held over from the previous ones is describing bundles this session
|
|
451
|
+
# no longer has. The groups come off the same re-read registry, so a write
|
|
452
|
+
# that cascaded through a member list shows its result rather than the list
|
|
453
|
+
# memory remembers.
|
|
454
|
+
@corpus = nil
|
|
455
|
+
@corpus_key = nil
|
|
456
|
+
@groups = nil
|
|
457
|
+
@scope = @entries.map(&:slug)
|
|
458
|
+
@active_slug = first_loaded_slug
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
def first_loaded_slug
|
|
462
|
+
@entries.find(&:loaded?)&.slug
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
def registry_entries
|
|
466
|
+
registry.listing.map do |row|
|
|
467
|
+
build_entry(slug: row[:slug], dir: row[:dir], default: row[:default],
|
|
468
|
+
registered: true, missing: row[:missing])
|
|
469
|
+
end
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
# okf reports an unresolvable group by putting nil in `resolved` — that is its
|
|
473
|
+
# signal for a hand-edited cycle, which it declines to walk rather than loop
|
|
474
|
+
# on. Asking `expand` again for the leaf slugs is guarded the same way, since
|
|
475
|
+
# `groups_listing` swallowed the error to compute the count.
|
|
476
|
+
def registry_groups
|
|
477
|
+
registry.groups_listing.map do |row|
|
|
478
|
+
bundles = begin
|
|
479
|
+
row[:resolved] && registry.expand(row[:slug]).map(&:slug)
|
|
480
|
+
rescue OKF::Error
|
|
481
|
+
nil
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
Group.new(slug: row[:slug], members: row[:members], bundles: bundles || [],
|
|
485
|
+
cyclic: bundles.nil?)
|
|
486
|
+
end
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
# Ad-hoc directories are slugged the way the server slugs its own ephemeral
|
|
490
|
+
# mounts, and deduped, so two directories sharing a basename stay distinct.
|
|
491
|
+
#
|
|
492
|
+
# A directory that arrived as an @ref keeps the slug it is registered under.
|
|
493
|
+
# Deriving it from the basename instead would name it `.okf` — the conventional
|
|
494
|
+
# container, and therefore the basename of nearly every registered bundle — so
|
|
495
|
+
# `okf-tui @okf-site @okf-mkt` would list @okf and @okf-2, having discarded both
|
|
496
|
+
# names that were typed. okf hit exactly this in its hub and fixed it the same
|
|
497
|
+
# way. Still deduped: a slug is unique in a registry, but a ref-named bundle and
|
|
498
|
+
# a plain directory in the same argv can still collide.
|
|
499
|
+
def ad_hoc_entries
|
|
500
|
+
taken = []
|
|
501
|
+
@dirs.each_with_index.map do |dir, index|
|
|
502
|
+
expanded = File.expand_path(dir)
|
|
503
|
+
registered = @ref_slugs[expanded]
|
|
504
|
+
slug = OKF::Registry.dedupe(registered || File.basename(expanded), taken) # dedupe slugifies
|
|
505
|
+
taken << slug
|
|
506
|
+
build_entry(slug: slug, dir: expanded, default: index.zero?,
|
|
507
|
+
registered: !registered.nil?, missing: !File.directory?(expanded))
|
|
508
|
+
end
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
def build_entry(slug:, dir:, default:, registered:, missing:)
|
|
512
|
+
entry = Entry.new(slug: slug, dir: dir, default: default, registered: registered)
|
|
513
|
+
|
|
514
|
+
if missing
|
|
515
|
+
entry.error = "directory is gone"
|
|
516
|
+
else
|
|
517
|
+
begin
|
|
518
|
+
entry.model = Model.new(dir, slug: slug)
|
|
519
|
+
rescue StandardError => e
|
|
520
|
+
entry.error = e.message
|
|
521
|
+
end
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
entry
|
|
525
|
+
end
|
|
526
|
+
end
|
|
527
|
+
end
|
data/lib/okf/tui.rb
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "okf"
|
|
4
|
+
|
|
5
|
+
require_relative "tui/version"
|
|
6
|
+
|
|
7
|
+
module OKF
|
|
8
|
+
# A terminal UI over OKF bundles: read one, switch between many, configure the
|
|
9
|
+
# registry, and search across all of them at once.
|
|
10
|
+
#
|
|
11
|
+
# `require "okf/tui"` loads the library only — the model, the workspace, and
|
|
12
|
+
# the screens. The argv-facing shell (OKF::TUI::CLI, and its option parsing)
|
|
13
|
+
# loads on demand: `okf tui` requires it from inside the plugin's #call, and so
|
|
14
|
+
# must any test that drives it. An embedding app never pays for the
|
|
15
|
+
# command-line machinery, and neither does an `okf lint` that merely made okf
|
|
16
|
+
# read this gem's plugin file.
|
|
17
|
+
#
|
|
18
|
+
# The split okf enforces between its pure core and its shell is what makes this
|
|
19
|
+
# small: OKF::Bundle::Reader and OKF::Registry are the only parts that touch
|
|
20
|
+
# disk, and every answer on screen is a pure call on the resulting in-memory
|
|
21
|
+
# bundles — catalog, graph, validate, lint, Bundle::Search. The TUI is one more
|
|
22
|
+
# shell over the same core the `okf` CLI and the graph server already use, and
|
|
23
|
+
# it invents no analysis of its own.
|
|
24
|
+
module TUI
|
|
25
|
+
class Error < OKF::Error
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# What search needs from okf, checked rather than assumed: the engine facade
|
|
29
|
+
# that merges several bundles into one ranked corpus (`across`, okf 1.9) and
|
|
30
|
+
# the prepared corpus a long-lived caller queries instead of rebuilding
|
|
31
|
+
# (`prepare`/`with`, okf 1.11).
|
|
32
|
+
#
|
|
33
|
+
# The gemspec's floor already requires both, so this is not about an old
|
|
34
|
+
# dependency — it is about a *second* okf installed ahead of the intended one
|
|
35
|
+
# on the load path, which no version constraint can prevent.
|
|
36
|
+
#
|
|
37
|
+
# It earns a check of its own because its absence is silent. Workspace#search
|
|
38
|
+
# rescues a failed search into an empty result — right for a query okf cannot
|
|
39
|
+
# parse, wrong for a method that is not there, because then every search
|
|
40
|
+
# answers "no matches" and the screen reads as an empty bundle rather than a
|
|
41
|
+
# broken install. The CLI refuses to boot instead of showing that screen.
|
|
42
|
+
def self.search_capable?
|
|
43
|
+
%i[across prepare with].all? { |name| OKF::Bundle::Search.respond_to?(name) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# What §5 needs from okf, on the same argument and for a sharper reason.
|
|
47
|
+
#
|
|
48
|
+
# Every screen here now reads the v0.2 families — a concept's provenance in
|
|
49
|
+
# browse, the spec version and the trust/status posture on health, two facets
|
|
50
|
+
# in the graph — and each of them is a question only okf can answer:
|
|
51
|
+
# `Bundle#okf_version` for what the bundle declares, and
|
|
52
|
+
# `Bundle::RowFilter.shows_trust?` for whether a derived tier is one to claim.
|
|
53
|
+
#
|
|
54
|
+
# Against an okf without them the failure is a NoMethodError from inside a
|
|
55
|
+
# frame, which is a crash where the screen should have been. Refusing at boot
|
|
56
|
+
# says which gem is wrong instead, and says it once.
|
|
57
|
+
def self.spec_capable?
|
|
58
|
+
OKF::Bundle.method_defined?(:okf_version) &&
|
|
59
|
+
defined?(OKF::Bundle::RowFilter) &&
|
|
60
|
+
OKF::Bundle::RowFilter.respond_to?(:shows_trust?)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# ── layout: the primitives every screen draws through ──
|
|
64
|
+
require_relative "tui/ui"
|
|
65
|
+
|
|
66
|
+
# ── domain: one bundle, and the set of them a session can see ──
|
|
67
|
+
require_relative "tui/model"
|
|
68
|
+
require_relative "tui/workspace"
|
|
69
|
+
|
|
70
|
+
# ── screens: pure row builders — no view writes to the terminal ──
|
|
71
|
+
require_relative "tui/views"
|
|
72
|
+
|
|
73
|
+
# ── shell: the interactive loop ──
|
|
74
|
+
require_relative "tui/app"
|
|
75
|
+
end
|
|
76
|
+
end
|