@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/CHANGELOG.md +15 -0
- package/CHANGELOG_EN.md +15 -0
- package/README.md +50 -0
- package/README_EN.md +1 -1
- package/lib/client.js +698 -304
- package/package.json +1 -1
- package/src/client/CodeEditor.tsx +129 -3
- package/src/client/cm-search-theme.ts +250 -0
- package/src/client/search-count.ts +125 -0
- package/src/client/styles/changes.css +11 -10
- package/src/client/styles/controls.css +19 -8
- package/src/client/styles/files.css +6 -4
- package/src/client/styles/history-filters.css +25 -35
- package/src/client/styles/history.css +1 -1
- package/src/client/styles/shell.css +6 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@young1lin/dsh-ui-gitworkbench",
|
|
3
|
-
"version": "0.1.
|
|
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: '
|
|
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-
|
|
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-
|
|
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:
|
|
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-
|
|
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:
|
|
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:
|
|
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
|