@young1lin/dsh-ui-gitworkbench 0.1.12 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@young1lin/dsh-ui-gitworkbench",
3
- "version": "0.1.12",
3
+ "version": "0.1.13",
4
4
  "description": "Out-of-tree dsh web UI plugin: a session-header git workbench chip opening a drawer with the file tree, per-file diff, history, compare, staging, commit, and sync (fetch/pull/push).",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -29,13 +29,15 @@ import { Compartment, EditorState, Facet, StateEffect, StateField, type Extensio
29
29
  import { EditorView, ViewPlugin, keymap, lineNumbers, highlightActiveLine, Decoration, type DecorationSet, type PluginValue, type ViewUpdate } from '@codemirror/view'
30
30
  import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'
31
31
  import { indentUnit } from '@codemirror/language'
32
- import { search, searchKeymap } from '@codemirror/search'
32
+ import { getSearchQuery, search, searchKeymap, searchPanelOpen, type SearchQuery } from '@codemirror/search'
33
33
 
34
34
  import css from './GitWorkbenchPanel.module.css'
35
35
  import { blameCompartment, blameField, blameGutter, setBlame } from './blame-gutter.ts'
36
36
  import { bufferDiff } from './cm-diff.ts'
37
+ import { SEARCH_PANEL_THEME } from './cm-search-theme.ts'
37
38
  import { lineTokenRanges } from './cm-tokens.ts'
38
39
  import type { HighlightRun } from './highlight.ts'
40
+ import { EMPTY_INDEX, formatCount, indexMatches, ordinalAt, type MatchIndex } from './search-count.ts'
39
41
  import type { BlameLine } from './GitWorkbenchPanel.tsx'
40
42
 
41
43
  /**
@@ -140,6 +142,111 @@ abstract class IdleLayer implements PluginValue {
140
142
  protected abstract build(view: EditorView): DecorationSet
141
143
  }
142
144
 
145
+ /**
146
+ * `3/128` beside the find field.
147
+ *
148
+ * `@codemirror/search` ships no count, which leaves the reader unable to tell
149
+ * a query that found nothing from one whose matches are all below the fold.
150
+ * The library exports no panel class to subclass either, so the label is put
151
+ * INTO the panel it already builds — one span, mounted after the search field
152
+ * and removed with the plugin.
153
+ *
154
+ * Counting walks the document, so it never happens on the keystroke path: the
155
+ * walk is deferred by the same idle the paint layers use, and it stops at
156
+ * `MATCH_CAP`. Moving between matches touches no document at all — the offsets
157
+ * are kept, and the ordinal is a binary search over them.
158
+ *
159
+ * While a new count is pending the previous number stays up rather than
160
+ * blanking. A query grows a character at a time, so the number it replaces is
161
+ * a near neighbour of the one arriving; blanking instead would collapse the
162
+ * span and shuffle the buttons beside it on every keystroke.
163
+ */
164
+ class SearchCount implements PluginValue {
165
+ private index: MatchIndex = EMPTY_INDEX
166
+ private spec: string | undefined
167
+ private timer: ReturnType<typeof setTimeout> | undefined
168
+ private readonly label = document.createElement('span')
169
+
170
+ constructor(private readonly view: EditorView) {
171
+ this.label.className = 'cm-gwSearchCount'
172
+ // The count changes without the reader moving focus, which is exactly what
173
+ // a live region is for.
174
+ this.label.setAttribute('aria-live', 'polite')
175
+ this.sync(view, true)
176
+ }
177
+
178
+ update(update: ViewUpdate): void {
179
+ if (!searchPanelOpen(update.state)) {
180
+ // Closed, or never opened. Forget the query so reopening recounts rather
181
+ // than showing a number for a document that has since been edited.
182
+ this.spec = undefined
183
+ this.index = EMPTY_INDEX
184
+ this.label.remove()
185
+ return
186
+ }
187
+ this.sync(update.view, update.docChanged)
188
+ }
189
+
190
+ /** A view is destroyed on every file switch; a timer that outlives one is a
191
+ * leak that also writes into a panel nobody is looking at. */
192
+ destroy(): void {
193
+ if (this.timer !== undefined) clearTimeout(this.timer)
194
+ this.timer = undefined
195
+ this.label.remove()
196
+ }
197
+
198
+ private sync(view: EditorView, docChanged: boolean): void {
199
+ this.mount(view)
200
+ const spec = specOf(getSearchQuery(view.state))
201
+ if (docChanged || spec !== this.spec) {
202
+ this.spec = spec
203
+ this.defer()
204
+ }
205
+ this.paint(view)
206
+ }
207
+
208
+ private mount(view: EditorView): void {
209
+ const panel = view.dom.querySelector('.cm-panel.cm-search')
210
+ if (panel === null || this.label.parentElement === panel) return
211
+ // Beside the field it is about, rather than at the end of a row whose
212
+ // width the checkboxes decide.
213
+ const field = panel.querySelector('input[name="search"]')
214
+ if (field === null) panel.append(this.label)
215
+ else field.after(this.label)
216
+ }
217
+
218
+ private defer(): void {
219
+ if (this.timer !== undefined) clearTimeout(this.timer)
220
+ this.timer = setTimeout(() => {
221
+ this.timer = undefined
222
+ const query = getSearchQuery(this.view.state)
223
+ this.index = query.valid
224
+ ? indexMatches(query.getCursor(this.view.state.doc))
225
+ : EMPTY_INDEX
226
+ this.paint(this.view)
227
+ }, REPAINT_IDLE_MS)
228
+ }
229
+
230
+ private paint(view: EditorView): void {
231
+ // An empty or unparseable query has nothing to report, and `0/0` under a
232
+ // field the reader has not typed in yet is noise.
233
+ if (!getSearchQuery(view.state).valid) {
234
+ this.label.textContent = ''
235
+ return
236
+ }
237
+ const at = view.state.selection.main.from
238
+ this.label.textContent = formatCount(this.index, ordinalAt(this.index, at))
239
+ }
240
+ }
241
+
242
+ /** What decides the match set. `replace` is part of the query and changes none
243
+ * of it, so a recount on every keystroke in the replace field is waste. */
244
+ function specOf(query: SearchQuery): string {
245
+ return [query.search, query.caseSensitive, query.regexp, query.wholeWord, query.literal].join('\u0000')
246
+ }
247
+
248
+ const searchCount = ViewPlugin.fromClass(SearchCount)
249
+
143
250
  /** The lines the view is about to show, 0-based and half-open. */
144
251
  function viewportLines(view: EditorView): { from: number; to: number } {
145
252
  const doc = view.state.doc
@@ -265,13 +372,28 @@ const paneTheme = EditorView.theme({
265
372
  fontVariantLigatures: 'none',
266
373
  },
267
374
  '.cm-content': { padding: '0', caretColor: 'var(--gs-accent)' },
375
+ // Opaque, and it has to be. The gutter plugin writes `position: sticky`
376
+ // as an INLINE style, so the numbers hold the pane's left edge while the
377
+ // code scrolls sideways underneath them — with a transparent fill that is
378
+ // not a gutter, it is a smear of code behind the line numbers. The library
379
+ // ships `#f5f5f5` here for the same reason; this is that fill, restated in
380
+ // the pane's own ground so every palette clothes it. Both hosts sit on
381
+ // `--gs-surface` (`.fbMain` in Files, `.diffPane` in Changes), and the cells
382
+ // painted a colour of their own — the active line, a changed line — paint
383
+ // over it exactly as they did over the transparency.
268
384
  '.cm-gutters': {
269
- backgroundColor: 'transparent',
385
+ backgroundColor: 'var(--gs-surface)',
270
386
  color: 'var(--gs-fg-faint)',
271
387
  border: 'none',
272
388
  paddingRight: '8px',
389
+ // The fill has to reach further left than the gutter does. `left: 0` sticks
390
+ // it to the scrollport's CONTENT box, while the pane clips at its PADDING
391
+ // box — so the code keeps travelling through the pane's own left padding
392
+ // and surfaces beside the numbers. The bleed covers that strip; it is the
393
+ // ground colour, and the pane clips it, so over-reaching costs nothing.
394
+ boxShadow: '-16px 0 0 0 var(--gs-surface)',
273
395
  },
274
- '.cm-activeLine': { backgroundColor: 'var(--gs-hover)' },
396
+ '.cm-activeLine': { backgroundColor: 'var(--gs-raise)' },
275
397
  // Same tints the diff columns use, so a line the reader just typed reads as
276
398
  // the same kind of thing as a line git already knows about.
277
399
  '.cm-gwChanged': { backgroundColor: 'var(--gs-add-line)' },
@@ -295,6 +417,9 @@ const paneTheme = EditorView.theme({
295
417
  whiteSpace: 'nowrap',
296
418
  cursor: 'pointer',
297
419
  },
420
+ // Ctrl/Cmd+F. Spread rather than written here so a test can read the spec
421
+ // without loading React and a CSS Module; see `cm-search-theme.ts`.
422
+ ...SEARCH_PANEL_THEME,
298
423
  })
299
424
 
300
425
  export function CodeEditor({ value, original, onChange, paint, indent, ariaLabel, onSave, blame, notCommitted, readOnly, onBlameClick }: {
@@ -358,6 +483,7 @@ export function CodeEditor({ value, original, onChange, paint, indent, ariaLabel
358
483
  lineNumbers(),
359
484
  history(),
360
485
  search({ top: true }),
486
+ searchCount,
361
487
  highlightActiveLine(),
362
488
  paintCompartment.of(paintFacet.of(paint)),
363
489
  painter,
@@ -0,0 +1,250 @@
1
+ /**
2
+ * The Ctrl/Cmd+F panel, restated in the drawer's own controls.
3
+ *
4
+ * `@codemirror/search` ships the panel with the library's stock chrome, and
5
+ * none of it follows a palette: the strip is `#f5f5f5` with black text (or
6
+ * `#333338` with white), the buttons carry a grey `linear-gradient` and a 1px
7
+ * radius, the fields are `1px solid silver`, the ticks are whatever the
8
+ * platform draws, and a match is `#ffff0054`. Worse, form controls do not
9
+ * inherit a font, and the library asks for `font-size: 70%` without naming a
10
+ * family — so the panel renders in the browser's default face at a size no
11
+ * other control in the drawer uses. Over a Nord or a cyberpunk drawer the
12
+ * strip stays light grey with black text on it.
13
+ *
14
+ * So the panel is dressed here, and ONLY through `--gs-*` tokens: every
15
+ * palette then clothes it for free, exactly as it clothes the pane around it.
16
+ * None of the shapes are invented for this panel — each is the one already on
17
+ * screen beside it:
18
+ *
19
+ * - the strip follows `.blockBar`, the drawer's other float over code, where
20
+ * `--gs-surface-2` plus a soft shadow are what keep it readable.
21
+ * - the buttons follow the shared compact control (`.treeIcon`,
22
+ * `.funnelPreset`, `.layoutButton`): `--gs-h-compact`, `--gs-pad-compact`,
23
+ * `--gs-r-control`, `--gs-t-dense`, and the same hover.
24
+ * - the fields follow the tree's own filter box, `.fbSearch`.
25
+ * - the ticks follow `.funnelRow input[type=checkbox]`: `appearance: none`
26
+ * and a rotated border for the mark, so nothing ships as an asset and every
27
+ * state recolors with the theme.
28
+ *
29
+ * Layout is the other half, and it stays in INLINE FLOW on purpose. The
30
+ * obvious answer — make the panel a wrapping flex row with one gap — costs
31
+ * the find/replace break: the library separates the two halves with a `<br>`,
32
+ * and Blink gives a `<br>` no box of its own inside a flex container. Neither
33
+ * `flex-basis: 100%`, `width: 100%` nor `min-width: 100%` on it makes the
34
+ * replace field start a line (measured on the running app; all four left it
35
+ * beside `by word`). So the controls stay inline-level atoms instead, and
36
+ * carry the rhythm the library asked for with `.2em .6em .2em 0` in a single
37
+ * margin. Wrapping still works when the pane is narrow: an atomic inline is a
38
+ * line-break opportunity, which is what lets the strip reflow in the 190px
39
+ * the Changes column drags down to.
40
+ *
41
+ * Kept out of `CodeEditor.tsx` so a test can read it: that file pulls React
42
+ * and a CSS Module, and neither loads under vitest.
43
+ *
44
+ * @module @young1lin/dsh-ui-gitworkbench/client/cm-search-theme
45
+ */
46
+
47
+ /** A CodeMirror theme spec, narrowed to what this module writes: one flat
48
+ * declaration block per selector, no nesting and no at-rules. */
49
+ export type ThemeSpec = Readonly<Record<string, Readonly<Record<string, string>>>>
50
+
51
+ /** Selector prefix for everything inside the panel. Spelled out per rule
52
+ * rather than nested, because CodeMirror's style builder only nests under a
53
+ * `&`, and a flat key is what a test can read back. */
54
+ const PANEL = '.cm-panel.cm-search'
55
+
56
+ /** Between two controls on a row, and between the find row and the replace
57
+ * row. The row gap is carried as a bottom margin per control, because inline
58
+ * flow has no `gap`; the panel's bottom padding is short by exactly this. */
59
+ const GAP = '6px'
60
+ const ROW_GAP = '6px'
61
+
62
+ export const SEARCH_PANEL_THEME: ThemeSpec = {
63
+ /* The strip. `.cm-panels` is the element the library paints, so the
64
+ override belongs there rather than on the panel inside it. The shadow is
65
+ `.blockBar`'s: this is a sticky bar with code scrolling under it, and a
66
+ border alone leaves the two planes touching. */
67
+ '.cm-panels': {
68
+ backgroundColor: 'var(--gs-surface-2)',
69
+ color: 'var(--gs-fg)',
70
+ boxShadow: '0 4px 14px var(--gs-shadow)',
71
+ },
72
+ '.cm-panels-top': { borderBottom: '1px solid var(--gs-border)' },
73
+ '.cm-panels-bottom': { borderTop: '1px solid var(--gs-border)' },
74
+
75
+ [PANEL]: {
76
+ position: 'relative',
77
+ /* The right gutter is the close button's: it is positioned over it, so
78
+ nothing may wrap underneath. The bottom is short by ROW_GAP, which every
79
+ control carries below itself; the two add back up to the top. */
80
+ padding: '8px 32px 2px 10px',
81
+ lineHeight: '1',
82
+ },
83
+ /* One rhythm for the whole strip, replacing the library's `.2em .6em .2em 0`.
84
+ `vertical-align: top` with one height is what makes a row of controls a
85
+ row rather than four boxes on a shared baseline. */
86
+ [`${PANEL} input, ${PANEL} button, ${PANEL} label`]: {
87
+ boxSizing: 'border-box',
88
+ verticalAlign: 'top',
89
+ margin: `0 ${GAP} ${ROW_GAP} 0`,
90
+ },
91
+
92
+ [`${PANEL} .cm-textfield`]: {
93
+ /* A column rather than a share of the row: find and replace are read as a
94
+ pair, and a pair that does not start and end at the same x is noise. */
95
+ width: '220px',
96
+ maxWidth: '100%',
97
+ boxSizing: 'border-box',
98
+ height: 'var(--gs-h-compact)',
99
+ padding: '0 8px',
100
+ font: 'inherit',
101
+ fontSize: 'var(--gs-t-dense)',
102
+ color: 'var(--gs-fg)',
103
+ backgroundColor: 'var(--gs-surface)',
104
+ border: '1px solid var(--gs-border)',
105
+ borderRadius: 'var(--gs-r-control)',
106
+ },
107
+ [`${PANEL} .cm-textfield::placeholder`]: { color: 'var(--gs-fg-faint)' },
108
+ [`${PANEL} .cm-textfield:focus`]: { outline: 'none', borderColor: 'var(--gs-accent)' },
109
+
110
+ /* `3/128`. A span rather than a control, so it takes none of the shared
111
+ rule above and states its own box; the meta size and the faint ink keep it
112
+ a readout beside the field rather than a fourth thing to click. */
113
+ [`${PANEL} .cm-gwSearchCount`]: {
114
+ display: 'inline-flex',
115
+ alignItems: 'center',
116
+ boxSizing: 'border-box',
117
+ height: 'var(--gs-h-compact)',
118
+ margin: `0 ${GAP} ${ROW_GAP} 0`,
119
+ verticalAlign: 'top',
120
+ font: 'inherit',
121
+ fontSize: 'var(--gs-t-meta)',
122
+ lineHeight: '1',
123
+ whiteSpace: 'nowrap',
124
+ fontVariantNumeric: 'tabular-nums',
125
+ color: 'var(--gs-fg-faint)',
126
+ },
127
+
128
+ [`${PANEL} .cm-button`]: {
129
+ display: 'inline-flex',
130
+ alignItems: 'center',
131
+ justifyContent: 'center',
132
+ boxSizing: 'border-box',
133
+ height: 'var(--gs-h-compact)',
134
+ padding: 'var(--gs-pad-compact)',
135
+ font: 'inherit',
136
+ fontSize: 'var(--gs-t-dense)',
137
+ lineHeight: '1',
138
+ whiteSpace: 'nowrap',
139
+ color: 'var(--gs-fg-dim)',
140
+ /* The gradient is the library's, and it is on `background-image`: a
141
+ background COLOUR alone would leave it painted on top. */
142
+ backgroundColor: 'transparent',
143
+ backgroundImage: 'none',
144
+ border: '1px solid var(--gs-border)',
145
+ borderRadius: 'var(--gs-r-control)',
146
+ cursor: 'pointer',
147
+ transition: 'background 120ms ease, color 120ms ease, border-color 120ms ease',
148
+ },
149
+ [`${PANEL} .cm-button:hover`]: {
150
+ color: 'var(--gs-fg)',
151
+ backgroundColor: 'var(--gs-raise)',
152
+ backgroundImage: 'none',
153
+ borderColor: 'var(--gs-fg-fainter)',
154
+ },
155
+ /* The library keeps a second gradient for the pressed state. */
156
+ [`${PANEL} .cm-button:active`]: {
157
+ backgroundColor: 'var(--gs-raise)',
158
+ backgroundImage: 'none',
159
+ },
160
+ [`${PANEL} .cm-button:focus-visible`]: { outline: '2px solid var(--gs-accent)', outlineOffset: '1px' },
161
+
162
+ [`${PANEL} label`]: {
163
+ display: 'inline-flex',
164
+ alignItems: 'center',
165
+ boxSizing: 'border-box',
166
+ height: 'var(--gs-h-compact)',
167
+ gap: '5px',
168
+ font: 'inherit',
169
+ fontSize: 'var(--gs-t-meta)',
170
+ lineHeight: '1',
171
+ whiteSpace: 'nowrap',
172
+ color: 'var(--gs-fg-dim)',
173
+ cursor: 'pointer',
174
+ WebkitUserSelect: 'none',
175
+ userSelect: 'none',
176
+ },
177
+
178
+ [`${PANEL} input[type=checkbox]`]: {
179
+ WebkitAppearance: 'none',
180
+ appearance: 'none',
181
+ position: 'relative',
182
+ boxSizing: 'border-box',
183
+ width: '14px',
184
+ height: '14px',
185
+ margin: '0',
186
+ backgroundColor: 'transparent',
187
+ border: '1px solid var(--gs-fg-fainter)',
188
+ borderRadius: '3px',
189
+ cursor: 'pointer',
190
+ transition: 'background 120ms ease, border-color 120ms ease',
191
+ },
192
+ [`${PANEL} input[type=checkbox]:hover`]: { borderColor: 'var(--gs-fg-dim)' },
193
+ [`${PANEL} input[type=checkbox]:checked`]: {
194
+ backgroundColor: 'var(--gs-accent)',
195
+ borderColor: 'var(--gs-accent)',
196
+ },
197
+ [`${PANEL} input[type=checkbox]:focus-visible`]: { outline: '2px solid var(--gs-accent)', outlineOffset: '1px' },
198
+ [`${PANEL} input[type=checkbox]:checked::after`]: {
199
+ content: '""',
200
+ position: 'absolute',
201
+ left: '4px',
202
+ top: '1px',
203
+ width: '3px',
204
+ height: '7px',
205
+ border: 'solid var(--gs-on-accent)',
206
+ borderWidth: '0 1.5px 1.5px 0',
207
+ transform: 'rotate(42deg)',
208
+ },
209
+
210
+ /* Close. Square and quiet — it dismisses a panel, so it is not the drawer's
211
+ red header close. `background-color` is restated because the library sets
212
+ it to `inherit`, which would take the strip's fill. */
213
+ [`${PANEL} [name=close]`]: {
214
+ position: 'absolute',
215
+ top: '8px',
216
+ right: '8px',
217
+ display: 'inline-flex',
218
+ alignItems: 'center',
219
+ justifyContent: 'center',
220
+ width: '20px',
221
+ height: '20px',
222
+ padding: '0',
223
+ margin: '0',
224
+ font: 'inherit',
225
+ fontSize: 'var(--gs-t-ui)',
226
+ lineHeight: '1',
227
+ color: 'var(--gs-fg-faint)',
228
+ backgroundColor: 'transparent',
229
+ border: '1px solid transparent',
230
+ borderRadius: 'var(--gs-r-control)',
231
+ cursor: 'pointer',
232
+ },
233
+ [`${PANEL} [name=close]:hover`]: { color: 'var(--gs-fg)', backgroundColor: 'var(--gs-raise)' },
234
+ [`${PANEL} [name=close]:focus-visible`]: { outline: '2px solid var(--gs-accent)', outlineOffset: '1px' },
235
+
236
+ /* A hit, and the hit the caret is on. Amber is what an editor uses to say
237
+ "found", and `--gs-warn` is the only warm token every palette defines;
238
+ the current one takes the drawer's single selected idiom —
239
+ `--gs-accent-bg` inside `--gs-accent-border` — rather than a second
240
+ highlighter colour. Declared after the plain match: both classes sit on
241
+ the same span at the same specificity, so source order decides. */
242
+ '.cm-searchMatch': {
243
+ backgroundColor: 'color-mix(in srgb, var(--gs-warn) 30%, transparent)',
244
+ borderRadius: '2px',
245
+ },
246
+ '.cm-searchMatch-selected': {
247
+ backgroundColor: 'color-mix(in srgb, var(--gs-accent) 34%, transparent)',
248
+ boxShadow: 'inset 0 0 0 1px var(--gs-accent)',
249
+ },
250
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * How many matches the find panel found, and which one you are on.
3
+ *
4
+ * The panel ships without a count, which leaves the one question a reader
5
+ * actually has unanswered: a query that highlights nothing on screen might
6
+ * have no matches at all, or two hundred of them below the fold, and the panel
7
+ * looks identical either way.
8
+ *
9
+ * Counting is proportional to the DOCUMENT, and this drawer does not put work
10
+ * proportional to the document on the keystroke path. Two things keep that
11
+ * true, and both live here rather than in the wiring:
12
+ *
13
+ * - the walk stops at {@link MATCH_CAP}. A cap that turns a feature off would
14
+ * be no fix, so this one bounds the WORK and keeps the feature: past the cap
15
+ * the total reads `5000+`, which answers "is my query too broad" as well as
16
+ * an exact number would. It bounds memory the same way — a single-letter
17
+ * search over 20,000 lines of real TypeScript finds 71,515 matches, and an
18
+ * array of those is half a megabyte kept alive for a number nobody reads.
19
+ * - the offsets are KEPT, so moving between matches is a binary search rather
20
+ * than a second walk. Measured on this repo's own client sources: a full
21
+ * count costs 3-5ms at 300 lines, 9-11ms at 2,000, 12ms at 4,000, and 60ms
22
+ * at 20,000 (`SIDE_LINE_CAP`, the ceiling the pane will load). Recounting on
23
+ * every Enter would put that 60ms on the navigation path; recounting when
24
+ * the typing stops puts it nowhere the reader can feel it.
25
+ *
26
+ * The index belongs to one query over one document, and the caller throws it
27
+ * away when either changes — see `SearchCount` in `CodeEditor.tsx`.
28
+ *
29
+ * @module @young1lin/dsh-ui-gitworkbench/client/search-count
30
+ */
31
+
32
+ /**
33
+ * How many match positions are kept.
34
+ *
35
+ * 5,000 is past any count a reader distinguishes from "lots" and well inside
36
+ * what the pane can hold: at 8 bytes an offset it is 40KB, against the half a
37
+ * megabyte an uncapped single-letter search over the ceiling would take.
38
+ */
39
+ export const MATCH_CAP = 5000
40
+
41
+ /** Where every match starts, and whether the walk stopped early. */
42
+ export interface MatchIndex {
43
+ /** Ascending start offsets, at most {@link MATCH_CAP} of them. */
44
+ readonly offsets: readonly number[]
45
+ /** There were more matches than were kept; the total reads `N+`. */
46
+ readonly capped: boolean
47
+ }
48
+
49
+ /** No query, or a query with nothing to find. */
50
+ export const EMPTY_INDEX: MatchIndex = { offsets: [], capped: false }
51
+
52
+ /** One match start, however it is handed over. */
53
+ interface Match { readonly from: number }
54
+
55
+ /**
56
+ * Anything that yields matches in order.
57
+ *
58
+ * Both halves of the union are here for a reason: `SearchQuery.getCursor` is
59
+ * DECLARED as an iterator and is also iterable at runtime, while a test wants
60
+ * to hand over a plain array. Accepting either keeps the rule readable without
61
+ * a document, a view or a DOM behind it.
62
+ */
63
+ export type MatchWalk = Iterable<Match> | Iterator<Match>
64
+
65
+ /**
66
+ * Walk matches into an index, stopping at `cap`.
67
+ *
68
+ * The walk is driven by hand rather than with `for…of`, because the union
69
+ * above may arrive already unwrapped. A cursor reuses ONE object across
70
+ * iterations, which is safe here only because nothing but the number is kept.
71
+ */
72
+ export function indexMatches(matches: MatchWalk, cap: number = MATCH_CAP): MatchIndex {
73
+ const step: Iterator<Match> = Symbol.iterator in matches
74
+ ? matches[Symbol.iterator]()
75
+ : matches
76
+ const offsets: number[] = []
77
+ for (;;) {
78
+ if (offsets.length >= cap) {
79
+ // Ask once more: `capped` has to mean "there are more", not "there might
80
+ // have been", or a total that lands exactly on the cap reads as `5000+`.
81
+ return { offsets, capped: step.next().done !== true }
82
+ }
83
+ const next = step.next()
84
+ if (next.done === true) return { offsets, capped: false }
85
+ offsets.push(next.value.from)
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Which match the position `from` is on or before, 1-based; 0 when there are
91
+ * none.
92
+ *
93
+ * The first match at or after the caret, because that is the one the panel
94
+ * will land on — which makes the answer right in both of the states the reader
95
+ * sees it in. Straight after typing the caret has not moved and the count
96
+ * should already read `1/128`, not `0/128`; after Enter the selection sits
97
+ * exactly on a match and the lower bound IS that match. Past the last one the
98
+ * panel wraps, so the answer wraps with it.
99
+ */
100
+ export function ordinalAt(index: MatchIndex, from: number): number {
101
+ const { offsets } = index
102
+ if (offsets.length === 0) return 0
103
+ let low = 0
104
+ let high = offsets.length
105
+ while (low < high) {
106
+ const mid = (low + high) >> 1
107
+ if (offsets[mid]! < from) low = mid + 1
108
+ else high = mid
109
+ }
110
+ return low === offsets.length ? 1 : low + 1
111
+ }
112
+
113
+ /**
114
+ * The label: `3/128`, `3/5000+` when the walk stopped early, `0/0` for a query
115
+ * that finds nothing.
116
+ *
117
+ * No words, in a panel whose own labels are the library's English and are not
118
+ * translated either — a pair of numbers says it in every language the drawer
119
+ * ships (see `locales.ts` for the strings that do need both).
120
+ */
121
+ export function formatCount(index: MatchIndex, ordinal: number): string {
122
+ const total = index.offsets.length
123
+ if (total === 0) return '0/0'
124
+ return `${ordinal}/${total}${index.capped ? '+' : ''}`
125
+ }
@@ -107,6 +107,7 @@
107
107
  box-sizing: border-box; width: 100%;
108
108
  padding: 4px 12px 4px 8px;
109
109
  border: 0;
110
+ border-radius: var(--gs-r-control);
110
111
  background: transparent;
111
112
  color: var(--gs-fg-dim);
112
113
  font-family: inherit;
@@ -114,7 +115,7 @@
114
115
  text-align: left;
115
116
  cursor: pointer;
116
117
  }
117
- .treeDir:hover { background: var(--gs-panel); color: var(--gs-fg-muted); }
118
+ .treeDir:hover { background: var(--gs-raise); color: var(--gs-fg-muted); }
118
119
  .treeDirActive { color: var(--gs-fg-muted); }
119
120
  .chevron {
120
121
  flex: none;
@@ -132,7 +133,7 @@
132
133
  .treeDirCount {
133
134
  flex: none;
134
135
  padding: 0 6px;
135
- border-radius: 999px;
136
+ border-radius: var(--gs-r-pill);
136
137
  background: var(--gs-neutral-bg);
137
138
  color: var(--gs-fg-dim);
138
139
  font-size: var(--gs-t-meta); font-weight: 600; line-height: 16px;
@@ -144,6 +145,7 @@
144
145
  padding: 5px 14px;
145
146
  border: 0;
146
147
  border-left: 2px solid transparent;
148
+ border-radius: var(--gs-r-control);
147
149
  background: transparent;
148
150
  color: var(--gs-fg-muted);
149
151
  font-family: inherit;
@@ -151,18 +153,14 @@
151
153
  text-align: left;
152
154
  cursor: pointer;
153
155
  }
154
- .file:hover { background: var(--gs-panel); }
155
- .fileActive {
156
- background: var(--gs-panel);
157
- border-left-color: var(--gs-accent);
158
- color: var(--gs-fg);
159
- }
156
+ .file:hover { background: var(--gs-raise); }
157
+ /* .fileActive: see the shared selected-state rule in controls.css. */
160
158
 
161
159
  .fileStatus {
162
160
  flex: none;
163
161
  display: inline-flex; align-items: center; justify-content: center;
164
162
  width: 18px; height: 18px;
165
- border-radius: 5px;
163
+ border-radius: var(--gs-r-control);
166
164
  font-weight: 700; font-size: var(--gs-t-meta); line-height: 1;
167
165
  }
168
166
  .stAdded, .stUntracked { color: var(--gs-add); background: var(--gs-add-bg); }
@@ -174,7 +172,7 @@
174
172
  .fileBinary {
175
173
  flex: none;
176
174
  padding: 0 5px;
177
- border-radius: 4px;
175
+ border-radius: var(--gs-r-control);
178
176
  background: var(--gs-neutral-bg);
179
177
  color: var(--gs-fg-dim);
180
178
  font-size: 9px; font-weight: 700; letter-spacing: 0.06em;
@@ -459,6 +457,9 @@
459
457
  opacity: 0.32;
460
458
  transition: opacity 140ms ease;
461
459
  pointer-events: none;
460
+ /* Above the gutter, which CodeMirror stacks at 200. The bar overlays its
461
+ first two pixels, so at the default z-index an opaque gutter buries it. */
462
+ z-index: 201;
462
463
  }
463
464
  .cmHost[data-editable]:focus-within::before { opacity: 1; }
464
465
  /* The change nav takes the free space, so it and `.sideActions` after it sit