@young1lin/dsh-ui-gitworkbench 0.1.12 → 0.1.14

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.14",
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,
@@ -4,6 +4,7 @@ import {
4
4
  } from 'react'
5
5
 
6
6
  import { attachWordRanges, gutterSides, overlayRanges, parseRows, type Row, type RowWithRanges } from './diff-model.ts'
7
+ import { CR, CR_GLYPH, splitOnCr } from './cr-mark.ts'
7
8
  import { parsePatch } from '../patch-model.ts'
8
9
  import { alignRows, allBlockLines, allBlockTally, blockActionsDisabled, blockCount, blockEdge, blockIsWholeFile, blockLines, blockTally, currentActionBlock, needsFirstBlockClearance, sideBodyState, type SideCell, type SideRow } from './side-rows.ts'
9
10
  import { anchorFor, blockNearestTo, blockTopsFromRows, blockTopsFromSideRows, countBlocks, scrollTopFor, stepBlockIndex, unifiedBlocks } from './diff-nav.ts'
@@ -170,14 +171,25 @@ function rowClass(kind: Row['kind']): string {
170
171
  function renderCode(row: RowWithRanges, tokens: readonly HighlightRun[]): ReactNode {
171
172
  if (row.kind === 'hunk') return row.text
172
173
  const painted = overlayRanges(tokens.length > 0 ? tokens : [{ text: row.text }], row.ranges ?? [])
173
- if (painted.length === 1 && painted[0]!.color === undefined && !painted[0]!.mark) return row.text
174
- return painted.map((tok, i) => (
175
- <span
176
- key={i}
177
- className={tok.mark ? (row.kind === 'add' ? css.wordAdd : css.wordDel) : undefined}
178
- style={tok.color === undefined && !tok.italic ? undefined : { color: tok.color, fontStyle: tok.italic ? 'italic' : undefined }}
179
- >{tok.text}</span>
180
- ))
174
+ // The trailing-CR marker rides AFTER everything painted: word ranges are
175
+ // char offsets into the row text, so a mid-line glyph would shift them.
176
+ // Mid-line CRs (a CR-only file) stay invisible here; the side pane draws those.
177
+ const crTail = row.text.endsWith(CR)
178
+ ? <span className={css.crMark} aria-hidden="true">{CR_GLYPH}</span>
179
+ : null
180
+ if (painted.length === 1 && painted[0]!.color === undefined && !painted[0]!.mark) {
181
+ return crTail === null ? row.text : <>{row.text}{crTail}</>
182
+ }
183
+ return (<>
184
+ {painted.map((tok, i) => (
185
+ <span
186
+ key={i}
187
+ className={tok.mark ? (row.kind === 'add' ? css.wordAdd : css.wordDel) : undefined}
188
+ style={tok.color === undefined && !tok.italic ? undefined : { color: tok.color, fontStyle: tok.italic ? 'italic' : undefined }}
189
+ >{tok.text}</span>
190
+ ))}
191
+ {crTail}
192
+ </>)
181
193
  }
182
194
 
183
195
  /* ---------- side-by-side diff rendering (working tree only) ---------- */
@@ -1043,16 +1055,33 @@ function sideCodeClass(row: SideRow, side: 'left' | 'right'): string {
1043
1055
  return `${side === 'left' ? css.sideCodeDel : css.sideCodeAdd} ${css.sideCellBlock}`
1044
1056
  }
1045
1057
 
1046
- /** One cell's Shiki runs, or its plain text when no tokens exist. */
1058
+ /** One text with every carriage return drawn as the CR glyph. No CR means
1059
+ * the text comes back untouched — the common line, on both sides, costs one
1060
+ * `includes`. The glyph spans are aria-hidden and unselectable, so copying a
1061
+ * line copies code, not markers. */
1062
+ function renderWithCrMarks(text: string): ReactNode {
1063
+ const parts = splitOnCr(text)
1064
+ if (parts.length === 1) return text
1065
+ const out: ReactNode[] = [parts[0]!]
1066
+ for (let i = 1; i < parts.length; i += 1) {
1067
+ out.push(<span key={`cr${i}`} className={css.crMark} aria-hidden="true">{CR_GLYPH}</span>)
1068
+ out.push(parts[i]!)
1069
+ }
1070
+ return out
1071
+ }
1072
+
1073
+ /** One cell's Shiki runs, or its plain text when no tokens exist; either way
1074
+ * each carriage return in the cell is drawn, so a line whose only change is
1075
+ * its ending shows the difference instead of two identical-looking cells. */
1047
1076
  function renderSideCode(cell: SideCell | null, tokens: readonly HighlightRun[] | undefined): ReactNode {
1048
1077
  if (cell === null) return ''
1049
- if (tokens === undefined || tokens.length === 0) return cell.text
1050
- if (tokens.length === 1 && tokens[0]!.color === undefined && !tokens[0]!.italic) return cell.text
1078
+ if (tokens === undefined || tokens.length === 0) return renderWithCrMarks(cell.text)
1079
+ if (tokens.length === 1 && tokens[0]!.color === undefined && !tokens[0]!.italic) return renderWithCrMarks(cell.text)
1051
1080
  return tokens.map((tok, i) => (
1052
1081
  <span
1053
1082
  key={i}
1054
1083
  style={tok.color === undefined && !tok.italic ? undefined : { color: tok.color, fontStyle: tok.italic ? 'italic' : undefined }}
1055
- >{tok.text}</span>
1084
+ >{renderWithCrMarks(tok.text)}</span>
1056
1085
  ))
1057
1086
  }
1058
1087
 
@@ -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,39 @@
1
+ /**
2
+ * Making the carriage return visible in diff views.
3
+ *
4
+ * A diff line's text carries the CR byte through verbatim (`fileSides` serves
5
+ * git's own bytes), and a browser draws nothing for it: two cells whose only
6
+ * difference is a line ending render as byte-identical text, so a whole-file
7
+ * CRLF rewrite reads as a wall of changed lines with no visible change in
8
+ * any of them. The fix is not to strip the CR — the byte must survive into
9
+ * every patch the block actions emit — but to draw a glyph at each CR while
10
+ * RENDERING, the way `git diff` spells it as `^M`.
11
+ *
12
+ * The glyph is U+240D (SYMBOL FOR CARRIAGE RETURN), the standard picture of
13
+ * the control character, so a marked line reads as "ends in CR" rather than
14
+ * as a stray character in the code.
15
+ *
16
+ * Pure: no React, no CSS, no git. `tests/cr-mark.test.ts` loads it directly.
17
+ *
18
+ * @module @young1lin/dsh-ui-gitworkbench/cr-mark
19
+ */
20
+
21
+ /** Built rather than escaped so this source file survives any tool that
22
+ * normalises text-mode line endings on its way to disk. */
23
+ export const CR = String.fromCharCode(13)
24
+
25
+ /** What a carriage return is drawn as. */
26
+ export const CR_GLYPH = '\u240d'
27
+
28
+ /**
29
+ * Split text at every carriage return, so a renderer can interleave one
30
+ * {@link CR_GLYPH} span between consecutive parts.
31
+ *
32
+ * @param text - one cell's text, as the diff model carries it.
33
+ * @returns the parts between CRs; a text with no CR comes back as itself in
34
+ * one piece (length 1), which is the caller's cheap early-out.
35
+ */
36
+ export function splitOnCr(text: string): readonly string[] {
37
+ if (!text.includes(CR)) return [text]
38
+ return text.split(CR)
39
+ }
@@ -99,7 +99,7 @@ export const zh: Record<WorkbenchKey, string> = {
99
99
  filesDiscardOpen: '丢弃并打开',
100
100
  // Read-only rather than withheld: in this view READING the file is the
101
101
  // point, and only saving it back would rewrite bytes nobody touched.
102
- fileReadOnlyCrlf: '这个文件的行尾是 CRLF,只能查看不能编辑(编辑器会把行尾统一成 LF,保存时整份文件都会被改写)。',
102
+ fileReadOnlyCrlf: '这个文件的行尾是 CRLF,只能查看不能编辑(编辑器会把行尾统一成 LF,保存时整份文件都会被改写);建议把行尾统一成 LF。',
103
103
  fileReadOnlyEncoding: '这个文件不是 UTF-8 编码,只能查看不能编辑:页面上的文字是一次有损解码,保存回去会改写每一个非 ASCII 字节。',
104
104
  blameLine: '第 {line} 行',
105
105
  blameInHistory: '他在本文件的提交',
@@ -292,7 +292,7 @@ export const zh: Record<WorkbenchKey, string> = {
292
292
  blameUncommitted: '尚未提交',
293
293
  blameFailed: '这个文件没有可追溯的历史(可能是未跟踪的新文件)。',
294
294
  blameTruncated: '文件过长,追溯信息只显示了前面一部分。',
295
- crlfNotice: '这个文件的行尾是 CRLF,暂不支持在线编辑(编辑框会把行尾统一成 LF,保存时整份文件都会被改写);查看和按块暂存/撤回不受影响。',
295
+ crlfNotice: '这个文件的行尾是 CRLF,暂不支持在线编辑(编辑框会把行尾统一成 LF,保存时整份文件都会被改写);建议把行尾统一成 LF。差异里的回车已用 ␍ 标出;查看和按块暂存/撤回不受影响。',
296
296
  encodingNotice: '这个文件不是 UTF-8 编码(可能是 GBK、Shift JIS 之类),暂不支持在线编辑:页面上看到的文字是一次有损解码,保存回去会把文件里每一个非 ASCII 字节都改写掉,包括你没动过的行。查看和按块暂存/撤回不受影响。',
297
297
  saveFailed: '保存失败',
298
298
  saveUnavailable: '当前宿主还不支持保存(需要重启 dsh web 加载新版宿主端)。',
@@ -339,7 +339,7 @@ export const en: Record<WorkbenchKey, string> = {
339
339
  filesVanished: '{path} is not in the repository any more — deleted, renamed, or on a branch that no longer has it.',
340
340
  filesUnsavedAsk: 'You have unsaved edits; opening another file discards them.',
341
341
  filesDiscardOpen: 'Discard and open',
342
- fileReadOnlyCrlf: 'This file uses CRLF line endings, so it opens read-only (the editor normalises them to LF, and a save would rewrite every line).',
342
+ fileReadOnlyCrlf: 'This file uses CRLF line endings, so it opens read-only (the editor normalises them to LF, and a save would rewrite every line); normalising the endings to LF is recommended.',
343
343
  fileReadOnlyEncoding: 'This file is not UTF-8, so it opens read-only: the text shown is a lossy decode, and saving it back would rewrite every non-ASCII byte.',
344
344
  blameLine: 'Line {line}',
345
345
  blameInHistory: 'Their commits on this file',
@@ -517,7 +517,7 @@ export const en: Record<WorkbenchKey, string> = {
517
517
  blameUncommitted: 'Not committed yet',
518
518
  blameFailed: 'This file has no history to blame (it may be a new, untracked file).',
519
519
  blameTruncated: 'The file is long, so blame is shown for the first part only.',
520
- crlfNotice: 'This file has CRLF line endings, which the editor does not support yet (the edit box would turn every ending into LF, so a save rewrites the whole file); viewing and block staging/rolling back still work.',
520
+ crlfNotice: 'This file has CRLF line endings, which the editor does not support yet (the edit box would turn every ending into LF, so a save rewrites the whole file); normalising the endings to LF is recommended. Carriage returns are marked ␍ in the diff; viewing and block staging/rolling back still work.',
521
521
  encodingNotice: 'This file is not UTF-8 (GBK, Shift JIS or similar), so the editor is unavailable: the text shown is a lossy decode of it, and saving that back would rewrite every non-ASCII byte in the file, including lines you never touched. Viewing and block staging/rolling back still work.',
522
522
  saveFailed: 'Save failed',
523
523
  saveUnavailable: 'This host does not support saving yet — restart dsh web to load the new host half.',