@young1lin/dsh-ui-gitworkbench 0.1.11 → 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/AGENTS.md +1 -1
- package/CHANGELOG.md +28 -0
- package/CHANGELOG_EN.md +28 -0
- package/README.md +100 -50
- package/README_EN.md +2 -1
- package/lib/client.js +6902 -6256
- package/package.json +1 -1
- package/src/client/ChangesFileTree.tsx +550 -0
- package/src/client/ChromeGlyph.tsx +27 -0
- package/src/client/CodeEditor.tsx +129 -3
- package/src/client/CommitHistory.tsx +1001 -0
- package/src/client/DiffViews.tsx +1070 -0
- package/src/client/GitWorkbenchPanel.module.css +15 -2513
- package/src/client/GitWorkbenchPanel.tsx +37 -3959
- package/src/client/PaneDivider.tsx +74 -0
- package/src/client/WorkbenchControls.tsx +1047 -0
- package/src/client/WorktreeGlyph.tsx +24 -0
- package/src/client/cm-search-theme.ts +250 -0
- package/src/client/diff-nav.ts +59 -0
- package/src/client/git-workbench-types.ts +252 -0
- package/src/client/locales.ts +14 -8
- package/src/client/row-window.ts +23 -0
- package/src/client/search-count.ts +125 -0
- package/src/client/side-rows.ts +66 -0
- package/src/client/styles/changes.css +505 -0
- package/src/client/styles/controls.css +236 -0
- package/src/client/styles/environment.css +89 -0
- package/src/client/styles/files.css +113 -0
- package/src/client/styles/history-filters.css +400 -0
- package/src/client/styles/history.css +276 -0
- package/src/client/styles/image.css +67 -0
- package/src/client/styles/operations.css +179 -0
- package/src/client/styles/shell.css +431 -0
- package/src/client/styles/themes.css +224 -0
- package/src/client/use-change-nav.ts +6 -5
- package/src/client/use-row-window.ts +55 -0
|
@@ -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,
|