@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
@@ -72,6 +72,29 @@ export interface RowWindow {
72
72
  readonly padBottom: number
73
73
  }
74
74
 
75
+ /** Whether two windows paint the same rows AND reserve the same total height.
76
+ * Comparing only start/end leaves a previous file's bottom spacer behind when
77
+ * two long files happen to expose the same viewport-sized row range. */
78
+ export function sameRowWindow(a: RowWindow, b: RowWindow): boolean {
79
+ return a.start === b.start && a.end === b.end
80
+ && a.padTop === b.padTop && a.padBottom === b.padBottom
81
+ }
82
+
83
+ /** A rendered window is reusable only for the exact mounted diff. Equal-length
84
+ * files can be at different scroll positions, so row count alone is not an identity. */
85
+ export interface HeldRowWindow {
86
+ readonly mountKey: string
87
+ readonly rowCount: number
88
+ readonly win: RowWindow
89
+ }
90
+
91
+ /** Return the held window when it belongs to this diff, otherwise a fresh top window. */
92
+ export function rowWindowForMount(held: HeldRowWindow, rowCount: number, mountKey: string): RowWindow {
93
+ return held.rowCount === rowCount && held.mountKey === mountKey
94
+ ? held.win
95
+ : rowWindow(0, 0, rowCount)
96
+ }
97
+
75
98
  /**
76
99
  * @param value - a number from the DOM, which can be NaN or negative.
77
100
  * @param fallback - used when it is neither finite nor usable.
@@ -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
+ }
@@ -174,6 +174,17 @@ export function blockLines(rows: readonly SideRow[], block: number): readonly nu
174
174
  return [...indices].sort((a, b) => a - b)
175
175
  }
176
176
 
177
+ /** Every changed hunk-line index, for a whole-layer Stage/Unstage action. */
178
+ export function allBlockLines(rows: readonly SideRow[]): readonly number[] {
179
+ const indices = new Set<number>()
180
+ for (const row of rows) {
181
+ if (row.block < 0) continue
182
+ if (row.leftIndex !== -1) indices.add(row.leftIndex)
183
+ if (row.rightIndex !== -1) indices.add(row.rightIndex)
184
+ }
185
+ return [...indices].sort((a, b) => a - b)
186
+ }
187
+
177
188
  /**
178
189
  * How many change blocks the rows hold.
179
190
  *
@@ -186,6 +197,49 @@ export function blockCount(rows: readonly SideRow[]): number {
186
197
  return max + 1
187
198
  }
188
199
 
200
+ export type BlockEdge = 'single' | 'first' | 'middle' | 'last'
201
+
202
+ /** Where one present side-cell sits on its block's visible perimeter. Absent
203
+ * cells return null: an addition-only block must not draw a blue cage through
204
+ * the empty left pane, and a deletion-only block does the symmetric thing. */
205
+ export function blockEdge(rows: readonly SideRow[], index: number, side: 'left' | 'right'): BlockEdge | null {
206
+ const row = rows[index]
207
+ if (row === undefined || row.block < 0 || row[side] === null) return null
208
+ const before = index > 0 && rows[index - 1]!.block === row.block && rows[index - 1]![side] !== null
209
+ const after = index + 1 < rows.length && rows[index + 1]!.block === row.block && rows[index + 1]![side] !== null
210
+ if (!before && !after) return 'single'
211
+ if (!before) return 'first'
212
+ return after ? 'middle' : 'last'
213
+ }
214
+
215
+ /** The selected Git block exposed by the fixed pane toolbar. Visibility is
216
+ * independent of buffer dirtiness: the component keeps actions mounted and
217
+ * disables unsafe ones. A refreshed diff may carry fewer blocks, so an invalid
218
+ * selection safely falls back to the first one. */
219
+ export function currentActionBlock(totalBlocks: number, actionsVisible: boolean, selectedBlock: number): number | null {
220
+ if (!actionsVisible || totalBlocks <= 0) return null
221
+ return Number.isInteger(selectedBlock) && selectedBlock >= 0 && selectedBlock < totalBlocks ? selectedBlock : 0
222
+ }
223
+
224
+ /** Git hunk operations wait for a clean editor buffer and for the prior Git
225
+ * operation to settle. This rule disables controls; it never hides them. */
226
+ export function blockActionsDisabled(dirty: boolean, pendingBlock: number | null): boolean {
227
+ return dirty || pendingBlock !== null
228
+ }
229
+
230
+ /**
231
+ * Whether the first rendered row is already inside a change block.
232
+ *
233
+ * The block action bar normally floats one row above its first cell. When the
234
+ * file itself starts with a change there is no preceding row, so the columns
235
+ * must reserve that bar's clearance inside their own clipping boxes. This is
236
+ * derived from the full row model rather than hover state, avoiding a layout
237
+ * jump when the pointer enters line 1.
238
+ */
239
+ export function needsFirstBlockClearance(rows: readonly SideRow[]): boolean {
240
+ return rows.length > 0 && rows[0]!.block >= 0
241
+ }
242
+
189
243
  /**
190
244
  * Whether a block is the file's ENTIRE content: the one block of a diff with
191
245
  * no context row at all.
@@ -256,3 +310,15 @@ export function blockTally(rows: readonly SideRow[], block: number): { readonly
256
310
  }
257
311
  return { added, deleted }
258
312
  }
313
+
314
+ /** Whole-layer tallies, paired with {@link allBlockLines}. */
315
+ export function allBlockTally(rows: readonly SideRow[]): { readonly added: number; readonly deleted: number } {
316
+ let added = 0
317
+ let deleted = 0
318
+ for (const row of rows) {
319
+ if (row.block < 0) continue
320
+ if (row.left !== null) deleted += 1
321
+ if (row.right !== null) added += 1
322
+ }
323
+ return { added, deleted }
324
+ }