@cueloop/client 0.1.0-alpha.67 → 0.1.0-alpha.69
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 +7 -6
- package/src/App.appearance.test.tsx +8 -6
- package/src/App.diff.test.tsx +3 -3
- package/src/App.export.test.tsx +9 -3
- package/src/App.plan-review.test.tsx +10 -8
- package/src/App.prototype-markdown.test.tsx +92 -0
- package/src/App.prototype.test.tsx +17 -28
- package/src/App.readonly.test.tsx +3 -3
- package/src/App.rendering.test.tsx +3 -4
- package/src/App.test.tsx +14 -18
- package/src/App.tsx +117 -42
- package/src/App.walk.test.tsx +2 -2
- package/src/App.workbench.test.tsx +85 -14
- package/src/app-screens.tsx +26 -10
- package/src/app-view-model.ts +17 -3
- package/src/components/AnnotatableFileView.stories.tsx +42 -0
- package/src/components/AnnotatableFileView.test.tsx +102 -0
- package/src/components/AnnotatableFileView.tsx +126 -0
- package/src/components/AnnotationCards.tsx +6 -6
- package/src/components/AppShell.tsx +62 -15
- package/src/components/BareWorkbenchFileView.stories.tsx +27 -0
- package/src/components/BareWorkbenchFileView.tsx +69 -0
- package/src/components/ChangesColumn.tsx +3 -2
- package/src/components/{DiffSheet.scroll.test.tsx → DiffContentView.scroll.test.tsx} +2 -2
- package/src/components/{DiffSheet.split.test.tsx → DiffContentView.split.test.tsx} +3 -3
- package/src/components/{DiffSheet.stories.tsx → DiffContentView.stories.tsx} +3 -3
- package/src/components/{DiffSheet.syntax.test.tsx → DiffContentView.syntax.test.tsx} +2 -2
- package/src/components/{DiffSheet.tsx → DiffContentView.tsx} +187 -28
- package/src/components/DiscussionMarkerRail.tsx +2 -2
- package/src/components/EditorGrid.tabs.test.tsx +17 -4
- package/src/components/EditorGrid.tsx +49 -19
- package/src/components/FileContentsView.test.tsx +3 -3
- package/src/components/FileContentsView.tsx +37 -17
- package/src/components/{SurfaceScrollbar.stories.tsx → OverlayScrollbar.stories.tsx} +5 -6
- package/src/components/{SurfaceScrollbar.test.tsx → OverlayScrollbar.test.tsx} +41 -13
- package/src/components/{SurfaceScrollbar.tsx → OverlayScrollbar.tsx} +49 -13
- package/src/components/ProjectTreeView.tsx +3 -2
- package/src/components/{PrototypeSheet.stories.tsx → PrototypeContentView.stories.tsx} +4 -5
- package/src/components/{PrototypeSheet.tsx → PrototypeContentView.tsx} +58 -58
- package/src/components/ScrollArea.stories.tsx +20 -0
- package/src/components/ScrollArea.tsx +44 -0
- package/src/components/SettingsDialog.tsx +3 -2
- package/src/components/{InboxList.stories.tsx → ThreadTree.stories.tsx} +5 -5
- package/src/components/{InboxList.tsx → ThreadTree.tsx} +6 -6
- package/src/components/ThreadsSidebar.tsx +5 -4
- package/src/components/TreePane.tsx +3 -2
- package/src/components/WelcomePlayground.stories.tsx +12 -0
- package/src/components/WelcomePlayground.test.tsx +47 -0
- package/src/components/WelcomePlayground.tsx +140 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +163 -283
- package/src/components/{diff-sheet-layout.test.ts → diff-content-view-layout.test.ts} +1 -1
- package/src/components/{diff-sheet-layout.ts → diff-content-view-layout.ts} +1 -1
- package/src/config.test.ts +19 -6
- package/src/config.ts +26 -6
- package/src/ghostty-terminal.test.ts +2 -1
- package/src/ghostty-terminal.ts +27 -4
- package/src/intent-dispatch.test.ts +2 -0
- package/src/intent-dispatch.ts +1 -1
- package/src/key-bindings.test.ts +1 -1
- package/src/key-bindings.ts +5 -1
- package/src/keymap.ts +1 -1
- package/src/prototype-browser.ts +8 -0
- package/src/prototype-pixels.tsx +35 -0
- package/src/ready-signal.test.ts +85 -0
- package/src/ready-signal.ts +52 -0
- package/src/session-controller.live-diff.test.ts +198 -0
- package/src/session-controller.ts +141 -26
- package/src/slash-palette.test.ts +54 -0
- package/src/slash-palette.ts +32 -18
- package/src/test-support.test.ts +12 -0
- package/src/test-support.ts +69 -10
- package/src/theme-presets.ts +20 -0
- package/src/theme.ts +18 -0
- package/src/thread-chords.ts +1 -1
- package/src/use-annotation-surface.tsx +12 -2
- package/src/use-settings-dialog.tsx +4 -4
- package/src/view-diff.test.ts +44 -1
- package/src/view-diff.ts +81 -0
- package/src/components/AnnotationCard.compose-rows.test.ts +0 -49
- package/src/components/AnnotationCard.stories.tsx +0 -94
- package/src/components/AnnotationCard.tsx +0 -250
- package/src/components/MarkerPopover.stories.tsx +0 -58
- package/src/components/MarkerPopover.tsx +0 -109
- package/src/components/WelcomeSurface.stories.tsx +0 -12
- package/src/components/WelcomeSurface.tsx +0 -52
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// so the diff reclaims the width. Mirrors the left Threads column on the far side.
|
|
4
4
|
// Co-located here: the state hook that opens it by context and the header toggle.
|
|
5
5
|
|
|
6
|
+
import { ScrollArea } from "./ScrollArea";
|
|
6
7
|
import React, { useMemo, useState } from "react";
|
|
7
8
|
import type { DiffFileContents, ReviewSession } from "@cueloop/schema";
|
|
8
9
|
import type { Theme } from "../theme";
|
|
@@ -115,7 +116,7 @@ export function ChangesFileTree({
|
|
|
115
116
|
}, [nodes, collapsedIds]);
|
|
116
117
|
|
|
117
118
|
return (
|
|
118
|
-
<
|
|
119
|
+
<ScrollArea>
|
|
119
120
|
<Tree
|
|
120
121
|
nodes={nodes}
|
|
121
122
|
expandedIds={expandedIds}
|
|
@@ -135,7 +136,7 @@ export function ChangesFileTree({
|
|
|
135
136
|
}
|
|
136
137
|
theme={theme}
|
|
137
138
|
/>
|
|
138
|
-
</
|
|
139
|
+
</ScrollArea>
|
|
139
140
|
);
|
|
140
141
|
}
|
|
141
142
|
|
|
@@ -5,7 +5,7 @@ import React from "react";
|
|
|
5
5
|
import { testRender } from "@opentui/react/test-utils";
|
|
6
6
|
import { ScrollBoxRenderable, type Renderable } from "@opentui/core";
|
|
7
7
|
import type { Annotation } from "@cueloop/schema";
|
|
8
|
-
import {
|
|
8
|
+
import { DiffContentView } from "./DiffContentView";
|
|
9
9
|
import { diffRows, marksByRows } from "../view-diff";
|
|
10
10
|
import { DARK } from "../theme";
|
|
11
11
|
import { annotationPaletteFor } from "../annotation-palette";
|
|
@@ -63,7 +63,7 @@ test("walking the caret down past wrapped discussion cards keeps it on screen an
|
|
|
63
63
|
createdAt: "2026-01-01T00:00:00Z",
|
|
64
64
|
}));
|
|
65
65
|
const setup = await testRender(
|
|
66
|
-
<
|
|
66
|
+
<DiffContentView
|
|
67
67
|
rows={rows}
|
|
68
68
|
session={fixtureDiffSession({ annotations })}
|
|
69
69
|
marks={marksByRows(annotations, rows)}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { describe, expect, test } from "bun:test";
|
|
8
8
|
import { testRender } from "@opentui/react/test-utils";
|
|
9
9
|
import React from "react";
|
|
10
|
-
import {
|
|
10
|
+
import { DiffContentView } from "./DiffContentView";
|
|
11
11
|
import { diffRows } from "../view-diff";
|
|
12
12
|
import { allowEventLoopUpdates } from "../test-support";
|
|
13
13
|
import { fixtureDiffSession } from "./story-fixtures";
|
|
@@ -37,7 +37,7 @@ const WRAPPING_PATCH = `diff --git a/app.ts b/app.ts
|
|
|
37
37
|
|
|
38
38
|
async function splitFrameLines(width: number): Promise<string[]> {
|
|
39
39
|
const setup = await testRender(
|
|
40
|
-
<
|
|
40
|
+
<DiffContentView
|
|
41
41
|
rows={diffRows(WRAPPING_PATCH)}
|
|
42
42
|
session={fixtureDiffSession()}
|
|
43
43
|
marks={new Map()}
|
|
@@ -65,7 +65,7 @@ describe("split diff rendering", () => {
|
|
|
65
65
|
test("the caret marks only the change side, not both columns", async () => {
|
|
66
66
|
// Arrange - the caret opens on a context row, which is shown in both columns
|
|
67
67
|
const setup = await testRender(
|
|
68
|
-
<
|
|
68
|
+
<DiffContentView
|
|
69
69
|
rows={diffRows(WRAPPING_PATCH)}
|
|
70
70
|
session={fixtureDiffSession()}
|
|
71
71
|
marks={new Map()}
|
|
@@ -5,10 +5,10 @@ import { annotationPaletteFor } from "../annotation-palette";
|
|
|
5
5
|
import { DEFAULT_QUICK_ACTIONS } from "../config";
|
|
6
6
|
import { marksByRows } from "../view-diff";
|
|
7
7
|
import type { Story, StoryMeta } from "./story";
|
|
8
|
-
import {
|
|
8
|
+
import { DiffContentView } from "./DiffContentView";
|
|
9
9
|
import { fixtureDiffRows, fixtureDiffSession } from "./story-fixtures";
|
|
10
10
|
|
|
11
|
-
export const meta: StoryMeta = { title: "Surfaces/
|
|
11
|
+
export const meta: StoryMeta = { title: "Surfaces/DiffContentView" };
|
|
12
12
|
|
|
13
13
|
const palette = annotationPaletteFor(DARK);
|
|
14
14
|
const noop = (): void => {};
|
|
@@ -27,7 +27,7 @@ function sheet(annotations: Annotation[], split = false): React.ReactNode {
|
|
|
27
27
|
const session = fixtureDiffSession({ annotations });
|
|
28
28
|
|
|
29
29
|
return (
|
|
30
|
-
<
|
|
30
|
+
<DiffContentView
|
|
31
31
|
rows={rows}
|
|
32
32
|
session={session}
|
|
33
33
|
marks={marksByRows(annotations, rows)}
|
|
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, expect, test } from "bun:test";
|
|
|
2
2
|
import { testRender } from "@opentui/react/test-utils";
|
|
3
3
|
import type { RGBA } from "@opentui/core";
|
|
4
4
|
import React from "react";
|
|
5
|
-
import {
|
|
5
|
+
import { DiffContentView } from "./DiffContentView";
|
|
6
6
|
import { fixtureDiffSession } from "./story-fixtures";
|
|
7
7
|
import { diffRows } from "../view-diff";
|
|
8
8
|
import { DARK } from "../theme";
|
|
@@ -31,7 +31,7 @@ test("renders tree-sitter colors, with the changed word in the diff color on top
|
|
|
31
31
|
// Arrange
|
|
32
32
|
const noop = (): void => {};
|
|
33
33
|
const setup = await testRender(
|
|
34
|
-
<
|
|
34
|
+
<DiffContentView
|
|
35
35
|
rows={diffRows(PATCH)}
|
|
36
36
|
session={fixtureDiffSession()}
|
|
37
37
|
marks={new Map()}
|
|
@@ -27,7 +27,7 @@ import { useFrameMeasure } from "../use-frame-measure";
|
|
|
27
27
|
import { useTerminalVirtualizer } from "../use-terminal-virtualizer";
|
|
28
28
|
import { useAnnotationSurface, type LineSource } from "../use-annotation-surface";
|
|
29
29
|
import { DiscussionMarkerRail } from "./DiscussionMarkerRail";
|
|
30
|
-
import { coloredRowSpans } from "./diff-
|
|
30
|
+
import { coloredRowSpans } from "./diff-content-view-layout";
|
|
31
31
|
|
|
32
32
|
/** Shared empty map so an unresolved/stale highlight state is a stable value. */
|
|
33
33
|
const EMPTY_SYNTAX: Map<number, SyntaxSpan[]> = new Map();
|
|
@@ -41,9 +41,24 @@ const REJECTED_ATTRIBUTES = createTextAttributes({ strikethrough: true, dim: tru
|
|
|
41
41
|
/** A file band renders its name in bold between an equal rule above and below. */
|
|
42
42
|
const FILE_HEADER_ATTRIBUTES = createTextAttributes({ bold: true });
|
|
43
43
|
|
|
44
|
-
/** The gutter before a code line: caret bar, four-digit line number, a space, the sign, a space. */
|
|
44
|
+
/** The split-view gutter before a code line: caret bar, four-digit line number, a space, the sign, a space. */
|
|
45
45
|
const GUTTER_COLUMNS = 8;
|
|
46
46
|
|
|
47
|
+
/** The stacked gutter around its two line-number columns: caret bar, the space between them, a space, the sign, a space. */
|
|
48
|
+
const UNIFIED_GUTTER_CHROME = 5;
|
|
49
|
+
|
|
50
|
+
/** The widest line number in the diff, so the stacked gutter's two columns stay as narrow as the file allows. */
|
|
51
|
+
function lineNumberWidth(rows: DiffRow[]): number {
|
|
52
|
+
let width = 1;
|
|
53
|
+
|
|
54
|
+
for (const row of rows) {
|
|
55
|
+
if (row.oldLine !== undefined) width = Math.max(width, String(row.oldLine).length);
|
|
56
|
+
if (row.newLine !== undefined) width = Math.max(width, String(row.newLine).length);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return width;
|
|
60
|
+
}
|
|
61
|
+
|
|
47
62
|
/** The per-file fold controls the file band renders; absent in read-only story renders. */
|
|
48
63
|
export interface DiffFoldControls {
|
|
49
64
|
isCollapsed: (file: string) => boolean;
|
|
@@ -54,13 +69,15 @@ export interface DiffFoldControls {
|
|
|
54
69
|
onCopyPath: (file: string) => void;
|
|
55
70
|
}
|
|
56
71
|
|
|
57
|
-
export interface
|
|
72
|
+
export interface DiffContentViewProps {
|
|
58
73
|
rows: DiffRow[];
|
|
59
74
|
session: ReviewSession;
|
|
60
75
|
/** Annotations resolved onto rows (marksByRows), with char ranges and spans. */
|
|
61
76
|
marks: Map<number, Mark[]>;
|
|
62
77
|
quickActions: QuickAction[];
|
|
63
78
|
observer: boolean;
|
|
79
|
+
/** Whether comments can be drafted here; false for a non-diff thread's view-only live diff. */
|
|
80
|
+
commentsEnabled?: boolean;
|
|
64
81
|
/** A verdict is in: no draft may open; the app answers with its read-only status. */
|
|
65
82
|
resolved?: boolean;
|
|
66
83
|
/** True while a menu, dialog, or overlay owns the keyboard. */
|
|
@@ -87,6 +104,8 @@ export interface DiffSheetProps {
|
|
|
87
104
|
fileStats?: ReadonlyMap<string, { additions: number; deletions: number }>;
|
|
88
105
|
/** Render old|new side by side instead of one inline column; the App gates this on zoom. */
|
|
89
106
|
split?: boolean;
|
|
107
|
+
/** A plain-file view (all context rows): one line-number gutter, no +/- sign. */
|
|
108
|
+
fileView?: boolean;
|
|
90
109
|
theme?: Theme;
|
|
91
110
|
}
|
|
92
111
|
|
|
@@ -120,6 +139,26 @@ function FileCountsBadge({
|
|
|
120
139
|
);
|
|
121
140
|
}
|
|
122
141
|
|
|
142
|
+
/** The file band's expand-all-lines toggle: the word "expand" stays put, its color carries the state
|
|
143
|
+
* (active white when the file is expanded, inactive gray when collapsed to changes) so nothing shifts. */
|
|
144
|
+
function ExpandToggle({
|
|
145
|
+
expanded,
|
|
146
|
+
onToggle,
|
|
147
|
+
tokens,
|
|
148
|
+
}: {
|
|
149
|
+
expanded: boolean;
|
|
150
|
+
onToggle: () => void;
|
|
151
|
+
tokens: Theme;
|
|
152
|
+
}): React.ReactNode {
|
|
153
|
+
return (
|
|
154
|
+
<box onMouseUp={onToggle} style={{ alignSelf: "center" }}>
|
|
155
|
+
<text fg={expanded ? tokens.text : tokens.textDim} style={{ wrapMode: "none" }}>
|
|
156
|
+
expand
|
|
157
|
+
</text>
|
|
158
|
+
</box>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
123
162
|
/**
|
|
124
163
|
* A file band: chevron, bold name, and the added/removed counts between an equal rule above and
|
|
125
164
|
* below. `fold` absent (story renders) drops the controls.
|
|
@@ -173,10 +212,10 @@ function FileBand({
|
|
|
173
212
|
/>
|
|
174
213
|
) : null}
|
|
175
214
|
{fold && canExpand ? (
|
|
176
|
-
<
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
215
|
+
<ExpandToggle
|
|
216
|
+
expanded={expanded}
|
|
217
|
+
onToggle={() => fold.onToggleExpand(file)}
|
|
218
|
+
tokens={tokens}
|
|
180
219
|
/>
|
|
181
220
|
) : null}
|
|
182
221
|
</box>
|
|
@@ -293,6 +332,7 @@ function codeLineSpans(
|
|
|
293
332
|
lineStart: number,
|
|
294
333
|
lineRanges: MarkRange[],
|
|
295
334
|
fgByColumn: string[],
|
|
335
|
+
emphasisBgByColumn: Array<string | undefined>,
|
|
296
336
|
rejected: boolean,
|
|
297
337
|
tokens: Theme,
|
|
298
338
|
palette: AnnotationPalette,
|
|
@@ -301,11 +341,18 @@ function codeLineSpans(
|
|
|
301
341
|
let column = 0;
|
|
302
342
|
|
|
303
343
|
for (const run of runsFor(lineText, lineRanges)) {
|
|
304
|
-
|
|
344
|
+
// a caret or a comment mark owns the whole run's backdrop; otherwise the changed-word tint (if any)
|
|
345
|
+
// shows per column and the row box paints the soft band behind the rest
|
|
346
|
+
const runBackground = run.caretOnly
|
|
347
|
+
? palette.caretCell
|
|
348
|
+
: run.marked
|
|
349
|
+
? palette.markBackdrop
|
|
350
|
+
: undefined;
|
|
305
351
|
const attributes = (run.marked ? UNDERLINE : 0) | (rejected ? REJECTED_ATTRIBUTES : 0);
|
|
306
352
|
|
|
307
353
|
for (const character of run.text) {
|
|
308
354
|
const fg = rejected ? tokens.textDim : (fgByColumn[lineStart + column] ?? tokens.textMuted);
|
|
355
|
+
const bg = runBackground ?? emphasisBgByColumn[lineStart + column];
|
|
309
356
|
const previous = spans[spans.length - 1];
|
|
310
357
|
|
|
311
358
|
if (
|
|
@@ -352,6 +399,45 @@ function rowBaseColor(row: DiffRow, tokens: Theme): string {
|
|
|
352
399
|
: tokens.textMuted;
|
|
353
400
|
}
|
|
354
401
|
|
|
402
|
+
/** The soft band behind a whole added/removed row; context rows carry no band. */
|
|
403
|
+
function rowBackground(row: DiffRow, tokens: Theme): string | undefined {
|
|
404
|
+
return row.kind === "add"
|
|
405
|
+
? tokens.insertedBackground
|
|
406
|
+
: row.kind === "del"
|
|
407
|
+
? tokens.deletedBackground
|
|
408
|
+
: undefined;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** The stronger tint behind an add/del row's gutter chip and its intra-line changed words. */
|
|
412
|
+
function rowEmphasisBackground(row: DiffRow, tokens: Theme): string | undefined {
|
|
413
|
+
return row.kind === "add"
|
|
414
|
+
? tokens.insertedEmphasisBackground
|
|
415
|
+
: row.kind === "del"
|
|
416
|
+
? tokens.deletedEmphasisBackground
|
|
417
|
+
: undefined;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** Per-column emphasis backdrop: the changed words of a modified line, else none. */
|
|
421
|
+
function emphasisBackgroundColumns(
|
|
422
|
+
text: string,
|
|
423
|
+
intraline: IntralineRun[] | undefined,
|
|
424
|
+
emphasisBackground: string | undefined,
|
|
425
|
+
): Array<string | undefined> {
|
|
426
|
+
const columns: Array<string | undefined> = Array.from({ length: text.length }, () => undefined);
|
|
427
|
+
|
|
428
|
+
if (!intraline || !emphasisBackground) return columns;
|
|
429
|
+
let offset = 0;
|
|
430
|
+
|
|
431
|
+
for (const run of intraline) {
|
|
432
|
+
if (run.changed)
|
|
433
|
+
for (let index = 0; index < run.text.length && offset + index < text.length; index++)
|
|
434
|
+
columns[offset + index] = emphasisBackground;
|
|
435
|
+
offset += run.text.length;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return columns;
|
|
439
|
+
}
|
|
440
|
+
|
|
355
441
|
/** Async tree-sitter highlights, discarded when they belong to superseded rows. */
|
|
356
442
|
function useSyntaxHighlights(rows: DiffRow[]): Map<number, SyntaxSpan[]> {
|
|
357
443
|
const [highlighted, setHighlighted] = useState<{
|
|
@@ -365,9 +451,16 @@ function useSyntaxHighlights(rows: DiffRow[]): Map<number, SyntaxSpan[]> {
|
|
|
365
451
|
useEffect(() => {
|
|
366
452
|
let active = true;
|
|
367
453
|
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
454
|
+
highlightDiffRows(rows).then(
|
|
455
|
+
(byRow) => {
|
|
456
|
+
if (active) setHighlighted({ rows, byRow });
|
|
457
|
+
},
|
|
458
|
+
(error) => {
|
|
459
|
+
// a highlighter torn down mid-init rejects after unmount (active === false): expected, silent.
|
|
460
|
+
// a rejection while still mounted is a real failure - surface it and leave the rows unhighlighted
|
|
461
|
+
if (active) console.error("diff syntax highlighting failed", error);
|
|
462
|
+
},
|
|
463
|
+
);
|
|
371
464
|
|
|
372
465
|
return () => {
|
|
373
466
|
active = false;
|
|
@@ -377,12 +470,13 @@ function useSyntaxHighlights(rows: DiffRow[]): Map<number, SyntaxSpan[]> {
|
|
|
377
470
|
return highlighted.rows === rows ? highlighted.byRow : EMPTY_SYNTAX;
|
|
378
471
|
}
|
|
379
472
|
|
|
380
|
-
export function
|
|
473
|
+
export function DiffContentView({
|
|
381
474
|
rows,
|
|
382
475
|
session,
|
|
383
476
|
marks,
|
|
384
477
|
quickActions,
|
|
385
478
|
observer,
|
|
479
|
+
commentsEnabled = true,
|
|
386
480
|
resolved = false,
|
|
387
481
|
suspended = false,
|
|
388
482
|
onComposingChange,
|
|
@@ -398,8 +492,9 @@ export function DiffSheet({
|
|
|
398
492
|
fold,
|
|
399
493
|
fileStats: fileStatsProp,
|
|
400
494
|
split = false,
|
|
495
|
+
fileView = false,
|
|
401
496
|
theme,
|
|
402
|
-
}:
|
|
497
|
+
}: DiffContentViewProps): React.ReactNode {
|
|
403
498
|
const tokens = useComponentTheme(theme);
|
|
404
499
|
const scrollRef = useRef<ScrollBoxRenderable | null>(null);
|
|
405
500
|
const source: LineSource = {
|
|
@@ -414,6 +509,7 @@ export function DiffSheet({
|
|
|
414
509
|
quickActions,
|
|
415
510
|
tokens,
|
|
416
511
|
observer,
|
|
512
|
+
commentsEnabled,
|
|
417
513
|
resolved,
|
|
418
514
|
suspended,
|
|
419
515
|
onComposingChange,
|
|
@@ -427,6 +523,8 @@ export function DiffSheet({
|
|
|
427
523
|
onExit,
|
|
428
524
|
});
|
|
429
525
|
const { palette } = surface;
|
|
526
|
+
const numberWidth = useMemo(() => lineNumberWidth(rows), [rows]);
|
|
527
|
+
const unifiedGutterColumns = numberWidth * 2 + UNIFIED_GUTTER_CHROME;
|
|
430
528
|
const intralineByRow = useMemo(() => intralineRunsByRow(rows), [rows]);
|
|
431
529
|
const syntaxByRow = useSyntaxHighlights(rows);
|
|
432
530
|
const splitRows = useMemo(() => (split ? splitDiffRows(rows) : []), [split, rows]);
|
|
@@ -438,10 +536,12 @@ export function DiffSheet({
|
|
|
438
536
|
(left, right) => left === right,
|
|
439
537
|
0,
|
|
440
538
|
);
|
|
441
|
-
//
|
|
442
|
-
const textWidth =
|
|
443
|
-
? Math.max(0,
|
|
444
|
-
:
|
|
539
|
+
// a file view shows one number; split gives each column its own gutter; stacked shows old and new
|
|
540
|
+
const textWidth = fileView
|
|
541
|
+
? Math.max(0, viewWidth - (numberWidth + 2))
|
|
542
|
+
: split
|
|
543
|
+
? Math.max(0, Math.floor((viewWidth - 1) / 2) - GUTTER_COLUMNS)
|
|
544
|
+
: Math.max(0, viewWidth - unifiedGutterColumns);
|
|
445
545
|
|
|
446
546
|
// Every visual line is its own text renderable (so a drag can hit-test it), and a large
|
|
447
547
|
// diff has tens of thousands of them - more native text buffers than the renderer can
|
|
@@ -489,10 +589,72 @@ export function DiffSheet({
|
|
|
489
589
|
syntaxByRow.get(rowIndex),
|
|
490
590
|
tokens,
|
|
491
591
|
);
|
|
592
|
+
const emphasisBgByColumn = emphasisBackgroundColumns(
|
|
593
|
+
text,
|
|
594
|
+
intralineByRow.get(rowIndex),
|
|
595
|
+
rowEmphasisBackground(row, tokens),
|
|
596
|
+
);
|
|
597
|
+
const rowBg = rowBackground(row, tokens);
|
|
492
598
|
const rejected = rejectedRows.has(rowIndex);
|
|
493
599
|
const isCaretRow = paintMarks && surface.head.blockIndex === rowIndex;
|
|
494
|
-
|
|
495
|
-
const
|
|
600
|
+
// split view carries one number per side; the stacked view shows the old and new numbers together
|
|
601
|
+
const splitLineNumber = row.kind === "del" ? row.oldLine : row.newLine;
|
|
602
|
+
const splitGutter = `${isCaretRow ? "▎" : " "}${String(splitLineNumber ?? "").padStart(4, " ")} ${rowSign(row)} `;
|
|
603
|
+
const barChar = isCaretRow ? "▎" : " ";
|
|
604
|
+
const barColor = isCaretRow ? tokens.accent : tokens.textDim;
|
|
605
|
+
const oldNumber =
|
|
606
|
+
row.kind === "add"
|
|
607
|
+
? " ".repeat(numberWidth)
|
|
608
|
+
: String(row.oldLine ?? "").padStart(numberWidth);
|
|
609
|
+
const newNumber =
|
|
610
|
+
row.kind === "del"
|
|
611
|
+
? " ".repeat(numberWidth)
|
|
612
|
+
: String(row.newLine ?? "").padStart(numberWidth);
|
|
613
|
+
const gutterFor = (lineIndex: number): React.ReactNode => {
|
|
614
|
+
if (fileView) {
|
|
615
|
+
// a plain file has no old/new sides, so one line number reads like an ordinary file viewer
|
|
616
|
+
return lineIndex === 0 ? (
|
|
617
|
+
<>
|
|
618
|
+
<span fg={barColor}>{barChar}</span>
|
|
619
|
+
<span fg={tokens.textDim}>{String(row.newLine ?? "").padStart(numberWidth)}</span>
|
|
620
|
+
<span> </span>
|
|
621
|
+
</>
|
|
622
|
+
) : (
|
|
623
|
+
" ".repeat(numberWidth + 2)
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
if (split) {
|
|
627
|
+
return lineIndex === 0 ? (
|
|
628
|
+
<>
|
|
629
|
+
<span fg={isCaretRow ? tokens.accent : tokens.textDim}>{splitGutter.slice(0, 6)}</span>
|
|
630
|
+
<span fg={rowBaseColor(row, tokens)}>{splitGutter.slice(6)}</span>
|
|
631
|
+
</>
|
|
632
|
+
) : (
|
|
633
|
+
" ".repeat(GUTTER_COLUMNS)
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
// stacked: caret bar, old new (each tinted red/green on its side), then the change sign;
|
|
637
|
+
// the numbers sit on the row's soft band, only the changed code carries the brighter backdrop
|
|
638
|
+
return lineIndex === 0 ? (
|
|
639
|
+
<>
|
|
640
|
+
<span fg={barColor}>{barChar}</span>
|
|
641
|
+
<span fg={row.kind === "del" ? tokens.deletedForeground : tokens.textDim}>
|
|
642
|
+
{oldNumber}
|
|
643
|
+
</span>
|
|
644
|
+
<span fg={tokens.textDim}> </span>
|
|
645
|
+
<span fg={row.kind === "add" ? tokens.insertedForeground : tokens.textDim}>
|
|
646
|
+
{newNumber}
|
|
647
|
+
</span>
|
|
648
|
+
<span> </span>
|
|
649
|
+
<span fg={rowBaseColor(row, tokens)}>{`${rowSign(row)} `}</span>
|
|
650
|
+
</>
|
|
651
|
+
) : (
|
|
652
|
+
<>
|
|
653
|
+
<span fg={barColor}>{barChar}</span>
|
|
654
|
+
{" ".repeat(unifiedGutterColumns - 1)}
|
|
655
|
+
</>
|
|
656
|
+
);
|
|
657
|
+
};
|
|
496
658
|
const lineNodes: React.ReactNode[] = [];
|
|
497
659
|
const cards: React.ReactNode[] = [];
|
|
498
660
|
|
|
@@ -501,20 +663,16 @@ export function DiffSheet({
|
|
|
501
663
|
const isLastLine = lineIndex === lines.length - 1;
|
|
502
664
|
|
|
503
665
|
lineNodes.push(
|
|
504
|
-
<box
|
|
666
|
+
<box
|
|
667
|
+
key={`${keyPrefix}-line-${lineIndex}`}
|
|
668
|
+
style={{ flexDirection: "row", backgroundColor: rowBg }}
|
|
669
|
+
>
|
|
505
670
|
<text
|
|
506
671
|
selectable={false}
|
|
507
672
|
fg={isCaretRow && lineIndex === 0 ? tokens.accent : tokens.textDim}
|
|
508
673
|
style={{ flexShrink: 0, wrapMode: "none" }}
|
|
509
674
|
>
|
|
510
|
-
{lineIndex
|
|
511
|
-
<>
|
|
512
|
-
<span fg={isCaretRow ? tokens.accent : tokens.textDim}>{gutter.slice(0, 6)}</span>
|
|
513
|
-
<span fg={rowBaseColor(row, tokens)}>{gutter.slice(6)}</span>
|
|
514
|
-
</>
|
|
515
|
-
) : (
|
|
516
|
-
" ".repeat(GUTTER_COLUMNS)
|
|
517
|
-
)}
|
|
675
|
+
{gutterFor(lineIndex)}
|
|
518
676
|
</text>
|
|
519
677
|
<text
|
|
520
678
|
selectable={false}
|
|
@@ -527,6 +685,7 @@ export function DiffSheet({
|
|
|
527
685
|
line.start,
|
|
528
686
|
lineMarkRanges(ranges, line),
|
|
529
687
|
fgByColumn,
|
|
688
|
+
emphasisBgByColumn,
|
|
530
689
|
rejected,
|
|
531
690
|
tokens,
|
|
532
691
|
palette,
|
|
@@ -14,7 +14,7 @@ import type { Discussion } from "../discussions";
|
|
|
14
14
|
import { useFrameMeasure } from "../use-frame-measure";
|
|
15
15
|
import { useComponentTheme } from "./theme-context";
|
|
16
16
|
import { useRootOverlay } from "./RootOverlay";
|
|
17
|
-
import {
|
|
17
|
+
import { OverlayScrollbar } from "./OverlayScrollbar";
|
|
18
18
|
|
|
19
19
|
interface HoveredMarker {
|
|
20
20
|
key: string;
|
|
@@ -175,7 +175,7 @@ export function DiscussionMarkerRail({
|
|
|
175
175
|
onHover={setHovered}
|
|
176
176
|
onJump={onJump}
|
|
177
177
|
/>
|
|
178
|
-
{scrollbox ? <
|
|
178
|
+
{scrollbox ? <OverlayScrollbar scrollbox={scrollbox} theme={theme} /> : null}
|
|
179
179
|
</>
|
|
180
180
|
);
|
|
181
181
|
}
|
|
@@ -39,11 +39,24 @@ describe("visibleTabWindow", () => {
|
|
|
39
39
|
// Act / Assert
|
|
40
40
|
expect(visibleTabWindow(wide, 0, 0, 10)).toEqual({ first: 0, end: 1 });
|
|
41
41
|
});
|
|
42
|
+
|
|
43
|
+
test("the comment badge counts toward a tab's width, so a commented run does not overflow", () => {
|
|
44
|
+
// Arrange - three 9-cell tabs fit a 27-cell strip exactly with no badge
|
|
45
|
+
const three = ["aaaa", "bbbb", "cccc"].map((name) => fileTab(name, name, "diff"));
|
|
46
|
+
|
|
47
|
+
// Act - without badges all three fit; a ` ● 2` badge on each widens them past the strip
|
|
48
|
+
const bare = visibleTabWindow(three, 0, 0, 27);
|
|
49
|
+
const badged = visibleTabWindow(three, 0, 0, 27, () => 2);
|
|
50
|
+
|
|
51
|
+
// Assert - the badge width narrows the window rather than clipping the last tab
|
|
52
|
+
expect(bare.end).toBe(3);
|
|
53
|
+
expect(badged.end).toBeLessThan(3);
|
|
54
|
+
});
|
|
42
55
|
});
|
|
43
56
|
|
|
44
57
|
const NAMES = [
|
|
45
58
|
"AppShell.tsx",
|
|
46
|
-
"
|
|
59
|
+
"DiffContentView.tsx",
|
|
47
60
|
"IconButton.tsx",
|
|
48
61
|
"EditorGrid.zoom.test.tsx",
|
|
49
62
|
"ThreadView.tsx",
|
|
@@ -89,9 +102,9 @@ describe("editor tab strip", () => {
|
|
|
89
102
|
// Assert - no squeezed labels: every visible tab reads in full up to the clip edge
|
|
90
103
|
expect(row).toContain("Changes ");
|
|
91
104
|
expect(row).toContain("AppShell.tsx ");
|
|
92
|
-
expect(row).toContain("
|
|
93
|
-
// and the header controls sit at the right edge, past the clipped strip
|
|
94
|
-
expect(row.trimEnd().endsWith(
|
|
105
|
+
expect(row).toContain("DiffContentView.tsx ");
|
|
106
|
+
// and the header controls sit at the right edge, past the clipped strip (search is out for now)
|
|
107
|
+
expect(row.trimEnd().endsWith(NERD.zoom)).toBe(true);
|
|
95
108
|
});
|
|
96
109
|
|
|
97
110
|
test("the strip scrolls to reveal the active tab", async () => {
|
|
@@ -91,9 +91,11 @@ function EditorTabButton({
|
|
|
91
91
|
);
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
/** Cells a tab occupies:
|
|
95
|
-
function tabCellWidth(tab: EditorTab): number {
|
|
96
|
-
|
|
94
|
+
/** Cells a tab occupies: padding, label, the ` ● N` comment badge when present, the close cell, the right rule. */
|
|
95
|
+
export function tabCellWidth(tab: EditorTab, commentCount = 0): number {
|
|
96
|
+
const badge = commentCount > 0 ? 3 + String(commentCount).length : 0;
|
|
97
|
+
|
|
98
|
+
return tab.label.length + 5 + badge;
|
|
97
99
|
}
|
|
98
100
|
|
|
99
101
|
/** The run of tabs `[first, end)` the strip shows. */
|
|
@@ -108,9 +110,11 @@ export function visibleTabWindow(
|
|
|
108
110
|
firstVisible: number,
|
|
109
111
|
activeIndex: number,
|
|
110
112
|
stripWidth: number,
|
|
113
|
+
commentCount: (tab: EditorTab) => number = () => 0,
|
|
111
114
|
): TabWindow {
|
|
112
115
|
// unmeasured strip: show everything, the header clips until the width is known
|
|
113
116
|
if (stripWidth <= 0) return { first: 0, end: tabs.length };
|
|
117
|
+
const width = (index: number): number => tabCellWidth(tabs[index]!, commentCount(tabs[index]!));
|
|
114
118
|
const fitEnd = (first: number): number => {
|
|
115
119
|
let used = first > 0 ? 1 : 0;
|
|
116
120
|
let end = first;
|
|
@@ -118,8 +122,8 @@ export function visibleTabWindow(
|
|
|
118
122
|
while (end < tabs.length) {
|
|
119
123
|
const remaining = end + 1 < tabs.length ? 1 : 0;
|
|
120
124
|
|
|
121
|
-
if (used +
|
|
122
|
-
used +=
|
|
125
|
+
if (used + width(end) + remaining > stripWidth) break;
|
|
126
|
+
used += width(end);
|
|
123
127
|
end++;
|
|
124
128
|
}
|
|
125
129
|
|
|
@@ -192,12 +196,22 @@ function EditorGroupPane({
|
|
|
192
196
|
0,
|
|
193
197
|
);
|
|
194
198
|
const [firstVisible, setFirstVisible] = useState(0);
|
|
199
|
+
// a zoom re-anchors the strip to the leftmost tab, so the new width fills from the left
|
|
200
|
+
// and the step markers disappear when every tab now fits
|
|
201
|
+
const [wasZoomed, setWasZoomed] = useState(props.zoomed);
|
|
202
|
+
|
|
203
|
+
if (wasZoomed !== props.zoomed) {
|
|
204
|
+
setWasZoomed(props.zoomed);
|
|
205
|
+
setFirstVisible(0);
|
|
206
|
+
}
|
|
195
207
|
const activeIndex = Math.max(
|
|
196
208
|
0,
|
|
197
209
|
group.tabs.findIndex((tab) => tab.id === active?.id),
|
|
198
210
|
);
|
|
199
211
|
// derived every render: the manual offset is a floor the active tab can pull the window past
|
|
200
|
-
const tabWindow = visibleTabWindow(group.tabs, firstVisible, activeIndex, stripWidth)
|
|
212
|
+
const tabWindow = visibleTabWindow(group.tabs, firstVisible, activeIndex, stripWidth, (tab) =>
|
|
213
|
+
tab.path ? (props.commentCounts?.get(tab.path) ?? 0) : 0,
|
|
214
|
+
);
|
|
201
215
|
|
|
202
216
|
return (
|
|
203
217
|
<box
|
|
@@ -227,8 +241,16 @@ function EditorGroupPane({
|
|
|
227
241
|
style={{ flexDirection: "row", flexGrow: 1, flexShrink: 1, flexBasis: 0, minWidth: 0 }}
|
|
228
242
|
>
|
|
229
243
|
{tabWindow.first > 0 ? (
|
|
230
|
-
<box
|
|
231
|
-
|
|
244
|
+
<box
|
|
245
|
+
onMouseUp={(event) => {
|
|
246
|
+
event.stopPropagation();
|
|
247
|
+
setFirstVisible(tabWindow.first - 1);
|
|
248
|
+
}}
|
|
249
|
+
style={{ flexShrink: 0 }}
|
|
250
|
+
>
|
|
251
|
+
<text fg={tokens.textDim} selectable={false}>
|
|
252
|
+
{"‹"}
|
|
253
|
+
</text>
|
|
232
254
|
</box>
|
|
233
255
|
) : null}
|
|
234
256
|
{group.tabs.slice(tabWindow.first, tabWindow.end).map((tab) => (
|
|
@@ -243,29 +265,37 @@ function EditorGroupPane({
|
|
|
243
265
|
/>
|
|
244
266
|
))}
|
|
245
267
|
{tabWindow.end < group.tabs.length ? (
|
|
246
|
-
<box
|
|
247
|
-
|
|
268
|
+
<box
|
|
269
|
+
onMouseUp={(event) => {
|
|
270
|
+
event.stopPropagation();
|
|
271
|
+
setFirstVisible(tabWindow.first + 1);
|
|
272
|
+
}}
|
|
273
|
+
style={{ flexShrink: 0 }}
|
|
274
|
+
>
|
|
275
|
+
<text fg={tokens.textDim} selectable={false}>
|
|
276
|
+
{"›"}
|
|
277
|
+
</text>
|
|
248
278
|
</box>
|
|
249
279
|
) : null}
|
|
250
280
|
</box>
|
|
251
281
|
<box style={{ flexDirection: "row", flexShrink: 0, paddingLeft: 1, paddingRight: 1 }}>
|
|
252
|
-
|
|
253
|
-
<IconButton
|
|
254
|
-
glyph={NERD.zoom}
|
|
255
|
-
active={props.zoomed}
|
|
256
|
-
onPress={props.onZoom}
|
|
257
|
-
tip={props.zoomed ? "Zoom Out" : "Zoom In"}
|
|
258
|
-
marginRight={isFile ? 2 : 0}
|
|
259
|
-
theme={tokens}
|
|
260
|
-
/>
|
|
282
|
+
{/* search sits out until it does something; split takes its place */}
|
|
261
283
|
{isFile ? (
|
|
262
284
|
<IconButton
|
|
263
285
|
glyph="split"
|
|
264
286
|
active={menuOpen}
|
|
265
287
|
onPress={() => setMenuOpen((open) => !open)}
|
|
288
|
+
marginRight={2}
|
|
266
289
|
theme={tokens}
|
|
267
290
|
/>
|
|
268
291
|
) : null}
|
|
292
|
+
<IconButton
|
|
293
|
+
glyph={NERD.zoom}
|
|
294
|
+
active={props.zoomed}
|
|
295
|
+
onPress={props.onZoom}
|
|
296
|
+
tip={props.zoomed ? "Zoom Out" : "Zoom In"}
|
|
297
|
+
theme={tokens}
|
|
298
|
+
/>
|
|
269
299
|
</box>
|
|
270
300
|
</box>
|
|
271
301
|
{menuOpen ? (
|