@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/CHANGELOG.md +21 -0
- package/CHANGELOG_EN.md +21 -0
- package/README.md +76 -0
- package/README_EN.md +1 -1
- package/lib/client.js +741 -309
- package/package.json +1 -1
- package/src/client/CodeEditor.tsx +129 -3
- package/src/client/DiffViews.tsx +41 -12
- package/src/client/cm-search-theme.ts +250 -0
- package/src/client/cr-mark.ts +39 -0
- package/src/client/locales.ts +4 -4
- package/src/client/search-count.ts +125 -0
- package/src/client/styles/changes.css +19 -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.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: '
|
|
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,
|
package/src/client/DiffViews.tsx
CHANGED
|
@@ -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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
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
|
+
}
|
package/src/client/locales.ts
CHANGED
|
@@ -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.',
|