@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.
Files changed (36) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +28 -0
  3. package/CHANGELOG_EN.md +28 -0
  4. package/README.md +100 -50
  5. package/README_EN.md +2 -1
  6. package/lib/client.js +6902 -6256
  7. package/package.json +1 -1
  8. package/src/client/ChangesFileTree.tsx +550 -0
  9. package/src/client/ChromeGlyph.tsx +27 -0
  10. package/src/client/CodeEditor.tsx +129 -3
  11. package/src/client/CommitHistory.tsx +1001 -0
  12. package/src/client/DiffViews.tsx +1070 -0
  13. package/src/client/GitWorkbenchPanel.module.css +15 -2513
  14. package/src/client/GitWorkbenchPanel.tsx +37 -3959
  15. package/src/client/PaneDivider.tsx +74 -0
  16. package/src/client/WorkbenchControls.tsx +1047 -0
  17. package/src/client/WorktreeGlyph.tsx +24 -0
  18. package/src/client/cm-search-theme.ts +250 -0
  19. package/src/client/diff-nav.ts +59 -0
  20. package/src/client/git-workbench-types.ts +252 -0
  21. package/src/client/locales.ts +14 -8
  22. package/src/client/row-window.ts +23 -0
  23. package/src/client/search-count.ts +125 -0
  24. package/src/client/side-rows.ts +66 -0
  25. package/src/client/styles/changes.css +505 -0
  26. package/src/client/styles/controls.css +236 -0
  27. package/src/client/styles/environment.css +89 -0
  28. package/src/client/styles/files.css +113 -0
  29. package/src/client/styles/history-filters.css +400 -0
  30. package/src/client/styles/history.css +276 -0
  31. package/src/client/styles/image.css +67 -0
  32. package/src/client/styles/operations.css +179 -0
  33. package/src/client/styles/shell.css +431 -0
  34. package/src/client/styles/themes.css +224 -0
  35. package/src/client/use-change-nav.ts +6 -5
  36. 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: '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,