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
data/lib/okf/tui/app.rb
ADDED
|
@@ -0,0 +1,1908 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tty-cursor"
|
|
4
|
+
require "tty-reader"
|
|
5
|
+
require "tty-screen"
|
|
6
|
+
require "tty-markdown"
|
|
7
|
+
|
|
8
|
+
require_relative "ui"
|
|
9
|
+
require_relative "model"
|
|
10
|
+
require_relative "views"
|
|
11
|
+
|
|
12
|
+
module OKF::TUI
|
|
13
|
+
# The interactive loop: hold the state, read a key, mutate the state, repaint.
|
|
14
|
+
#
|
|
15
|
+
# Painting is whole-frame. Every view returns a rectangle of rows already
|
|
16
|
+
# padded to the terminal width, so a repaint is "cursor home, print N rows" —
|
|
17
|
+
# each row overwrites the one beneath it exactly, which is what keeps the
|
|
18
|
+
# screen from flickering without any diffing machinery.
|
|
19
|
+
#
|
|
20
|
+
# Two axes of state, and keeping them apart is what makes the views simple:
|
|
21
|
+
# the **active bundle** (what browse, health and graph are about) and the
|
|
22
|
+
# **scope** (which bundles a search covers). Only the bundles view moves the
|
|
23
|
+
# first; only the scope keys move the second.
|
|
24
|
+
class App
|
|
25
|
+
# Control keys, named so the source carries no invisible bytes.
|
|
26
|
+
CTRL_C = "\u0003"
|
|
27
|
+
CTRL_D = "\u0004"
|
|
28
|
+
CTRL_U = "\u0015"
|
|
29
|
+
DELETE = "\u007F"
|
|
30
|
+
# Joins a group slug to one of its members inside a prompt's subject. A NUL can
|
|
31
|
+
# appear in neither, which is why okf uses the same byte to key its own corpus.
|
|
32
|
+
MEMBER_REF = "\u0000"
|
|
33
|
+
ESCAPE = "\e"
|
|
34
|
+
TAB = "\t"
|
|
35
|
+
UP = "\e[A"
|
|
36
|
+
DOWN = "\e[B"
|
|
37
|
+
|
|
38
|
+
# The views that are about one bundle, and so have nothing to show when
|
|
39
|
+
# none can be read.
|
|
40
|
+
SINGLE_BUNDLE_VIEWS = %i[browse health graph].freeze
|
|
41
|
+
|
|
42
|
+
# The views that are a single scrolling page rather than a selectable list.
|
|
43
|
+
CONTENT_VIEWS = %i[health help].freeze
|
|
44
|
+
|
|
45
|
+
# Six, and `help` stays last where a reader expects it. There was a seventh —
|
|
46
|
+
# an `index` map beside `browse` — and it went because browse answers the same
|
|
47
|
+
# question with a key the reader already has: `/index` lists every way in, one
|
|
48
|
+
# per directory, and opens the authored file. What the map could show and browse
|
|
49
|
+
# cannot is real but narrow (the tree indented as it nests, `▪`/`·` for a
|
|
50
|
+
# directory that authored no index.md, okf's synthesized listing for one, and
|
|
51
|
+
# the per-directory type/tag rollups), and it did not earn a permanent tab.
|
|
52
|
+
TABS = [
|
|
53
|
+
[ :bundles, "bundles" ],
|
|
54
|
+
[ :browse, "browse" ],
|
|
55
|
+
[ :search, "search" ],
|
|
56
|
+
[ :graph, "graph" ],
|
|
57
|
+
[ :health, "health" ],
|
|
58
|
+
[ :help, "help" ]
|
|
59
|
+
].freeze
|
|
60
|
+
|
|
61
|
+
KEY_VIEWS = { "1" => :bundles, "2" => :browse, "3" => :search,
|
|
62
|
+
"4" => :graph, "5" => :health, "6" => :help }.freeze
|
|
63
|
+
|
|
64
|
+
# The views with a list to narrow. `/` starts typing in each of them; the
|
|
65
|
+
# filter belongs to the view, so switching away drops it rather than
|
|
66
|
+
# carrying a bundle filter into the concept list.
|
|
67
|
+
FILTERABLE_VIEWS = %i[bundles browse graph].freeze
|
|
68
|
+
|
|
69
|
+
# A pending question on the status line. `kind` decides what the answer does;
|
|
70
|
+
# `free_text` says whether it collects a line or a single confirming key.
|
|
71
|
+
class Prompt
|
|
72
|
+
attr_reader :kind, :label, :buffer, :subject
|
|
73
|
+
|
|
74
|
+
def initialize(kind:, label:, buffer:, subject:, free_text:)
|
|
75
|
+
@kind = kind
|
|
76
|
+
@label = label
|
|
77
|
+
@buffer = buffer
|
|
78
|
+
@subject = subject
|
|
79
|
+
@free_text = free_text
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def free_text?
|
|
83
|
+
@free_text
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def initialize(dirs: [], ref_slugs: {}, home: nil, cwd: nil, output: $stdout)
|
|
88
|
+
@output = output
|
|
89
|
+
@workspace = Workspace.new(dirs: dirs, ref_slugs: ref_slugs, home: home, cwd: cwd)
|
|
90
|
+
# With one readable bundle there is nothing to pick between, so open on it
|
|
91
|
+
# directly. Otherwise start on the list — including when nothing is
|
|
92
|
+
# readable, where the list is the only place the problem can be seen.
|
|
93
|
+
@view = @workspace.entries.length == 1 && @workspace.model ? :browse : :bundles
|
|
94
|
+
@pane = :list
|
|
95
|
+
@cursor = 0
|
|
96
|
+
@filter = +""
|
|
97
|
+
@query = +""
|
|
98
|
+
@searched = +"" # the query the current results belong to; see #search_hits
|
|
99
|
+
@graph_facet = nil
|
|
100
|
+
@search_focus = :results
|
|
101
|
+
@search_mode = :fuzzy
|
|
102
|
+
@find = +"" # find-in-document, when the detail pane has focus
|
|
103
|
+
@finding = false
|
|
104
|
+
@find_index = 0
|
|
105
|
+
@find_jump = false
|
|
106
|
+
@filtering = false
|
|
107
|
+
@following = false # the link picker, over the document in the detail pane
|
|
108
|
+
@follow_cursor = 0
|
|
109
|
+
@follow_scroll = 0
|
|
110
|
+
@group_cursor = 0 # the groups pane
|
|
111
|
+
@group_scroll = 0
|
|
112
|
+
@member_cursor = 0 # the member pane
|
|
113
|
+
@trail = [] # where the reader was before each jump
|
|
114
|
+
@prompt = nil
|
|
115
|
+
@scroll = 0
|
|
116
|
+
@detail_scroll = 0
|
|
117
|
+
@content_scroll = 0
|
|
118
|
+
# health's summary pane, which scrolls apart from its findings
|
|
119
|
+
@health_scroll = 0
|
|
120
|
+
@body_cache = {}
|
|
121
|
+
@message = nil
|
|
122
|
+
@running = true
|
|
123
|
+
@quit_armed = false
|
|
124
|
+
@cursor_control = TTY::Cursor
|
|
125
|
+
@reader = TTY::Reader.new(interrupt: :exit, track_history: false)
|
|
126
|
+
reset_cursor
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# The active bundle. Nil only when nothing in the workspace could be read.
|
|
130
|
+
def model
|
|
131
|
+
workspace.model
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def run
|
|
135
|
+
@output.print @cursor_control.hide + @cursor_control.clear_screen
|
|
136
|
+
paint
|
|
137
|
+
loop do
|
|
138
|
+
key = @reader.read_keypress(nonblock: false)
|
|
139
|
+
break unless key
|
|
140
|
+
|
|
141
|
+
handle(key)
|
|
142
|
+
break unless @running
|
|
143
|
+
|
|
144
|
+
paint
|
|
145
|
+
end
|
|
146
|
+
ensure
|
|
147
|
+
@output.print @cursor_control.show + @cursor_control.clear_screen + @cursor_control.move_to(0, 0)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# ── frame ────────────────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
def paint
|
|
153
|
+
width = TTY::Screen.width
|
|
154
|
+
height = TTY::Screen.height
|
|
155
|
+
|
|
156
|
+
header = Views.header(self, width)
|
|
157
|
+
footer = [ Views.footer(self, width) ]
|
|
158
|
+
body_height = [ height - header.length - footer.length - 1, 3 ].max
|
|
159
|
+
|
|
160
|
+
# browse, health and graph are all about *a* bundle. With none readable
|
|
161
|
+
# there is nothing for them to render, so the list stands in — it is the
|
|
162
|
+
# one view that can still show what went wrong.
|
|
163
|
+
view = model.nil? && SINGLE_BUNDLE_VIEWS.include?(@view) ? :bundles : @view
|
|
164
|
+
|
|
165
|
+
body =
|
|
166
|
+
case view
|
|
167
|
+
when :bundles then Views.bundles(self, width, body_height)
|
|
168
|
+
when :search then Views.search(self, width, body_height)
|
|
169
|
+
when :health then Views.health(self, width, body_height)
|
|
170
|
+
when :graph then Views.graph(self, width, body_height)
|
|
171
|
+
when :help then Views.help(self, width, body_height)
|
|
172
|
+
else Views.browse(self, width, body_height)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
frame = header + Ui.fit_block(body, width: width, height: body_height) + [ message_row(width) ] + footer
|
|
176
|
+
|
|
177
|
+
@output.print @cursor_control.move_to(0, 0)
|
|
178
|
+
@output.print frame.first(height).join("\n")
|
|
179
|
+
@output.flush
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def message_row(width)
|
|
183
|
+
Ui.line(width) do |row|
|
|
184
|
+
if @prompt
|
|
185
|
+
row.add(" #{@prompt.label} ", :black, :on_yellow, :bold)
|
|
186
|
+
row.add(" #{@prompt.buffer}", :bright_white, :bold)
|
|
187
|
+
row.add("▏", :yellow, :bold) if @prompt.free_text?
|
|
188
|
+
elsif @finding
|
|
189
|
+
row.add(" find: ", :black, :on_yellow, :bold)
|
|
190
|
+
row.add(" #{@find}", :bright_white, :bold)
|
|
191
|
+
row.add("▏", :yellow, :bold)
|
|
192
|
+
elsif @filtering
|
|
193
|
+
row.add(" filter: ", :black, :on_yellow, :bold)
|
|
194
|
+
row.add(" #{@filter}", :bright_white, :bold)
|
|
195
|
+
row.add("▏", :yellow, :bold)
|
|
196
|
+
elsif find_status
|
|
197
|
+
row.add(" #{find_status}", @find_total.to_i.zero? ? :yellow : :bright_black)
|
|
198
|
+
elsif @message
|
|
199
|
+
# Flash, in the strict sense: #handle clears it on the very next key, so
|
|
200
|
+
# this line is on screen only until the user does anything at all. That is
|
|
201
|
+
# what makes a strong mark right here rather than shouting — it cannot
|
|
202
|
+
# become permanent noise, and :bright_black had made the one line that
|
|
203
|
+
# reports what just happened the quietest thing on the screen.
|
|
204
|
+
#
|
|
205
|
+
# Cyan, not the yellow the prompt, find and filter lines wear. This row has
|
|
206
|
+
# two states — it is *asking* you something, or it is *telling* you
|
|
207
|
+
# something — and one colour each keeps them apart without reading a word.
|
|
208
|
+
row.add(" #{@message} ", :black, :on_cyan, :bold)
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def status_hints
|
|
214
|
+
base =
|
|
215
|
+
case @view
|
|
216
|
+
when :bundles
|
|
217
|
+
if filter_found_nothing?
|
|
218
|
+
[ [ "↵", "search all bundles" ], [ "Esc", "clear filter" ] ]
|
|
219
|
+
elsif @pane == :members
|
|
220
|
+
[ [ "↑↓", "pick a member" ], [ "-", "remove it" ], [ "Esc", "back to the groups" ] ]
|
|
221
|
+
elsif @pane == :groups
|
|
222
|
+
[ [ "↵", "scope to it" ], [ "↑↓", "pick a group" ], [ "Tab", "its members" ],
|
|
223
|
+
[ "n", "rename" ], [ "x", "delete" ], [ "Esc", "back to the bundles" ] ]
|
|
224
|
+
else
|
|
225
|
+
# The scope comes before the config keys, and `A`/`N` beside `space`. It
|
|
226
|
+
# is one of the two axes this view is *about*, and the footer truncates:
|
|
227
|
+
# at 80 columns everything from `d` rightward is already off screen, so
|
|
228
|
+
# what the prefix names is the whole of what a narrow terminal teaches.
|
|
229
|
+
# Reordered after "it is not clear how to control the scope" — which was
|
|
230
|
+
# true, since neither `A` nor `N` appeared here at all.
|
|
231
|
+
#
|
|
232
|
+
#
|
|
233
|
+
# `+` sits inside that prefix, and `a add` gave up the slot for it:
|
|
234
|
+
# of the two keys a reader would have called "add", only one of them adds
|
|
235
|
+
# to a group, and registering a directory is a once-per-bundle act that
|
|
236
|
+
# the empty registry and view 6 both still teach.
|
|
237
|
+
#
|
|
238
|
+
# It does not name the group. The groups pane is right below with its own
|
|
239
|
+
# cursor on the row, and spelling the slug out here read as a claim about
|
|
240
|
+
# something the footer could not point at.
|
|
241
|
+
[ [ "↵", "open" ], [ "space", "scope" ], [ "A/N", "all/none" ] ] +
|
|
242
|
+
(selected_group ? [ [ "+", "join group" ] ] : []) +
|
|
243
|
+
[ [ "/", "filter" ], [ "d", "default" ], [ "n", "rename" ], [ "x", "remove" ],
|
|
244
|
+
[ "c", "group" ] ]
|
|
245
|
+
end
|
|
246
|
+
when :browse
|
|
247
|
+
if @following
|
|
248
|
+
[ [ "↑↓", "pick" ], [ "1-9", "jump straight to one" ], [ "↵", "follow" ],
|
|
249
|
+
[ "Esc", "back to the body" ] ]
|
|
250
|
+
elsif filter_found_nothing?
|
|
251
|
+
[ [ "↵", "search all bundles" ], [ "Esc", "clear filter" ] ]
|
|
252
|
+
elsif reading_body?
|
|
253
|
+
# In the body, `/` looks through the document rather than the list.
|
|
254
|
+
[ [ "↑↓ J/K", "scroll" ], [ "Tab", "pane" ], [ "/", "find in page" ], [ "f", "links" ] ] +
|
|
255
|
+
(@find.empty? ? [] : [ [ "n/N", "next/prev match" ] ])
|
|
256
|
+
else
|
|
257
|
+
[ [ "↑↓", "move" ], [ "Tab", "pane" ], [ "J/K", "scroll body" ], [ "/", "filter" ], [ "f", "links" ] ]
|
|
258
|
+
end
|
|
259
|
+
when :search
|
|
260
|
+
# Both Enter and the whole key set change with focus, so the hints do too.
|
|
261
|
+
if editing_query?
|
|
262
|
+
# `e` deliberately does not cycle the mode here — it is a letter, and
|
|
263
|
+
# every letter belongs to the query while the field has focus. Esc
|
|
264
|
+
# first, which the hint says.
|
|
265
|
+
[ [ "type", "query" ], [ "↵", search_pending? ? "search" : "open" ],
|
|
266
|
+
[ "Esc", "stop editing (then e changes the mode)" ] ]
|
|
267
|
+
elsif @searched.empty?
|
|
268
|
+
# Nothing searched yet, so there is nothing to navigate — the only
|
|
269
|
+
# moves that matter are starting one, and choosing how it is asked.
|
|
270
|
+
[ [ "/", "search" ], [ "e", search_mode_label ] ]
|
|
271
|
+
else
|
|
272
|
+
[ [ "↑↓", "results" ], [ "↵", "open" ], [ "/", "edit query" ], [ "e", search_mode_label ] ]
|
|
273
|
+
end
|
|
274
|
+
when :graph
|
|
275
|
+
act = @pane == :detail ? "open concept" : "narrow by facet"
|
|
276
|
+
hints = [ [ "↑↓", "move" ], [ "Tab", "pane" ], [ "↵", act ], [ "/", "filter" ] ]
|
|
277
|
+
hints + (@graph_facet ? [ [ "Esc", "clear facet" ] ] : [])
|
|
278
|
+
when :health
|
|
279
|
+
# Two panes, each scrolling on its own, so Tab names the one the keys move.
|
|
280
|
+
# A narrow terminal shows them one at a time and Tab swaps which — same key,
|
|
281
|
+
# same meaning, a layout that stops pretending both fit.
|
|
282
|
+
[ [ "↑↓", "scroll" ], [ "Tab", @pane == :detail ? "the findings" : "the standing" ],
|
|
283
|
+
[ "/", "find" ] ] +
|
|
284
|
+
(@find.empty? ? [] : [ [ "n/N", "next/prev match" ] ])
|
|
285
|
+
else
|
|
286
|
+
[ [ "↑↓", "scroll" ], [ "/", "find" ] ] +
|
|
287
|
+
(@find.empty? ? [] : [ [ "n/N", "next/prev match" ] ])
|
|
288
|
+
end
|
|
289
|
+
# Only offered once there is somewhere to go back to, so the row does not
|
|
290
|
+
# advertise a key that would do nothing.
|
|
291
|
+
base += [ [ "⌫", "back" ] ] unless @trail.empty?
|
|
292
|
+
base + [ %w[1-6 views], %w[r reload], %w[qq quit] ]
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# ── bundles view state ───────────────────────────────────────────────────
|
|
296
|
+
|
|
297
|
+
# The registry rows surviving the filter, matched on slug or path — the two
|
|
298
|
+
# things a row shows that a user would type.
|
|
299
|
+
def visible_entries
|
|
300
|
+
return workspace.entries if @filter.empty?
|
|
301
|
+
|
|
302
|
+
needle = @filter.downcase
|
|
303
|
+
workspace.entries.select do |entry|
|
|
304
|
+
"#{entry.slug} #{entry.dir}".downcase.include?(needle)
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# ── the bundles view's three panes ───────────────────────────────────────
|
|
309
|
+
#
|
|
310
|
+
# bundles (top left), groups (bottom left), and the detail of whichever of the
|
|
311
|
+
# two has focus (right). Tab cycles; each pane keeps its own cursor.
|
|
312
|
+
#
|
|
313
|
+
# It was one interleaved list under a GROUPS heading, and the shape caused three
|
|
314
|
+
# separate problems. The heading was a row nothing could select, so the cursor
|
|
315
|
+
# had to skip it. A long registry pushed the groups below the fold, so they were
|
|
316
|
+
# not reliably on screen at all. And with only one selection at a time, adding a
|
|
317
|
+
# bundle to a group had nothing to name the bundle *with* — which is why `+`
|
|
318
|
+
# reached for the search scope, and why it was the key that kept being wrong.
|
|
319
|
+
#
|
|
320
|
+
# Two selections make `+` direct: the bundle under the cursor joins the group
|
|
321
|
+
# that is selected. Nothing about searching is involved.
|
|
322
|
+
PANES = %i[bundles groups members].freeze
|
|
323
|
+
|
|
324
|
+
def bundles_pane?
|
|
325
|
+
@view == :bundles && @pane == :bundles
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def groups_pane?
|
|
329
|
+
@view == :bundles && @pane == :groups
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def member_pane?
|
|
333
|
+
@view == :bundles && @pane == :members && !selected_group.nil?
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# Which of the two left panes the detail pane is describing. The members pane is
|
|
337
|
+
# a view *of* a group, so it counts as the groups side.
|
|
338
|
+
def detailing_group?
|
|
339
|
+
@view == :bundles && %i[groups members].include?(@pane) && !selected_group.nil?
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# Groups surviving the filter, matched on the group's own name, on its members
|
|
343
|
+
# as written, and on the bundles it resolves to.
|
|
344
|
+
#
|
|
345
|
+
# All three, because the resolved set is what the filter is usually asking
|
|
346
|
+
# about — "which named scope covers @nested?" — and for a nested group that
|
|
347
|
+
# bundle appears nowhere in the member list. Matching only the members would
|
|
348
|
+
# answer @docs and hide @everything, which covers @nested just as much.
|
|
349
|
+
def visible_groups
|
|
350
|
+
groups = workspace.groups
|
|
351
|
+
return groups if @filter.empty?
|
|
352
|
+
|
|
353
|
+
needle = @filter.downcase
|
|
354
|
+
groups.select do |group|
|
|
355
|
+
haystack = [ group.slug, group.members.join(" "), group.bundles.join(" ") ].join(" ")
|
|
356
|
+
haystack.downcase.include?(needle)
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# Each pane indexes its own *filtered* list, so every action lands on the row
|
|
361
|
+
# the user is looking at.
|
|
362
|
+
def selected_entry
|
|
363
|
+
visible_entries[@cursor]
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def selected_group
|
|
367
|
+
visible_groups[@group_cursor]
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# ── the member pane ──────────────────────────────────────────────────────
|
|
371
|
+
#
|
|
372
|
+
# A group's members are listed in the detail pane, so Tab moves into them and
|
|
373
|
+
# `-` removes the one under the cursor. That is the same Tab the browse view has
|
|
374
|
+
# always used, and it replaced a `-` on the group row that acted on the
|
|
375
|
+
# *intersection* of the scope and the member list — a set with no row on screen,
|
|
376
|
+
# which a reader had to compute to predict what the key would do.
|
|
377
|
+
#
|
|
378
|
+
# Adding stays on the group row, because the bundles to add are the ones marked
|
|
379
|
+
# `◉` in the list beside it. Removing is the direction with something to point at.
|
|
380
|
+
def group_members
|
|
381
|
+
selected_group&.members || []
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def selected_member
|
|
385
|
+
group_members[@member_cursor]
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def scoped?(slug)
|
|
389
|
+
workspace.scoped?(slug)
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# Whether the scope in force *is* this group's set — set equality rather than
|
|
393
|
+
# overlap. A group's ◉ says "a search right now covers exactly these", so
|
|
394
|
+
# toggling one member off has to clear it; treating a superset as a match would
|
|
395
|
+
# leave two groups both claiming to be in force.
|
|
396
|
+
# The group under the groups cursor, when this bundle is one of its members —
|
|
397
|
+
# which is what decides whether the detail pane has a `+` to offer. Direct
|
|
398
|
+
# members only, deliberately: a bundle reached through a *nested* group is in the
|
|
399
|
+
# set a search covers but not in the list `+` would change.
|
|
400
|
+
def member_group(slug)
|
|
401
|
+
group = selected_group
|
|
402
|
+
group if group&.members&.include?(slug)
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def group_in_scope?(group)
|
|
406
|
+
return false if group.bundles.empty?
|
|
407
|
+
|
|
408
|
+
workspace.scope.sort == group.bundles.sort
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
# ── browse list state ────────────────────────────────────────────────────
|
|
412
|
+
|
|
413
|
+
# Rows surviving the filter, in catalog order.
|
|
414
|
+
def filtered_rows
|
|
415
|
+
return [] unless model
|
|
416
|
+
return model.rows if @filter.empty?
|
|
417
|
+
|
|
418
|
+
needle = @filter.downcase
|
|
419
|
+
model.rows.select do |row|
|
|
420
|
+
[ row[:id], row[:title], row[:type], Array(row[:tags]).join(" ") ]
|
|
421
|
+
.join(" ").downcase.include?(needle)
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
# The browse list as a flat sequence of directory headings and concept rows. The
|
|
426
|
+
# headings are entries too, so windowing and the cursor share one index
|
|
427
|
+
# space — but only concept entries are selectable, which `move` enforces.
|
|
428
|
+
def list_entries
|
|
429
|
+
@list_entries_key ||= nil
|
|
430
|
+
# `dup` matters: @filter is mutated in place as the user types, so keying
|
|
431
|
+
# on the object itself would compare a key against its own later state and
|
|
432
|
+
# the cache would never invalidate. The active bundle is part of the key
|
|
433
|
+
# too, or switching bundles would keep showing the previous ones list.
|
|
434
|
+
key = [ @filter.dup, workspace.active_slug ]
|
|
435
|
+
return @list_entries if @list_entries_key == key
|
|
436
|
+
|
|
437
|
+
entries = []
|
|
438
|
+
browse_groups.each do |dir, group|
|
|
439
|
+
entries << { kind: :dir, label: dir == "." ? "(root)" : dir, count: group[:concepts].length }
|
|
440
|
+
group[:reserved].each { |path| entries << { kind: :reserved, path: path } }
|
|
441
|
+
group[:concepts].each { |row| entries << { kind: :concept, row: row } }
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
@list_entries_key = key
|
|
445
|
+
@list_entries = entries
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
# The bundle as it sits on disk: each directory in turn, its index.md first,
|
|
449
|
+
# then its log.md, then its concepts — and the root before anything nested,
|
|
450
|
+
# since that is the order someone reads a bundle in (§6 makes index.md the
|
|
451
|
+
# way in). Sorting by path puts every subfolder directly under its parent.
|
|
452
|
+
def browse_groups
|
|
453
|
+
return {} unless model
|
|
454
|
+
|
|
455
|
+
groups = Hash.new { |hash, dir| hash[dir] = { reserved: [], concepts: [] } }
|
|
456
|
+
|
|
457
|
+
model.reserved.each do |entry|
|
|
458
|
+
next unless matches_filter?(entry.path)
|
|
459
|
+
|
|
460
|
+
groups[File.dirname(entry.path)][:reserved] << entry.path
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
filtered_rows.each do |row|
|
|
464
|
+
groups[File.dirname(row[:path])][:concepts] << row
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
groups.each_value { |group| group[:reserved].sort_by! { |path| File.basename(path) } }
|
|
468
|
+
groups.sort_by { |dir, _| dir == "." ? [ 0, "" ] : [ 1, dir ] }
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def matches_filter?(text)
|
|
472
|
+
@filter.empty? || text.to_s.downcase.include?(@filter.downcase)
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
def selectable_indices
|
|
476
|
+
list_entries.each_index.select { |index| %i[concept reserved].include?(list_entries[index][:kind]) }
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
# The row under the cursor, or the first one, when it is a concept. A
|
|
480
|
+
# reserved file has no catalog row — the detail pane asks for it separately.
|
|
481
|
+
def selected_row
|
|
482
|
+
entry = selected_browse_entry
|
|
483
|
+
entry && entry[:kind] == :concept ? entry[:row] : nil
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
def selected_browse_entry
|
|
487
|
+
entry = list_entries[@cursor]
|
|
488
|
+
return entry if entry && %i[concept reserved].include?(entry[:kind])
|
|
489
|
+
|
|
490
|
+
first = selectable_indices.first
|
|
491
|
+
first ? list_entries[first] : nil
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
# ── graph view state ─────────────────────────────────────────────────────
|
|
495
|
+
#
|
|
496
|
+
# The graph is two selectable lists rather than a page: facets on the left
|
|
497
|
+
# (a type or a tag), concepts on the right. Tab moves between them, and what
|
|
498
|
+
# Enter means follows from which kind of row the cursor is on — narrow by
|
|
499
|
+
# this facet, or go read this concept.
|
|
500
|
+
|
|
501
|
+
attr_reader :workspace, :view, :pane, :cursor, :filter, :query, :searched, :prompt, :find, :find_index,
|
|
502
|
+
:graph_facet, :follow_cursor, :group_cursor, :member_cursor, :search_mode
|
|
503
|
+
|
|
504
|
+
# Concepts the facet admits. Selecting `Capability` does not recompute link
|
|
505
|
+
# degree — the counts stay the bundle's real ones — it narrows *which*
|
|
506
|
+
# concepts the lists are drawn from.
|
|
507
|
+
def faceted_rows
|
|
508
|
+
return [] unless model
|
|
509
|
+
return model.rows if @graph_facet.nil?
|
|
510
|
+
|
|
511
|
+
field = @graph_facet[:field]
|
|
512
|
+
value = @graph_facet[:value]
|
|
513
|
+
model.rows.select { |row| facet_admits?(row, field, value) }
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
# A type and a tag match exactly; a dir names itself and everything beneath it,
|
|
517
|
+
# which is okf's `--dir` rule (see Model.under_dir?). Narrowing to `platform`
|
|
518
|
+
# therefore reaches `platform/services/api`, exactly as `okf catalog --dir
|
|
519
|
+
# platform` does — a dir facet that matched only its own level would be the
|
|
520
|
+
# first-path-segment rollup `--area` was, which okf deprecated for losing every
|
|
521
|
+
# level below it.
|
|
522
|
+
def facet_admits?(row, field, value)
|
|
523
|
+
case field
|
|
524
|
+
when :type then Model.type_label(row[:type]) == value
|
|
525
|
+
when :dir then Model.under_dir?(row[:dir], value)
|
|
526
|
+
# §5.4 and §5.3, narrowed exactly as `--status` and `--trust` narrow —
|
|
527
|
+
# okf's own predicate, so an absent status reads `stable` here the way it
|
|
528
|
+
# reads `stable` there, and a tier folds both spellings. Trust also gates on
|
|
529
|
+
# what the row is willing to claim, so the facet covers exactly the rows
|
|
530
|
+
# wearing a tier: counting rows this screen shows no tier for would narrow
|
|
531
|
+
# to more concepts than the count promised.
|
|
532
|
+
when :status then OKF::Bundle::RowFilter.matches?(row, status: value)
|
|
533
|
+
when :trust then Model.shows_trust?(row) && OKF::Bundle::RowFilter.matches?(row, trust: value)
|
|
534
|
+
else Array(row[:tags]).map(&:to_s).include?(value)
|
|
535
|
+
end
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
def facet_active?(field, value)
|
|
539
|
+
@graph_facet && @graph_facet[:field] == field && @graph_facet[:value] == value
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
# Left pane: the type and tag tallies, counted within the facet in force.
|
|
543
|
+
def graph_facet_entries
|
|
544
|
+
return [] unless model
|
|
545
|
+
|
|
546
|
+
subset = faceted_rows
|
|
547
|
+
types = Views.narrow(model.types_of(subset), @filter)
|
|
548
|
+
tags = Views.narrow(model.tags_of(subset), @filter).first(12)
|
|
549
|
+
dirs = Views.narrow(model.dirs_of(subset), @filter)
|
|
550
|
+
|
|
551
|
+
entries = [ { kind: :heading, label: "types", colour: :cyan } ]
|
|
552
|
+
entries.concat(facet_rows(types, :type))
|
|
553
|
+
entries << { kind: :blank }
|
|
554
|
+
entries << { kind: :heading, label: "tags", colour: :magenta }
|
|
555
|
+
entries.concat(facet_rows(tags, :tag))
|
|
556
|
+
|
|
557
|
+
# Only where the bundle actually nests. The test is a directory with a
|
|
558
|
+
# separator in it, not a count of directories: `conformant/` has two
|
|
559
|
+
# (datasets, tables) and is still one level deep, where every dir facet says
|
|
560
|
+
# exactly what a top-level rollup would — and a rollup is what okf deprecated
|
|
561
|
+
# `--area` for being. okf takes the same view on its graph page, which offers
|
|
562
|
+
# a bundle that does not nest no depth control at all.
|
|
563
|
+
if dirs.any? { |dir, _count| dir.include?("/") }
|
|
564
|
+
entries << { kind: :blank }
|
|
565
|
+
entries << { kind: :heading, label: "dirs", colour: :yellow }
|
|
566
|
+
entries.concat(facet_rows(dirs, :dir))
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
entries.concat(provenance_facets(subset))
|
|
570
|
+
entries
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
# §5's two facets, offered only where the bundle has something to say — the
|
|
574
|
+
# same rule the dir facet follows, and the same rule okf's own graph page
|
|
575
|
+
# applies to these two.
|
|
576
|
+
#
|
|
577
|
+
# Status needs one *declared* value, because a lone `stable` row is what an
|
|
578
|
+
# undeclared status already means; the counts then use the effective value, so
|
|
579
|
+
# the group reads `stable 22 · deprecated 1` rather than hiding the majority
|
|
580
|
+
# the one deprecated concept is measured against. Trust needs one tier okf is
|
|
581
|
+
# willing to claim, or every v0.1 bundle would offer a facet whose only row
|
|
582
|
+
# said "unverified" about a family it never adopted — and both the count and
|
|
583
|
+
# the narrowing read that same predicate, so the row and what it selects agree.
|
|
584
|
+
def provenance_facets(subset)
|
|
585
|
+
entries = []
|
|
586
|
+
|
|
587
|
+
if subset.any? { |row| !row[:status].to_s.empty? }
|
|
588
|
+
statuses = Views.narrow(model.statuses_of(subset), @filter)
|
|
589
|
+
unless statuses.empty?
|
|
590
|
+
entries << { kind: :blank }
|
|
591
|
+
entries << { kind: :heading, label: "status", colour: :yellow }
|
|
592
|
+
entries.concat(facet_rows(statuses, :status))
|
|
593
|
+
end
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
tiers = Views.narrow(model.tiers_of(subset), @filter)
|
|
597
|
+
unless tiers.empty?
|
|
598
|
+
entries << { kind: :blank }
|
|
599
|
+
entries << { kind: :heading, label: "trust", colour: :green }
|
|
600
|
+
entries.concat(facet_rows(tiers, :trust))
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
entries
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
# Each block of bars is scaled to its own tallest, and its labels padded to
|
|
607
|
+
# its own longest, so the two sections read independently.
|
|
608
|
+
def facet_rows(pairs, field)
|
|
609
|
+
peak = pairs.map { |_, count| count }.max
|
|
610
|
+
pad = pairs.map { |value, _| Ui.width(value.to_s) }.max
|
|
611
|
+
|
|
612
|
+
pairs.map do |value, count|
|
|
613
|
+
{ kind: :facet, field: field, value: value, count: count, peak: peak, pad: pad }
|
|
614
|
+
end
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
# Right pane: the hubs and orphans among those concepts.
|
|
618
|
+
def graph_concept_entries
|
|
619
|
+
return [] unless model
|
|
620
|
+
|
|
621
|
+
subset = Views.narrow_rows(faceted_rows, @filter)
|
|
622
|
+
entries = []
|
|
623
|
+
|
|
624
|
+
entries << { kind: :heading, label: "most linked-to", colour: :blue }
|
|
625
|
+
entries.concat(degree_entries(subset, :links_in, :blue))
|
|
626
|
+
entries << { kind: :blank }
|
|
627
|
+
entries << { kind: :heading, label: "most linked-from", colour: :green }
|
|
628
|
+
entries.concat(degree_entries(subset, :links_out, :green))
|
|
629
|
+
entries << { kind: :blank }
|
|
630
|
+
|
|
631
|
+
orphans = subset.map { |row| row[:id] } & model.orphan_ids
|
|
632
|
+
entries << { kind: :heading, label: "orphans (no edges either way)", colour: orphans.empty? ? :green : :yellow }
|
|
633
|
+
if orphans.empty?
|
|
634
|
+
entries << { kind: :note, label: "every concept is reachable", colour: :green }
|
|
635
|
+
else
|
|
636
|
+
pad = orphans.first(6).map { |id| Ui.width(id.to_s) }.max
|
|
637
|
+
orphans.first(6).each { |id| entries << { kind: :concept, id: id, count: 0, peak: 1, pad: pad, colour: :yellow } }
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
entries
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
def degree_entries(subset, field, colour)
|
|
644
|
+
top = subset.sort_by { |row| -row[field].to_i }.first(8)
|
|
645
|
+
peak = top.map { |row| row[field].to_i }.max
|
|
646
|
+
pad = top.map { |row| Ui.width(row[:id].to_s) }.max
|
|
647
|
+
|
|
648
|
+
top.map do |row|
|
|
649
|
+
{ kind: :concept, id: row[:id], count: row[field].to_i, peak: peak, pad: pad, colour: colour }
|
|
650
|
+
end
|
|
651
|
+
end
|
|
652
|
+
|
|
653
|
+
def graph_entries
|
|
654
|
+
@pane == :detail ? graph_concept_entries : graph_facet_entries
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
def graph_selectable
|
|
658
|
+
graph_entries.each_index.select { |index| %i[facet concept].include?(graph_entries[index][:kind]) }
|
|
659
|
+
end
|
|
660
|
+
|
|
661
|
+
def graph_selected
|
|
662
|
+
graph_entries[@cursor]
|
|
663
|
+
end
|
|
664
|
+
|
|
665
|
+
# ── scrolling ────────────────────────────────────────────────────────────
|
|
666
|
+
|
|
667
|
+
# The first visible index, scrolled just enough to keep the cursor on screen.
|
|
668
|
+
def window(count, height)
|
|
669
|
+
height = [ height, 1 ].max
|
|
670
|
+
@scroll = [ @scroll, @cursor - height + 1 ].max
|
|
671
|
+
@scroll = [ @scroll, @cursor ].min
|
|
672
|
+
@scroll = [ [ @scroll, count - height ].min, 0 ].max
|
|
673
|
+
@scroll
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
# The groups pane scrolls independently of the bundles pane, so it needs its own
|
|
677
|
+
# offset: sharing @scroll would make paging one list drag the other.
|
|
678
|
+
def group_window(count, height)
|
|
679
|
+
height = [ height, 1 ].max
|
|
680
|
+
@group_scroll = [ @group_scroll.to_i, @group_cursor - height + 1 ].max
|
|
681
|
+
@group_scroll = [ @group_scroll, @group_cursor ].min
|
|
682
|
+
@group_scroll = [ [ @group_scroll, count - height ].min, 0 ].max
|
|
683
|
+
end
|
|
684
|
+
|
|
685
|
+
def detail_scroll_for(count, visible)
|
|
686
|
+
max = [ count - visible, 0 ].max
|
|
687
|
+
@detail_scroll = [ [ @detail_scroll, max ].min, 0 ].max
|
|
688
|
+
end
|
|
689
|
+
|
|
690
|
+
# The offset for the views that are one long page rather than a list —
|
|
691
|
+
# health, graph and help. Clamped here, against the row count the view
|
|
692
|
+
# actually produced, so the state can never point past the end.
|
|
693
|
+
def content_scroll_for(count, visible, key = :content)
|
|
694
|
+
max = [ count - visible, 0 ].max
|
|
695
|
+
set_scroll(key, [ [ scroll_for(key), max ].min, 0 ].max)
|
|
696
|
+
end
|
|
697
|
+
|
|
698
|
+
# Health is two pages side by side and each keeps its own place: the findings
|
|
699
|
+
# are unbounded and the summary is not, so one shared offset would drag the
|
|
700
|
+
# short pane to its end and hold it there while the long one scrolled.
|
|
701
|
+
def scroll_for(key)
|
|
702
|
+
key == :health ? @health_scroll : @content_scroll
|
|
703
|
+
end
|
|
704
|
+
|
|
705
|
+
def set_scroll(key, value)
|
|
706
|
+
key == :health ? (@health_scroll = value) : (@content_scroll = value)
|
|
707
|
+
end
|
|
708
|
+
|
|
709
|
+
# Which of the two the keys move: the one with focus.
|
|
710
|
+
def content_scroll_key
|
|
711
|
+
@view == :health && @pane == :detail ? :health : :content
|
|
712
|
+
end
|
|
713
|
+
|
|
714
|
+
# Wide enough that tty-markdown never wraps a line itself.
|
|
715
|
+
#
|
|
716
|
+
# Its wrapper (the `strings` gem) miscounts ANSI escapes and raises
|
|
717
|
+
# IndexError from String#insert on coloured input — 50 of 192 concept/width
|
|
718
|
+
# combinations in the okf bundle alone, and only in a real terminal, since a
|
|
719
|
+
# pipe turns the colour off. Handing it a width it can never reach sidesteps
|
|
720
|
+
# the wrapping entirely, and Ui.reflow does the job instead: it was already
|
|
721
|
+
# re-wrapping this output anyway, because tty-markdown otherwise keeps the
|
|
722
|
+
# source line breaks. Tables come out at their natural width and are clipped
|
|
723
|
+
# to the pane exactly as before.
|
|
724
|
+
PARSE_WIDTH = 10_000
|
|
725
|
+
|
|
726
|
+
# Concept bodies are rendered by tty-markdown once and cached — re-rendering
|
|
727
|
+
# on every keypress is what would make scrolling feel heavy. Keyed by bundle
|
|
728
|
+
# too, since ids are only unique within one.
|
|
729
|
+
def rendered_body(row, width)
|
|
730
|
+
render_markdown([ workspace.active_slug, row[:id], width ], model.body_for(row), width)
|
|
731
|
+
end
|
|
732
|
+
|
|
733
|
+
def rendered_reserved(path, width)
|
|
734
|
+
render_markdown([ workspace.active_slug, :reserved, path, width ], model.reserved_text(path), width)
|
|
735
|
+
end
|
|
736
|
+
|
|
737
|
+
def render_markdown(cache_key, source, width)
|
|
738
|
+
@body_cache[cache_key] ||= begin
|
|
739
|
+
if source.to_s.strip.empty?
|
|
740
|
+
[ Ui.pastel.decorate("(empty)", :bright_black) ]
|
|
741
|
+
else
|
|
742
|
+
limit = [ width, 20 ].max
|
|
743
|
+
# Say the colour decision out loud rather than letting tty-markdown
|
|
744
|
+
# sniff the terminal: it is the coloured path that trips the wrapping
|
|
745
|
+
# bug PARSE_WIDTH avoids, and a check that cannot turn colour on
|
|
746
|
+
# cannot see that bug at all.
|
|
747
|
+
mode = Ui.pastel.enabled? ? :always : :never
|
|
748
|
+
Ui.reflow(TTY::Markdown.parse(source, width: PARSE_WIDTH, color: mode).lines, limit)
|
|
749
|
+
end
|
|
750
|
+
rescue StandardError => e
|
|
751
|
+
# Name the failure. "IndexError" alone sent this one looking in the
|
|
752
|
+
# wrong place for a while.
|
|
753
|
+
[ Ui.pastel.decorate("(could not render markdown: #{e.class}: #{e.message})", :red) ]
|
|
754
|
+
end
|
|
755
|
+
end
|
|
756
|
+
|
|
757
|
+
# Cross-bundle: the hits come from every scoped bundle, ranked together.
|
|
758
|
+
#
|
|
759
|
+
# Results belong to `@searched`, not to `@query`, and only Enter moves one to
|
|
760
|
+
# the other. Searching per keystroke meant building a fresh index over every
|
|
761
|
+
# scoped bundle for each letter, and the view churning through answers to
|
|
762
|
+
# half-typed words; a timed pause fixed the cost but still left the moment
|
|
763
|
+
# of searching up to a guess about typing rhythm. Submitting is explicit
|
|
764
|
+
# instead, so the field edits freely and the results below hold still.
|
|
765
|
+
def search_hits
|
|
766
|
+
return [] if @searched.empty? # nothing submitted yet — nothing to ask
|
|
767
|
+
|
|
768
|
+
key = [ @searched.dup, workspace.scope, @search_mode ]
|
|
769
|
+
return @search_hits if @search_hits_key == key
|
|
770
|
+
|
|
771
|
+
@search_hits_key = key
|
|
772
|
+
@search_hits = workspace.search(@searched, mode: @search_mode)
|
|
773
|
+
end
|
|
774
|
+
|
|
775
|
+
# The three ways okf can be asked a question, cycled with `e`. Modes rather than
|
|
776
|
+
# engine names, because that is how okf routes: a query declares the capability
|
|
777
|
+
# it needs and the facade picks the engine that has it.
|
|
778
|
+
SEARCH_MODES = [
|
|
779
|
+
{ id: :fuzzy, label: "fuzzy", note: "ranked, typo-tolerant — misses terms glued to symbols" },
|
|
780
|
+
{ id: :text, label: "text", note: "raw substring, okf's default — finds $OKF_HOME and `minifts`" },
|
|
781
|
+
{ id: :regexp, label: "regexp", note: "a pattern over the same raw text" }
|
|
782
|
+
].freeze
|
|
783
|
+
|
|
784
|
+
def search_mode_entry
|
|
785
|
+
SEARCH_MODES.find { |mode| mode[:id] == @search_mode } || SEARCH_MODES.first
|
|
786
|
+
end
|
|
787
|
+
|
|
788
|
+
def search_mode_label
|
|
789
|
+
search_mode_entry[:label]
|
|
790
|
+
end
|
|
791
|
+
|
|
792
|
+
def search_mode_note
|
|
793
|
+
search_mode_entry[:note]
|
|
794
|
+
end
|
|
795
|
+
|
|
796
|
+
# Cycling re-asks the question rather than leaving the previous engine's answer
|
|
797
|
+
# sitting under a new label: the hits are keyed on the mode, so it reruns.
|
|
798
|
+
def cycle_search_mode
|
|
799
|
+
index = SEARCH_MODES.index { |mode| mode[:id] == @search_mode }.to_i
|
|
800
|
+
@search_mode = SEARCH_MODES[(index + 1) % SEARCH_MODES.length][:id]
|
|
801
|
+
@cursor = 0
|
|
802
|
+
@message = "search: #{search_mode_label} — #{search_mode_note}"
|
|
803
|
+
end
|
|
804
|
+
|
|
805
|
+
# Does the search field have focus — i.e. do printable keys type into it?
|
|
806
|
+
def editing_query?
|
|
807
|
+
@view == :search && @search_focus == :field
|
|
808
|
+
end
|
|
809
|
+
|
|
810
|
+
def focus_field
|
|
811
|
+
@search_focus = :field
|
|
812
|
+
end
|
|
813
|
+
|
|
814
|
+
# Stop editing, stay where you are.
|
|
815
|
+
#
|
|
816
|
+
# Unsearched edits are dropped, not run: Esc is how you abandon a query you
|
|
817
|
+
# started typing, and running it would be the opposite of stopping. Reverting
|
|
818
|
+
# to the query the results actually answer also means the field and the list
|
|
819
|
+
# below it always agree once the field is released — there is no lingering
|
|
820
|
+
# "these results are for something else" state to explain.
|
|
821
|
+
def leave_field
|
|
822
|
+
@query = @searched.dup
|
|
823
|
+
@search_focus = :results
|
|
824
|
+
end
|
|
825
|
+
|
|
826
|
+
# Is there typing the results have not caught up with yet?
|
|
827
|
+
def search_pending?
|
|
828
|
+
@view == :search && @searched != @query
|
|
829
|
+
end
|
|
830
|
+
|
|
831
|
+
# Adopt the typed query and let the next #search_hits do the work. Called
|
|
832
|
+
# from the loop after the pause, and directly on Enter.
|
|
833
|
+
def commit_search
|
|
834
|
+
return false unless @searched != @query
|
|
835
|
+
|
|
836
|
+
@searched = @query.dup
|
|
837
|
+
@cursor = 0
|
|
838
|
+
@scroll = 0
|
|
839
|
+
true
|
|
840
|
+
end
|
|
841
|
+
|
|
842
|
+
# ── input ────────────────────────────────────────────────────────────────
|
|
843
|
+
|
|
844
|
+
def handle(key)
|
|
845
|
+
return handle_prompt(key) if @prompt
|
|
846
|
+
|
|
847
|
+
@message = nil
|
|
848
|
+
# Whether the *previous* key armed the quit, and the disarm for this one.
|
|
849
|
+
# Above the mode handlers on purpose: typing `q` into a filter or a query
|
|
850
|
+
# has to cancel the arming too, or the chord leaks across a text field.
|
|
851
|
+
armed = @quit_armed
|
|
852
|
+
@quit_armed = false
|
|
853
|
+
# The picker is the innermost mode, so it owns the keyboard outright —
|
|
854
|
+
# including the digits, which is the whole reason it is a mode and not a
|
|
855
|
+
# pane. `1` picks a link while it is open; everywhere else it is view one.
|
|
856
|
+
return handle_follow(key) if @following
|
|
857
|
+
return handle_find(key) if @finding
|
|
858
|
+
return handle_filter(key) if @filtering
|
|
859
|
+
# Only while the field has focus do printable keys mean query text. Once
|
|
860
|
+
# Esc leaves it, the search view answers the same keys as everywhere else.
|
|
861
|
+
return handle_query(key) if @view == :search && @search_focus == :field
|
|
862
|
+
|
|
863
|
+
if KEY_VIEWS.key?(key)
|
|
864
|
+
switch(KEY_VIEWS[key])
|
|
865
|
+
return
|
|
866
|
+
end
|
|
867
|
+
|
|
868
|
+
case key
|
|
869
|
+
when "q" then armed ? (@running = false) : arm_quit
|
|
870
|
+
# Ctrl-C stays single. It is the escape hatch, and an escape hatch that
|
|
871
|
+
# needs confirming is not one.
|
|
872
|
+
when CTRL_C then @running = false
|
|
873
|
+
when "?" then switch(:help)
|
|
874
|
+
when "j", DOWN then move(1)
|
|
875
|
+
when "k", UP then move(-1)
|
|
876
|
+
when "g" then jump(:first)
|
|
877
|
+
when "G" then jump(:last)
|
|
878
|
+
when CTRL_D then move(10)
|
|
879
|
+
when CTRL_U then move(-10)
|
|
880
|
+
# A `when` matches whether or not its guard holds, so these must hand the
|
|
881
|
+
# key back rather than swallow it — `n` is rename in the bundles view.
|
|
882
|
+
when "n" then findable? ? step_match(1) : fallback(key)
|
|
883
|
+
when "N" then findable? ? step_match(-1) : fallback(key)
|
|
884
|
+
when "J" then @detail_scroll += 3
|
|
885
|
+
when "K" then @detail_scroll = [ @detail_scroll - 3, 0 ].max
|
|
886
|
+
when TAB then toggle_pane
|
|
887
|
+
when "f" then start_follow
|
|
888
|
+
when DELETE then back
|
|
889
|
+
when "/" then start_typing
|
|
890
|
+
when "i" then focus_field if @view == :search
|
|
891
|
+
when "e" then cycle_search_mode if @view == :search
|
|
892
|
+
when "s" then switch(:search)
|
|
893
|
+
when "r" then reload
|
|
894
|
+
when "\r", "\n" then handle_return
|
|
895
|
+
when ESCAPE then handle_escape
|
|
896
|
+
else fallback(key)
|
|
897
|
+
end
|
|
898
|
+
end
|
|
899
|
+
|
|
900
|
+
def fallback(key)
|
|
901
|
+
handle_bundle_key(key) if @view == :bundles
|
|
902
|
+
end
|
|
903
|
+
|
|
904
|
+
def running?
|
|
905
|
+
@running
|
|
906
|
+
end
|
|
907
|
+
|
|
908
|
+
# `q` quit on the first press, so one stray keystroke ended the session with
|
|
909
|
+
# nothing to undo it. It arms instead, and says so — a chord nobody is told
|
|
910
|
+
# about is just a key that stopped working.
|
|
911
|
+
def arm_quit
|
|
912
|
+
@quit_armed = true
|
|
913
|
+
@message = "press q again to quit"
|
|
914
|
+
end
|
|
915
|
+
|
|
916
|
+
# Enter, once the field no longer has focus: open whatever is selected.
|
|
917
|
+
def handle_return
|
|
918
|
+
case @view
|
|
919
|
+
when :search then open_hit
|
|
920
|
+
when :browse then escalate_to_search if filter_found_nothing?
|
|
921
|
+
when :graph then activate_graph_row
|
|
922
|
+
when :bundles then filter_found_nothing? ? escalate_to_search : open_registry_row
|
|
923
|
+
end
|
|
924
|
+
end
|
|
925
|
+
|
|
926
|
+
# The keys that only mean something over the registry list.
|
|
927
|
+
def handle_bundle_key(key)
|
|
928
|
+
case @pane
|
|
929
|
+
when :members then member_key(key)
|
|
930
|
+
when :groups then group_key(key)
|
|
931
|
+
else bundle_key(key)
|
|
932
|
+
end
|
|
933
|
+
end
|
|
934
|
+
|
|
935
|
+
# The bundles pane. `+` is the one that reaches across: the bundle under this
|
|
936
|
+
# cursor joins the group selected in the pane below, which is two visible rows
|
|
937
|
+
# rather than an invisible set.
|
|
938
|
+
def bundle_key(key)
|
|
939
|
+
case key
|
|
940
|
+
when " " then toggle_scope
|
|
941
|
+
when "A" then workspace.scope_all && @search_hits_key = nil
|
|
942
|
+
when "N" then workspace.scope_none && @search_hits_key = nil
|
|
943
|
+
when "d" then set_default
|
|
944
|
+
when "a" then ask(:add, "directory to register:", free_text: true)
|
|
945
|
+
when "n" then ask_rename
|
|
946
|
+
when "x" then ask_remove
|
|
947
|
+
when "c" then ask_group
|
|
948
|
+
when "+" then add_selected_to_group
|
|
949
|
+
# Removal lives in the pane that lists the members, where each one has a row
|
|
950
|
+
# of its own. Saying so beats going silent: a key that quietly stopped working
|
|
951
|
+
# is indistinguishable from a broken one.
|
|
952
|
+
when "-" then @message = "- removes a member: Tab to the groups, Tab again into them"
|
|
953
|
+
end
|
|
954
|
+
end
|
|
955
|
+
|
|
956
|
+
# The groups pane. `n` and `x` are the same keys they are for a bundle, on the
|
|
957
|
+
# slug this pane has selected — okf's rename and del span a group and cascade.
|
|
958
|
+
def group_key(key)
|
|
959
|
+
case key
|
|
960
|
+
when "n" then ask_rename
|
|
961
|
+
when "x" then ask_remove
|
|
962
|
+
when "c" then ask_group
|
|
963
|
+
when "+" then @message = "+ adds from the bundles pane — Tab back, point at one, then +"
|
|
964
|
+
when "-" then @message = "- removes a member: Tab into them, or point at the bundle and press -"
|
|
965
|
+
end
|
|
966
|
+
end
|
|
967
|
+
|
|
968
|
+
def member_key(key)
|
|
969
|
+
case key
|
|
970
|
+
when "-" then ask_member_removal
|
|
971
|
+
end
|
|
972
|
+
end
|
|
973
|
+
|
|
974
|
+
# `c` names the bundles now in scope. The scope *is* the selection in this
|
|
975
|
+
# view — `◉` says so on every row — so a group is made by toggling what you
|
|
976
|
+
# want and giving it a name, rather than by typing a member list a second time.
|
|
977
|
+
def ask_group
|
|
978
|
+
scoped = workspace.scope
|
|
979
|
+
return (@message = "nothing in scope to name — space toggles a bundle in") if scoped.empty?
|
|
980
|
+
|
|
981
|
+
ask(:group, "name for a group of #{scoped.length} scoped #{scoped.length == 1 ? "bundle" : "bundles"}:",
|
|
982
|
+
free_text: true)
|
|
983
|
+
end
|
|
984
|
+
|
|
985
|
+
# `+` and `-` on a group row: the cursor names the group, the scope names the
|
|
986
|
+
# bundles. No prompt, because neither half is ambiguous — which is the reason
|
|
987
|
+
# to spend the scope on this rather than ask for a typed list.
|
|
988
|
+
# `+` from the bundles pane: the bundle under this cursor joins the group the
|
|
989
|
+
# groups pane has selected. Two rows, both on screen, neither of them the search
|
|
990
|
+
# scope — which is what the earlier cuts of this key got wrong twice.
|
|
991
|
+
#
|
|
992
|
+
# Additive and reversible, so it does not ask.
|
|
993
|
+
def add_selected_to_group
|
|
994
|
+
entry = selected_entry
|
|
995
|
+
group = selected_group
|
|
996
|
+
return (@message = "no bundle here to add") if entry.nil?
|
|
997
|
+
return (@message = "no group to add it to — c names the bundles in scope as one") if group.nil?
|
|
998
|
+
return (@message = "@#{group.slug} already names @#{entry.slug}") if group.members.include?(entry.slug)
|
|
999
|
+
|
|
1000
|
+
apply_group_edit_keeping_focus do
|
|
1001
|
+
keeping_group_scoped(group.slug) { workspace.add_to_group(group.slug, [ entry.slug ]) }
|
|
1002
|
+
end
|
|
1003
|
+
end
|
|
1004
|
+
|
|
1005
|
+
# A group that *is* the scope in force stays in force across an edit to it.
|
|
1006
|
+
# Without this, `+` on a bundle left the ◉ on its row hollow and quietly emptied
|
|
1007
|
+
# the one on the group row too: the group had grown, the scope had not, so set
|
|
1008
|
+
# equality stopped holding and a search still covered the old set. The report was
|
|
1009
|
+
# the visible half — "it does not select the bundle" — and this was the cause.
|
|
1010
|
+
#
|
|
1011
|
+
# Only when it was in force beforehand. Re-scoping a group nobody had scoped
|
|
1012
|
+
# would make an edit to it silently replace the reader's own selection.
|
|
1013
|
+
def keeping_group_scoped(slug)
|
|
1014
|
+
group = workspace.group(slug)
|
|
1015
|
+
in_force = !group.nil? && group_in_scope?(group)
|
|
1016
|
+
message = yield
|
|
1017
|
+
workspace.scope_group(slug) if in_force && workspace.group(slug)
|
|
1018
|
+
message
|
|
1019
|
+
end
|
|
1020
|
+
|
|
1021
|
+
# Removing acts on the member row under the cursor — one member, the one being
|
|
1022
|
+
# pointed at. It still asks, because okf deletes a group whose last member
|
|
1023
|
+
# leaves, so this key can destroy the group rather than trim it, and the question
|
|
1024
|
+
# says which of the two is about to happen.
|
|
1025
|
+
def ask_member_removal
|
|
1026
|
+
group = selected_group
|
|
1027
|
+
return (@message = "Tab into a group's members to remove one") if group.nil?
|
|
1028
|
+
|
|
1029
|
+
member = selected_member
|
|
1030
|
+
return (@message = "@#{group.slug} has no members to remove") if member.nil?
|
|
1031
|
+
|
|
1032
|
+
ask_member_gone(group, member)
|
|
1033
|
+
end
|
|
1034
|
+
|
|
1035
|
+
# One question for both panes that can ask it, so the consequence is named the
|
|
1036
|
+
# same way whichever row the reader was pointing at.
|
|
1037
|
+
def ask_member_gone(group, member)
|
|
1038
|
+
label =
|
|
1039
|
+
if group.members.length == 1
|
|
1040
|
+
"remove @#{member} — its last, so @#{group.slug} goes too. remove? (y/n)"
|
|
1041
|
+
else
|
|
1042
|
+
"remove @#{member} from @#{group.slug}? (y/n)"
|
|
1043
|
+
end
|
|
1044
|
+
|
|
1045
|
+
ask(:ungroup, label, free_text: false, subject: [ group.slug, member ].join(MEMBER_REF))
|
|
1046
|
+
end
|
|
1047
|
+
|
|
1048
|
+
# The cursor stays where it was in both panes: a write should not move the
|
|
1049
|
+
# reader, and with two selections there are two to preserve.
|
|
1050
|
+
def apply_group_edit_keeping_focus
|
|
1051
|
+
bundle = selected_entry&.slug
|
|
1052
|
+
group = selected_group&.slug
|
|
1053
|
+
@message = yield
|
|
1054
|
+
@search_hits_key = nil
|
|
1055
|
+
invalidate
|
|
1056
|
+
clamp_cursor
|
|
1057
|
+
focus(bundle) if bundle
|
|
1058
|
+
focus_group(group) if group
|
|
1059
|
+
@message
|
|
1060
|
+
end
|
|
1061
|
+
|
|
1062
|
+
# Keep the cursor on the group that was just edited, rather than on whatever
|
|
1063
|
+
# ends up at that index after the reload — the same reason #set_default follows
|
|
1064
|
+
# its bundle instead of its position.
|
|
1065
|
+
def focus_group(slug)
|
|
1066
|
+
index = visible_groups.index { |group| group.slug == slug }
|
|
1067
|
+
@group_cursor = index if index
|
|
1068
|
+
@message
|
|
1069
|
+
end
|
|
1070
|
+
|
|
1071
|
+
# Find-in-document. Live rather than submitted: this only scans the lines
|
|
1072
|
+
# already rendered, so it costs nothing to follow every keystroke, and
|
|
1073
|
+
# watching the body jump as you type is the whole point.
|
|
1074
|
+
def handle_find(key)
|
|
1075
|
+
case key
|
|
1076
|
+
when "\r", "\n" then @finding = false
|
|
1077
|
+
when ESCAPE then clear_find
|
|
1078
|
+
when DELETE, "\b" then (@find.chop! || @find) && jump_to_match(0)
|
|
1079
|
+
when CTRL_C then @running = false
|
|
1080
|
+
else
|
|
1081
|
+
return unless printable?(key)
|
|
1082
|
+
|
|
1083
|
+
@find << key
|
|
1084
|
+
jump_to_match(0)
|
|
1085
|
+
end
|
|
1086
|
+
end
|
|
1087
|
+
|
|
1088
|
+
# ── following a link ─────────────────────────────────────────────────────
|
|
1089
|
+
#
|
|
1090
|
+
# The links of the document in the detail pane, in reading order. Both kinds
|
|
1091
|
+
# of document have them: a concept body, and a reserved file — and it is the
|
|
1092
|
+
# reserved ones that matter most, since an index.md is a list of links by
|
|
1093
|
+
# design (§6) and the log is a list of what changed where.
|
|
1094
|
+
|
|
1095
|
+
def follow_links
|
|
1096
|
+
return [] unless model
|
|
1097
|
+
|
|
1098
|
+
entry = selected_browse_entry
|
|
1099
|
+
return [] if entry.nil?
|
|
1100
|
+
|
|
1101
|
+
path = entry[:kind] == :reserved ? entry[:path] : entry[:row][:path]
|
|
1102
|
+
model.links_for(path.to_s)
|
|
1103
|
+
end
|
|
1104
|
+
|
|
1105
|
+
def following?
|
|
1106
|
+
@following
|
|
1107
|
+
end
|
|
1108
|
+
|
|
1109
|
+
def start_follow
|
|
1110
|
+
return unless @view == :browse
|
|
1111
|
+
|
|
1112
|
+
if follow_links.empty?
|
|
1113
|
+
@message = "no links in this document"
|
|
1114
|
+
return
|
|
1115
|
+
end
|
|
1116
|
+
|
|
1117
|
+
@following = true
|
|
1118
|
+
@follow_cursor = 0
|
|
1119
|
+
@follow_scroll = 0
|
|
1120
|
+
# The picker draws where the body does, so the pane it belongs to has to
|
|
1121
|
+
# be the one showing.
|
|
1122
|
+
@pane = :detail
|
|
1123
|
+
end
|
|
1124
|
+
|
|
1125
|
+
def handle_follow(key)
|
|
1126
|
+
links = follow_links
|
|
1127
|
+
|
|
1128
|
+
case key
|
|
1129
|
+
when ESCAPE, "f" then @following = false
|
|
1130
|
+
when CTRL_C then @running = false
|
|
1131
|
+
when "\r", "\n" then follow_selected(links)
|
|
1132
|
+
when "j", DOWN then @follow_cursor = [ @follow_cursor + 1, links.length - 1 ].min
|
|
1133
|
+
when "k", UP then @follow_cursor = [ @follow_cursor - 1, 0 ].max
|
|
1134
|
+
when "g" then @follow_cursor = 0
|
|
1135
|
+
when "G" then @follow_cursor = [ links.length - 1, 0 ].max
|
|
1136
|
+
when /\A[1-9]\z/
|
|
1137
|
+
index = key.to_i - 1
|
|
1138
|
+
return if index >= links.length
|
|
1139
|
+
|
|
1140
|
+
@follow_cursor = index
|
|
1141
|
+
follow_selected(links)
|
|
1142
|
+
end
|
|
1143
|
+
end
|
|
1144
|
+
|
|
1145
|
+
def follow_selected(links)
|
|
1146
|
+
link = links[@follow_cursor]
|
|
1147
|
+
return if link.nil?
|
|
1148
|
+
|
|
1149
|
+
case link[:kind]
|
|
1150
|
+
when :concept
|
|
1151
|
+
@following = false
|
|
1152
|
+
open_concept(link[:id])
|
|
1153
|
+
@message = "opened #{link[:id]}"
|
|
1154
|
+
when :reserved
|
|
1155
|
+
@following = false
|
|
1156
|
+
open_reserved(link[:target])
|
|
1157
|
+
@message = "opened #{link[:target]}"
|
|
1158
|
+
else
|
|
1159
|
+
# Not an error: a link with nothing at the end of it is knowledge that
|
|
1160
|
+
# has not been written yet — okf's own position, and lint's job to
|
|
1161
|
+
# report. The picker says so and stays open, because a dead link means
|
|
1162
|
+
# the reader has not finished choosing, and closing would make them
|
|
1163
|
+
# press `f` again to find that out.
|
|
1164
|
+
@message = "#{link[:target]} is not in this bundle yet"
|
|
1165
|
+
end
|
|
1166
|
+
end
|
|
1167
|
+
|
|
1168
|
+
# The window over the picker, mirroring #window — its own offset, because
|
|
1169
|
+
# the list pane's scroll belongs to a different list.
|
|
1170
|
+
def follow_window(count, height)
|
|
1171
|
+
height = [ height, 1 ].max
|
|
1172
|
+
@follow_scroll = [ @follow_scroll, @follow_cursor - height + 1 ].max
|
|
1173
|
+
@follow_scroll = [ @follow_scroll, @follow_cursor ].min
|
|
1174
|
+
@follow_scroll = [ [ @follow_scroll, count - height ].min, 0 ].max
|
|
1175
|
+
end
|
|
1176
|
+
|
|
1177
|
+
# ── the way back ─────────────────────────────────────────────────────────
|
|
1178
|
+
#
|
|
1179
|
+
# A jump nobody can undo is a jump nobody makes twice. Pushed inside the two
|
|
1180
|
+
# openers, which means following a link, opening a search hit and leaving the
|
|
1181
|
+
# graph for a concept are all reversible by the same key.
|
|
1182
|
+
|
|
1183
|
+
MAX_TRAIL = 32
|
|
1184
|
+
|
|
1185
|
+
def trail_depth
|
|
1186
|
+
@trail.length
|
|
1187
|
+
end
|
|
1188
|
+
|
|
1189
|
+
def push_trail
|
|
1190
|
+
@trail << { slug: workspace.active_slug, view: @view, pane: @pane, cursor: @cursor,
|
|
1191
|
+
scroll: @scroll, detail_scroll: @detail_scroll, filter: @filter.dup }
|
|
1192
|
+
@trail.shift while @trail.length > MAX_TRAIL
|
|
1193
|
+
end
|
|
1194
|
+
|
|
1195
|
+
def back
|
|
1196
|
+
spot = @trail.pop
|
|
1197
|
+
return if spot.nil?
|
|
1198
|
+
|
|
1199
|
+
# activate resets every offset, so the remembered ones are restored after
|
|
1200
|
+
# it rather than before — otherwise the bundle switch quietly wipes them.
|
|
1201
|
+
activate(spot[:slug]) if spot[:slug] != workspace.active_slug
|
|
1202
|
+
@following = false
|
|
1203
|
+
@view = spot[:view]
|
|
1204
|
+
@pane = spot[:pane]
|
|
1205
|
+
@filter = spot[:filter].dup
|
|
1206
|
+
@list_entries_key = nil
|
|
1207
|
+
@cursor = spot[:cursor]
|
|
1208
|
+
@scroll = spot[:scroll]
|
|
1209
|
+
@detail_scroll = spot[:detail_scroll]
|
|
1210
|
+
@message = "back"
|
|
1211
|
+
end
|
|
1212
|
+
|
|
1213
|
+
def clear_find
|
|
1214
|
+
@finding = false
|
|
1215
|
+
@find = +""
|
|
1216
|
+
@find_index = 0
|
|
1217
|
+
@find_jump = false
|
|
1218
|
+
end
|
|
1219
|
+
|
|
1220
|
+
def jump_to_match(index)
|
|
1221
|
+
@find_index = index
|
|
1222
|
+
@find_jump = true
|
|
1223
|
+
end
|
|
1224
|
+
|
|
1225
|
+
def step_match(delta)
|
|
1226
|
+
return unless findable? && !@find.empty?
|
|
1227
|
+
|
|
1228
|
+
@find_index += delta
|
|
1229
|
+
@find_jump = true
|
|
1230
|
+
end
|
|
1231
|
+
|
|
1232
|
+
# The lines of a rendered body carrying the term. Matched on the visible
|
|
1233
|
+
# text, so a word split by colour still counts.
|
|
1234
|
+
def find_matches(body)
|
|
1235
|
+
return [] if @find.empty?
|
|
1236
|
+
|
|
1237
|
+
needle = @find.downcase
|
|
1238
|
+
body.each_index.select { |index| body[index].to_s.gsub(Ui::ANSI, "").downcase.include?(needle) }
|
|
1239
|
+
end
|
|
1240
|
+
|
|
1241
|
+
# The scroll offset for a body, honouring a pending jump. The match lands a
|
|
1242
|
+
# couple of lines down rather than flush at the top, so it arrives with the
|
|
1243
|
+
# context above it.
|
|
1244
|
+
def detail_offset(count, visible, matches)
|
|
1245
|
+
if @find_jump && !matches.empty?
|
|
1246
|
+
@find_index %= matches.length
|
|
1247
|
+
@detail_scroll = [ matches[@find_index] - 2, 0 ].max
|
|
1248
|
+
@find_jump = false
|
|
1249
|
+
end
|
|
1250
|
+
|
|
1251
|
+
# Remembered so the status line can report the find without needing the
|
|
1252
|
+
# body, which only the detail pane has.
|
|
1253
|
+
@find_total = matches.length
|
|
1254
|
+
detail_scroll_for(count, visible)
|
|
1255
|
+
end
|
|
1256
|
+
|
|
1257
|
+
# Same jump, against the offset the one-long-page views scroll on.
|
|
1258
|
+
# The rows the current page last rendered. Only a scrolling page keeps this,
|
|
1259
|
+
# and only so a check can assert against the same list on screen.
|
|
1260
|
+
def remember_page(rows)
|
|
1261
|
+
@last_page_rows = rows
|
|
1262
|
+
end
|
|
1263
|
+
|
|
1264
|
+
def content_offset(count, visible, matches, key = :content)
|
|
1265
|
+
if @find_jump && !matches.empty?
|
|
1266
|
+
@find_index %= matches.length
|
|
1267
|
+
set_scroll(key, [ matches[@find_index] - 2, 0 ].max)
|
|
1268
|
+
@find_jump = false
|
|
1269
|
+
end
|
|
1270
|
+
|
|
1271
|
+
@find_total = matches.length
|
|
1272
|
+
content_scroll_for(count, visible, key)
|
|
1273
|
+
end
|
|
1274
|
+
|
|
1275
|
+
def find_status
|
|
1276
|
+
return nil if @find.empty? || !findable?
|
|
1277
|
+
return "no line matches “#{@find}”" if @find_total.to_i.zero?
|
|
1278
|
+
|
|
1279
|
+
"match #{(@find_index % @find_total) + 1} of #{@find_total} for “#{@find}” · n/N steps"
|
|
1280
|
+
end
|
|
1281
|
+
|
|
1282
|
+
def handle_filter(key)
|
|
1283
|
+
case key
|
|
1284
|
+
# Enter accepts the filter — or, when it matched nothing, takes the term
|
|
1285
|
+
# to the search view instead. Widening is the natural next move from an
|
|
1286
|
+
# empty list, so it costs the same keystroke rather than a new one.
|
|
1287
|
+
when "\r", "\n" then return filter_found_nothing? ? escalate_to_search : (@filtering = false)
|
|
1288
|
+
when ESCAPE then (@filtering = false) || clear_filter
|
|
1289
|
+
when DELETE, "\b" then @filter.chop!
|
|
1290
|
+
when CTRL_C then @running = false
|
|
1291
|
+
else @filter << key if printable?(key)
|
|
1292
|
+
end
|
|
1293
|
+
reset_cursor
|
|
1294
|
+
end
|
|
1295
|
+
|
|
1296
|
+
def handle_query(key)
|
|
1297
|
+
case key
|
|
1298
|
+
when CTRL_C then return @running = false
|
|
1299
|
+
# Esc stops editing and nothing else. It used to clear the query and then,
|
|
1300
|
+
# on a second press, leave for the bundles view — but wanting to stop
|
|
1301
|
+
# typing is not wanting to leave: the results are right there, and the
|
|
1302
|
+
# arrows should reach them without the view changing underfoot.
|
|
1303
|
+
when ESCAPE then return leave_field
|
|
1304
|
+
when CTRL_U then return @query.clear # clear the line, as in a shell
|
|
1305
|
+
# Enter means "search" while the query has unsearched edits, and "open the
|
|
1306
|
+
# selected hit" once it does not. No mode flag decides this — whether the
|
|
1307
|
+
# typed query and the searched one agree is the whole state, and the
|
|
1308
|
+
# footer says which of the two the next Enter will do.
|
|
1309
|
+
when "\r", "\n" then return commit_search ? nil : open_hit
|
|
1310
|
+
when DELETE, "\b" then @query.chop!
|
|
1311
|
+
when DOWN then return move(1)
|
|
1312
|
+
when UP then return move(-1)
|
|
1313
|
+
when TAB then return switch(:bundles)
|
|
1314
|
+
else @query << key if printable?(key)
|
|
1315
|
+
end
|
|
1316
|
+
@cursor = 0
|
|
1317
|
+
@scroll = 0
|
|
1318
|
+
end
|
|
1319
|
+
|
|
1320
|
+
# A prompt takes either a line of text or a single confirming key.
|
|
1321
|
+
def handle_prompt(key)
|
|
1322
|
+
unless @prompt.free_text?
|
|
1323
|
+
pending = @prompt
|
|
1324
|
+
@prompt = nil
|
|
1325
|
+
@message = key.downcase == "y" ? resolve(pending) : "cancelled"
|
|
1326
|
+
return
|
|
1327
|
+
end
|
|
1328
|
+
|
|
1329
|
+
case key
|
|
1330
|
+
when ESCAPE, CTRL_C then (@prompt = nil) || (@message = "cancelled")
|
|
1331
|
+
when "\r", "\n"
|
|
1332
|
+
pending = @prompt
|
|
1333
|
+
@prompt = nil
|
|
1334
|
+
@message = resolve(pending)
|
|
1335
|
+
when DELETE, "\b" then @prompt.buffer.chop!
|
|
1336
|
+
else @prompt.buffer << key if printable?(key)
|
|
1337
|
+
end
|
|
1338
|
+
end
|
|
1339
|
+
|
|
1340
|
+
# A facet row narrows the graph; a concept row leaves for it. Selecting the
|
|
1341
|
+
# facet already in force clears it, so the same key toggles.
|
|
1342
|
+
def activate_graph_row
|
|
1343
|
+
entry = graph_selected
|
|
1344
|
+
return if entry.nil?
|
|
1345
|
+
|
|
1346
|
+
case entry[:kind]
|
|
1347
|
+
when :facet
|
|
1348
|
+
if facet_active?(entry[:field], entry[:value])
|
|
1349
|
+
@graph_facet = nil
|
|
1350
|
+
@message = "cleared the facet"
|
|
1351
|
+
else
|
|
1352
|
+
@graph_facet = { field: entry[:field], value: entry[:value] }
|
|
1353
|
+
@message = "graph narrowed to #{entry[:field]} #{entry[:value]}"
|
|
1354
|
+
end
|
|
1355
|
+
@cursor = graph_selectable.first.to_i
|
|
1356
|
+
when :concept
|
|
1357
|
+
open_concept(entry[:id])
|
|
1358
|
+
@message = "opened #{entry[:id]}"
|
|
1359
|
+
end
|
|
1360
|
+
end
|
|
1361
|
+
|
|
1362
|
+
# ── registry config ──────────────────────────────────────────────────────
|
|
1363
|
+
|
|
1364
|
+
# Carry out an answered prompt. Every registry write goes through here.
|
|
1365
|
+
def resolve(pending)
|
|
1366
|
+
message =
|
|
1367
|
+
case pending.kind
|
|
1368
|
+
when :add then resolve_add(pending)
|
|
1369
|
+
when :rename then resolve_rename(pending)
|
|
1370
|
+
when :remove then workspace.remove(pending.subject)
|
|
1371
|
+
when :group then workspace.create_group(pending.buffer, workspace.scope)
|
|
1372
|
+
when :ungroup then resolve_ungroup(pending)
|
|
1373
|
+
end
|
|
1374
|
+
|
|
1375
|
+
invalidate
|
|
1376
|
+
clamp_cursor
|
|
1377
|
+
message
|
|
1378
|
+
end
|
|
1379
|
+
|
|
1380
|
+
# Both halves come from the prompt rather than from the cursor: a write that
|
|
1381
|
+
# re-read the pane would act on whatever is selected *now*, not on what the
|
|
1382
|
+
# question named.
|
|
1383
|
+
def resolve_ungroup(pending)
|
|
1384
|
+
slug, member = pending.subject.split(MEMBER_REF, 2)
|
|
1385
|
+
message = keeping_group_scoped(slug) { workspace.remove_from_group(slug, [ member ]) }
|
|
1386
|
+
@member_cursor = 0
|
|
1387
|
+
# Nothing left to point at, so step back out rather than hold focus in a pane
|
|
1388
|
+
# describing a group that is gone. #clamp_cursor catches the same case for a
|
|
1389
|
+
# group emptied any other way; this is the one that knows *which* group.
|
|
1390
|
+
#
|
|
1391
|
+
# Only from the members, though: the same question can now be asked from the
|
|
1392
|
+
# bundles pane, and answering it is no reason to move that reader elsewhere.
|
|
1393
|
+
@pane = :groups if @pane == :members && workspace.group(slug).nil?
|
|
1394
|
+
message
|
|
1395
|
+
end
|
|
1396
|
+
|
|
1397
|
+
# A newly registered bundle is one the user just expressed interest in, so
|
|
1398
|
+
# it joins the scope and the cursor moves to it.
|
|
1399
|
+
def resolve_add(pending)
|
|
1400
|
+
before = workspace.entries.map(&:slug)
|
|
1401
|
+
message = workspace.add(pending.buffer)
|
|
1402
|
+
|
|
1403
|
+
added = workspace.entries.find { |entry| !before.include?(entry.slug) }
|
|
1404
|
+
if added
|
|
1405
|
+
# A reload keeps the scope that was there before, which cannot mention a
|
|
1406
|
+
# bundle that did not exist yet — so put the new one in explicitly.
|
|
1407
|
+
workspace.toggle_scope(added.slug) unless workspace.scoped?(added.slug)
|
|
1408
|
+
focus(added.slug)
|
|
1409
|
+
end
|
|
1410
|
+
message
|
|
1411
|
+
end
|
|
1412
|
+
|
|
1413
|
+
# Scope membership survives a rename. It is keyed by slug, but what the user
|
|
1414
|
+
# put in scope is the *bundle*, and its directory is what stays the same
|
|
1415
|
+
# across a rename — so reconcile on the directory, or the bundle silently
|
|
1416
|
+
# drops out of search the moment it is renamed.
|
|
1417
|
+
def resolve_rename(pending)
|
|
1418
|
+
dir = workspace.entry(pending.subject)&.dir
|
|
1419
|
+
message = workspace.rename(pending.subject, pending.buffer)
|
|
1420
|
+
|
|
1421
|
+
renamed = workspace.entries.find { |entry| entry.dir == dir }
|
|
1422
|
+
if renamed
|
|
1423
|
+
workspace.toggle_scope(renamed.slug) unless workspace.scoped?(renamed.slug)
|
|
1424
|
+
focus(renamed.slug)
|
|
1425
|
+
end
|
|
1426
|
+
message
|
|
1427
|
+
end
|
|
1428
|
+
|
|
1429
|
+
def ask(kind, label, free_text:, subject: nil)
|
|
1430
|
+
@prompt = Prompt.new(kind: kind, label: label, buffer: +"", subject: subject, free_text: free_text)
|
|
1431
|
+
end
|
|
1432
|
+
|
|
1433
|
+
# Both span a group, because okf's own `rename` and `del` do: one rename
|
|
1434
|
+
# cascades across every member list, and one `del` cascade-drops the slug and
|
|
1435
|
+
# deletes any group it empties. Nothing here has to know that — it just has to
|
|
1436
|
+
# pass the slug the cursor is on rather than assuming it is a bundle's.
|
|
1437
|
+
def ask_rename
|
|
1438
|
+
slug = selected_slug
|
|
1439
|
+
return if slug.nil?
|
|
1440
|
+
|
|
1441
|
+
ask(:rename, "rename @#{slug} to:", free_text: true, subject: slug)
|
|
1442
|
+
end
|
|
1443
|
+
|
|
1444
|
+
def ask_remove
|
|
1445
|
+
slug = selected_slug
|
|
1446
|
+
return if slug.nil?
|
|
1447
|
+
|
|
1448
|
+
what = detailing_group? ? "group @#{slug}" : "@#{slug}"
|
|
1449
|
+
ask(:remove, "remove #{what} from the registry? (y/n)", free_text: false, subject: slug)
|
|
1450
|
+
end
|
|
1451
|
+
|
|
1452
|
+
# The slug the focused pane has selected — a group's when the groups or member
|
|
1453
|
+
# pane holds focus, a bundle's otherwise.
|
|
1454
|
+
def selected_slug
|
|
1455
|
+
detailing_group? ? selected_group&.slug : selected_entry&.slug
|
|
1456
|
+
end
|
|
1457
|
+
|
|
1458
|
+
def set_default
|
|
1459
|
+
entry = selected_entry
|
|
1460
|
+
return if entry.nil?
|
|
1461
|
+
|
|
1462
|
+
@message = workspace.make_default(entry.slug)
|
|
1463
|
+
# Making a bundle the default moves it to the front, so the list reorders
|
|
1464
|
+
# under the cursor. Follow the bundle rather than the position, or the
|
|
1465
|
+
# selection silently lands on a different one.
|
|
1466
|
+
focus(entry.slug)
|
|
1467
|
+
end
|
|
1468
|
+
|
|
1469
|
+
def toggle_scope
|
|
1470
|
+
entry = selected_entry
|
|
1471
|
+
return if entry.nil?
|
|
1472
|
+
|
|
1473
|
+
workspace.toggle_scope(entry.slug)
|
|
1474
|
+
@search_hits_key = nil
|
|
1475
|
+
end
|
|
1476
|
+
|
|
1477
|
+
def focus(slug)
|
|
1478
|
+
index = visible_entries.index { |entry| entry.slug == slug }
|
|
1479
|
+
@cursor = index if index
|
|
1480
|
+
end
|
|
1481
|
+
|
|
1482
|
+
# ── switching bundles ────────────────────────────────────────────────────
|
|
1483
|
+
|
|
1484
|
+
# Enter in the bundles view: which pane has focus decides what it means.
|
|
1485
|
+
def open_registry_row
|
|
1486
|
+
return open_bundle unless detailing_group?
|
|
1487
|
+
|
|
1488
|
+
group = selected_group
|
|
1489
|
+
return if group.nil?
|
|
1490
|
+
|
|
1491
|
+
@message = workspace.scope_group(group.slug)
|
|
1492
|
+
@search_hits_key = nil
|
|
1493
|
+
end
|
|
1494
|
+
|
|
1495
|
+
# Enter on a bundle: make it the active one and go read it.
|
|
1496
|
+
def open_bundle
|
|
1497
|
+
entry = selected_entry
|
|
1498
|
+
return if entry.nil?
|
|
1499
|
+
|
|
1500
|
+
unless entry.loaded?
|
|
1501
|
+
@message = "@#{entry.slug} cannot be read: #{entry.error}"
|
|
1502
|
+
return
|
|
1503
|
+
end
|
|
1504
|
+
|
|
1505
|
+
activate(entry.slug)
|
|
1506
|
+
switch(:browse)
|
|
1507
|
+
@message = "@#{entry.slug} — #{model.concept_count} concepts"
|
|
1508
|
+
end
|
|
1509
|
+
|
|
1510
|
+
def activate(slug)
|
|
1511
|
+
return false unless workspace.switch(slug)
|
|
1512
|
+
|
|
1513
|
+
# The browse list and every scroll offset belong to the bundle that was
|
|
1514
|
+
# active; none of them mean anything against the new one.
|
|
1515
|
+
invalidate
|
|
1516
|
+
@filter = +""
|
|
1517
|
+
@graph_facet = nil
|
|
1518
|
+
@scroll = 0
|
|
1519
|
+
@detail_scroll = 0
|
|
1520
|
+
@content_scroll = 0
|
|
1521
|
+
@health_scroll = 0
|
|
1522
|
+
true
|
|
1523
|
+
end
|
|
1524
|
+
|
|
1525
|
+
def invalidate
|
|
1526
|
+
@list_entries_key = nil
|
|
1527
|
+
@search_hits_key = nil
|
|
1528
|
+
end
|
|
1529
|
+
|
|
1530
|
+
def printable?(key)
|
|
1531
|
+
key.length == 1 && key.ord >= 32 && key.ord < 127
|
|
1532
|
+
end
|
|
1533
|
+
|
|
1534
|
+
# A filter that is looking for something and finding nothing — the moment
|
|
1535
|
+
# where offering the wider search is worth the line it costs.
|
|
1536
|
+
#
|
|
1537
|
+
# The registry counts too. A filter there looks through a dozen slugs and the
|
|
1538
|
+
# groups beside them, which is a narrow thing to be typing: a term matching none
|
|
1539
|
+
# of them is far more likely a question about what the bundles *say* than about
|
|
1540
|
+
# what one is called. Same key, same escalation.
|
|
1541
|
+
#
|
|
1542
|
+
# Both panes have to be empty, not just the focused one. A filter matching a
|
|
1543
|
+
# group and no bundle has found something — the first cut of this read only the
|
|
1544
|
+
# bundles pane, and swallowed the Enter that accepts such a filter.
|
|
1545
|
+
def filter_found_nothing?
|
|
1546
|
+
return false if @filter.empty?
|
|
1547
|
+
|
|
1548
|
+
case @view
|
|
1549
|
+
when :browse then selectable_indices.empty?
|
|
1550
|
+
when :bundles then visible_entries.empty? && visible_groups.empty?
|
|
1551
|
+
else false
|
|
1552
|
+
end
|
|
1553
|
+
end
|
|
1554
|
+
|
|
1555
|
+
# What the escalation is actually worth, which is not the same in both cases:
|
|
1556
|
+
# the browse filter matches titles, ids, types and tags in *this* bundle,
|
|
1557
|
+
# while search reads bodies too and spans every bundle. With one bundle open
|
|
1558
|
+
# the win is the full text; with several it is also the reach.
|
|
1559
|
+
def escalation_offer
|
|
1560
|
+
return nil unless filter_found_nothing?
|
|
1561
|
+
|
|
1562
|
+
if workspace.entries.length > 1
|
|
1563
|
+
"search all #{workspace.entries.length} bundles"
|
|
1564
|
+
else
|
|
1565
|
+
"run a full-text search"
|
|
1566
|
+
end
|
|
1567
|
+
end
|
|
1568
|
+
|
|
1569
|
+
# Carry the filter term over to the search view and run it. The scope widens
|
|
1570
|
+
# to every bundle, because "not in this one" is the whole reason to escalate
|
|
1571
|
+
# — and the header keeps saying so, so the widening is visible and undoable.
|
|
1572
|
+
def escalate_to_search
|
|
1573
|
+
term = @filter.dup
|
|
1574
|
+
return if term.empty?
|
|
1575
|
+
|
|
1576
|
+
@filtering = false
|
|
1577
|
+
@filter = +""
|
|
1578
|
+
workspace.scope_all
|
|
1579
|
+
|
|
1580
|
+
switch(:search)
|
|
1581
|
+
@query = term
|
|
1582
|
+
commit_search
|
|
1583
|
+
@message = "searched #{workspace.scope.length} bundles for “#{term}”"
|
|
1584
|
+
end
|
|
1585
|
+
|
|
1586
|
+
# One key, three fields — whichever the focus makes it. The list pane and
|
|
1587
|
+
# the body pane are different things to look through, so `/` in each looks
|
|
1588
|
+
# through the one under the cursor rather than always the list.
|
|
1589
|
+
def start_typing
|
|
1590
|
+
return focus_field if @view == :search
|
|
1591
|
+
return start_find if findable?
|
|
1592
|
+
|
|
1593
|
+
start_filter
|
|
1594
|
+
end
|
|
1595
|
+
|
|
1596
|
+
# Is the cursor in a rendered document rather than a list? Two kinds, because
|
|
1597
|
+
# they scroll on different offsets: a concept body in the browse detail pane,
|
|
1598
|
+
# and the one-long-page views. Both are things to read, so both take a find.
|
|
1599
|
+
def reading_body?
|
|
1600
|
+
@view == :browse && @pane == :detail
|
|
1601
|
+
end
|
|
1602
|
+
|
|
1603
|
+
def reading_page?
|
|
1604
|
+
CONTENT_VIEWS.include?(@view)
|
|
1605
|
+
end
|
|
1606
|
+
|
|
1607
|
+
def findable?
|
|
1608
|
+
reading_body? || reading_page?
|
|
1609
|
+
end
|
|
1610
|
+
|
|
1611
|
+
def start_find
|
|
1612
|
+
@finding = true
|
|
1613
|
+
@find = +""
|
|
1614
|
+
@find_index = 0
|
|
1615
|
+
end
|
|
1616
|
+
|
|
1617
|
+
def start_filter
|
|
1618
|
+
return unless FILTERABLE_VIEWS.include?(@view)
|
|
1619
|
+
|
|
1620
|
+
@filtering = true
|
|
1621
|
+
@filter = +""
|
|
1622
|
+
end
|
|
1623
|
+
|
|
1624
|
+
# Esc undoes the narrowing in force, innermost first: a graph facet before
|
|
1625
|
+
# the filter, so one key backs out of both without leaving the view.
|
|
1626
|
+
def toggle_pane
|
|
1627
|
+
return cycle_bundles_pane if @view == :bundles
|
|
1628
|
+
|
|
1629
|
+
@pane = @pane == :list ? :detail : :list
|
|
1630
|
+
# The two graph panes hold different lists, so a cursor carried across
|
|
1631
|
+
# would point at an arbitrary row — or at a heading, where it vanishes.
|
|
1632
|
+
@cursor = graph_selectable.first.to_i if @view == :graph
|
|
1633
|
+
end
|
|
1634
|
+
|
|
1635
|
+
# bundles → groups → members → bundles, skipping what is not there: an empty
|
|
1636
|
+
# registry has no groups pane to reach, and a group with no members has no member
|
|
1637
|
+
# pane. Skipping rather than stopping, so Tab never appears to do nothing.
|
|
1638
|
+
def cycle_bundles_pane
|
|
1639
|
+
order = [ :bundles ]
|
|
1640
|
+
order << :groups unless visible_groups.empty?
|
|
1641
|
+
order << :members unless selected_group.nil? || group_members.empty?
|
|
1642
|
+
|
|
1643
|
+
return @message = "nothing else to step into — no groups here yet" if order.length == 1
|
|
1644
|
+
|
|
1645
|
+
@pane = order[(order.index(@pane) || 0) + 1] || order.first
|
|
1646
|
+
@member_cursor = 0 if @pane == :members
|
|
1647
|
+
@pane
|
|
1648
|
+
end
|
|
1649
|
+
|
|
1650
|
+
def handle_escape
|
|
1651
|
+
return if @view == :search
|
|
1652
|
+
|
|
1653
|
+
# Esc ends the innermost thing first, and a submitted find is still one of
|
|
1654
|
+
# them: Enter only releases the field, it does not end the find — the term
|
|
1655
|
+
# stays lit and n/N still step through it. Falling through to the list's
|
|
1656
|
+
# Esc from there resets the cursor and throws the reader back to the first
|
|
1657
|
+
# file, which is the one thing a find must never cost.
|
|
1658
|
+
if findable? && !@find.empty?
|
|
1659
|
+
clear_find
|
|
1660
|
+
return
|
|
1661
|
+
end
|
|
1662
|
+
|
|
1663
|
+
if @view == :graph && @graph_facet
|
|
1664
|
+
@graph_facet = nil
|
|
1665
|
+
@cursor = graph_selectable.first.to_i
|
|
1666
|
+
@message = "cleared the facet"
|
|
1667
|
+
return
|
|
1668
|
+
end
|
|
1669
|
+
|
|
1670
|
+
# Each pane is a layer, and all of them are innermore than the filter.
|
|
1671
|
+
if @view == :bundles && @pane == :members
|
|
1672
|
+
@pane = :groups
|
|
1673
|
+
@member_cursor = 0
|
|
1674
|
+
return
|
|
1675
|
+
end
|
|
1676
|
+
|
|
1677
|
+
if @view == :bundles && @pane == :groups
|
|
1678
|
+
@pane = :bundles
|
|
1679
|
+
return
|
|
1680
|
+
end
|
|
1681
|
+
|
|
1682
|
+
clear_filter
|
|
1683
|
+
end
|
|
1684
|
+
|
|
1685
|
+
def clear_filter
|
|
1686
|
+
@filter = +""
|
|
1687
|
+
reset_cursor
|
|
1688
|
+
end
|
|
1689
|
+
|
|
1690
|
+
def switch(target)
|
|
1691
|
+
# Entering search does *not* grab the field. A view that swallows every
|
|
1692
|
+
# printable key the moment you arrive turns "3" into query text and takes
|
|
1693
|
+
# the number keys away from navigation. Typing starts on `/` here, the
|
|
1694
|
+
# same key that starts the filter in browse.
|
|
1695
|
+
@search_focus = :results if target == :search
|
|
1696
|
+
@view = target
|
|
1697
|
+
# The filter described the view being left, not the one being entered.
|
|
1698
|
+
@filtering = false
|
|
1699
|
+
@filter = +""
|
|
1700
|
+
@graph_facet = nil
|
|
1701
|
+
reset_cursor
|
|
1702
|
+
@pane = target == :bundles ? :bundles : :list if %i[browse bundles].include?(target)
|
|
1703
|
+
end
|
|
1704
|
+
|
|
1705
|
+
def reset_cursor
|
|
1706
|
+
@pane = :bundles if @view == :bundles
|
|
1707
|
+
@group_cursor = 0
|
|
1708
|
+
@member_cursor = 0
|
|
1709
|
+
@cursor = 0
|
|
1710
|
+
@scroll = 0
|
|
1711
|
+
@detail_scroll = 0
|
|
1712
|
+
@content_scroll = 0
|
|
1713
|
+
@health_scroll = 0
|
|
1714
|
+
@pane = :list if @view == :health
|
|
1715
|
+
@cursor = selectable_indices.first.to_i if @view == :browse
|
|
1716
|
+
@cursor = active_index if @view == :bundles
|
|
1717
|
+
@cursor = graph_selectable.first.to_i if @view == :graph
|
|
1718
|
+
end
|
|
1719
|
+
|
|
1720
|
+
# Opening the bundles view puts the cursor on the bundle currently active,
|
|
1721
|
+
# which is the one the user is most likely reasoning about.
|
|
1722
|
+
def active_index
|
|
1723
|
+
visible_entries.index { |entry| entry.slug == workspace.active_slug } || 0
|
|
1724
|
+
end
|
|
1725
|
+
|
|
1726
|
+
def items_length
|
|
1727
|
+
case @view
|
|
1728
|
+
when :bundles then visible_entries.length
|
|
1729
|
+
when :search then search_hits.length
|
|
1730
|
+
else 0
|
|
1731
|
+
end
|
|
1732
|
+
end
|
|
1733
|
+
|
|
1734
|
+
def clamp_cursor
|
|
1735
|
+
count = items_length
|
|
1736
|
+
@cursor = count.zero? ? 0 : [ [ @cursor, count - 1 ].min, 0 ].max
|
|
1737
|
+
return unless @view == :bundles
|
|
1738
|
+
|
|
1739
|
+
# The other two panes are rebuilt by the same writes, so they need clamping
|
|
1740
|
+
# for the same reason — a group removed under the cursor leaves it past the end.
|
|
1741
|
+
groups = visible_groups.length
|
|
1742
|
+
@group_cursor = groups.zero? ? 0 : [ [ @group_cursor, groups - 1 ].min, 0 ].max
|
|
1743
|
+
members = group_members.length
|
|
1744
|
+
@member_cursor = members.zero? ? 0 : [ [ @member_cursor, members - 1 ].min, 0 ].max
|
|
1745
|
+
# A pane with nothing in it cannot hold focus.
|
|
1746
|
+
@pane = :bundles if (@pane == :groups && groups.zero?) || (@pane == :members && members.zero?)
|
|
1747
|
+
end
|
|
1748
|
+
|
|
1749
|
+
def step(position, delta, length)
|
|
1750
|
+
[ [ position + delta, 0 ].max, [ length - 1, 0 ].max ].min
|
|
1751
|
+
end
|
|
1752
|
+
|
|
1753
|
+
# Move the selection, skipping the directory headings so the cursor only ever
|
|
1754
|
+
# lands on something selectable.
|
|
1755
|
+
def move(delta)
|
|
1756
|
+
# health, graph and help are one long page with nothing to select, so the
|
|
1757
|
+
# keys scroll them instead of moving a cursor.
|
|
1758
|
+
if CONTENT_VIEWS.include?(@view)
|
|
1759
|
+
key = content_scroll_key
|
|
1760
|
+
set_scroll(key, [ scroll_for(key) + delta, 0 ].max)
|
|
1761
|
+
return
|
|
1762
|
+
end
|
|
1763
|
+
|
|
1764
|
+
if @view == :graph
|
|
1765
|
+
indices = graph_selectable
|
|
1766
|
+
return if indices.empty?
|
|
1767
|
+
|
|
1768
|
+
position = indices.index(@cursor) || 0
|
|
1769
|
+
@cursor = indices[[ [ position + delta, 0 ].max, indices.length - 1 ].min]
|
|
1770
|
+
return
|
|
1771
|
+
end
|
|
1772
|
+
|
|
1773
|
+
if @view == :bundles
|
|
1774
|
+
case @pane
|
|
1775
|
+
when :members then @member_cursor = step(@member_cursor, delta, group_members.length)
|
|
1776
|
+
when :groups then @group_cursor = step(@group_cursor, delta, visible_groups.length)
|
|
1777
|
+
else @cursor = step(@cursor, delta, visible_entries.length)
|
|
1778
|
+
end
|
|
1779
|
+
return
|
|
1780
|
+
end
|
|
1781
|
+
|
|
1782
|
+
if @view == :browse && @pane == :detail
|
|
1783
|
+
@detail_scroll = [ @detail_scroll + delta, 0 ].max
|
|
1784
|
+
return
|
|
1785
|
+
end
|
|
1786
|
+
|
|
1787
|
+
if @view == :search
|
|
1788
|
+
return if search_hits.empty?
|
|
1789
|
+
|
|
1790
|
+
@cursor = [ [ @cursor + delta, 0 ].max, search_hits.length - 1 ].min
|
|
1791
|
+
return
|
|
1792
|
+
end
|
|
1793
|
+
|
|
1794
|
+
indices = selectable_indices
|
|
1795
|
+
return if indices.empty?
|
|
1796
|
+
|
|
1797
|
+
position = indices.index(@cursor) || 0
|
|
1798
|
+
position = [ [ position + delta, 0 ].max, indices.length - 1 ].min
|
|
1799
|
+
@cursor = indices[position]
|
|
1800
|
+
@detail_scroll = 0
|
|
1801
|
+
end
|
|
1802
|
+
|
|
1803
|
+
def jump(where)
|
|
1804
|
+
if CONTENT_VIEWS.include?(@view)
|
|
1805
|
+
# `G` on a scrolling page: a deliberately large offset, clamped to the
|
|
1806
|
+
# real end by content_scroll_for once the view reports its length.
|
|
1807
|
+
set_scroll(content_scroll_key, where == :first ? 0 : 1_000_000)
|
|
1808
|
+
return
|
|
1809
|
+
end
|
|
1810
|
+
|
|
1811
|
+
if @view == :graph
|
|
1812
|
+
indices = graph_selectable
|
|
1813
|
+
@cursor = (where == :first ? indices.first : indices.last).to_i
|
|
1814
|
+
return
|
|
1815
|
+
end
|
|
1816
|
+
|
|
1817
|
+
if @view == :bundles
|
|
1818
|
+
length =
|
|
1819
|
+
case @pane
|
|
1820
|
+
when :members then group_members.length
|
|
1821
|
+
when :groups then visible_groups.length
|
|
1822
|
+
else visible_entries.length
|
|
1823
|
+
end
|
|
1824
|
+
last = [ length - 1, 0 ].max
|
|
1825
|
+
position = where == :first ? 0 : last
|
|
1826
|
+
|
|
1827
|
+
case @pane
|
|
1828
|
+
when :members then @member_cursor = position
|
|
1829
|
+
when :groups then @group_cursor = position
|
|
1830
|
+
else @cursor = position
|
|
1831
|
+
end
|
|
1832
|
+
return
|
|
1833
|
+
end
|
|
1834
|
+
|
|
1835
|
+
if @view == :search
|
|
1836
|
+
@cursor = where == :first ? 0 : [ items_length - 1, 0 ].max
|
|
1837
|
+
return
|
|
1838
|
+
end
|
|
1839
|
+
|
|
1840
|
+
indices = selectable_indices
|
|
1841
|
+
return if indices.empty?
|
|
1842
|
+
|
|
1843
|
+
@cursor = where == :first ? indices.first : indices.last
|
|
1844
|
+
@detail_scroll = 0
|
|
1845
|
+
end
|
|
1846
|
+
|
|
1847
|
+
# Enter on a search hit. Across bundles the hit may belong to one that is not
|
|
1848
|
+
# active, so switching to it is part of opening — otherwise browse would
|
|
1849
|
+
# select an id in the wrong bundle, or in none.
|
|
1850
|
+
def open_hit
|
|
1851
|
+
hit = search_hits[@cursor]
|
|
1852
|
+
return if hit.nil?
|
|
1853
|
+
|
|
1854
|
+
slug = hit[:slug] || workspace.active_slug
|
|
1855
|
+
switched = slug != workspace.active_slug
|
|
1856
|
+
activate(slug) if switched
|
|
1857
|
+
|
|
1858
|
+
open_concept(hit[:id])
|
|
1859
|
+
@message = switched ? "opened @#{slug} — #{hit[:id]}" : "opened #{hit[:id]}"
|
|
1860
|
+
end
|
|
1861
|
+
|
|
1862
|
+
# Land in browse with `id` selected and its body showing. Shared by opening
|
|
1863
|
+
# a search hit, following a concept out of the graph, and following a link
|
|
1864
|
+
# out of a document.
|
|
1865
|
+
def open_concept(id)
|
|
1866
|
+
push_trail
|
|
1867
|
+
@view = :browse
|
|
1868
|
+
@pane = :detail
|
|
1869
|
+
@filtering = false
|
|
1870
|
+
@filter = +""
|
|
1871
|
+
@detail_scroll = 0
|
|
1872
|
+
@list_entries_key = nil
|
|
1873
|
+
|
|
1874
|
+
index = list_entries.index { |entry| entry[:kind] == :concept && entry[:row][:id] == id }
|
|
1875
|
+
@cursor = index || selectable_indices.first.to_i
|
|
1876
|
+
@scroll = 0
|
|
1877
|
+
end
|
|
1878
|
+
|
|
1879
|
+
# The reserved-file twin of open_concept. An index.md is a bundle's way in
|
|
1880
|
+
# (§6), so a link that points at one has to land the same way a concept does.
|
|
1881
|
+
def open_reserved(path)
|
|
1882
|
+
push_trail
|
|
1883
|
+
@view = :browse
|
|
1884
|
+
@pane = :detail
|
|
1885
|
+
@filtering = false
|
|
1886
|
+
@filter = +""
|
|
1887
|
+
@detail_scroll = 0
|
|
1888
|
+
@list_entries_key = nil
|
|
1889
|
+
|
|
1890
|
+
index = list_entries.index { |entry| entry[:kind] == :reserved && entry[:path] == path }
|
|
1891
|
+
@cursor = index || selectable_indices.first.to_i
|
|
1892
|
+
@scroll = 0
|
|
1893
|
+
end
|
|
1894
|
+
|
|
1895
|
+
def reload
|
|
1896
|
+
workspace.reload
|
|
1897
|
+
@body_cache = {}
|
|
1898
|
+
# The trail holds ids and offsets from before the reload; a bundle that
|
|
1899
|
+
# changed on disk may no longer have them.
|
|
1900
|
+
@trail = []
|
|
1901
|
+
invalidate
|
|
1902
|
+
reset_cursor
|
|
1903
|
+
@message = "reloaded #{workspace.entries.length} bundles"
|
|
1904
|
+
rescue StandardError => e
|
|
1905
|
+
@message = "reload failed: #{e.message}"
|
|
1906
|
+
end
|
|
1907
|
+
end
|
|
1908
|
+
end
|