@cueloop/client 0.1.0-alpha.68 → 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/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.ts +42 -3
- 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
|
@@ -18,12 +18,12 @@ test("renders the loaded file contents", async () => {
|
|
|
18
18
|
await waitForText(setup, "const answer = 42;");
|
|
19
19
|
});
|
|
20
20
|
|
|
21
|
-
test("shows a
|
|
21
|
+
test("shows a centered File deleted message when the file cannot be read", async () => {
|
|
22
22
|
const setup = await testRender(
|
|
23
23
|
<FileContentsView path="missing.ts" loadContents={() => Promise.resolve(null)} theme={DARK} />,
|
|
24
24
|
{ width: 60, height: 10 },
|
|
25
25
|
);
|
|
26
26
|
|
|
27
|
-
await waitForText(setup, "
|
|
28
|
-
expect(setup.captureCharFrame()).toContain("
|
|
27
|
+
await waitForText(setup, "File deleted");
|
|
28
|
+
expect(setup.captureCharFrame()).toContain("File deleted");
|
|
29
29
|
});
|
|
@@ -1,9 +1,15 @@
|
|
|
1
|
-
// A Changes file tab in contents mode: a workspace file rendered read-only with line
|
|
2
|
-
//
|
|
1
|
+
// A Changes/Project file tab in contents mode: a workspace file rendered read-only with line
|
|
2
|
+
// numbers, for surfaces with no review session to anchor notes to (the bare-launch welcome shell).
|
|
3
|
+
// Loads on mount and whenever the path changes; a null read renders a centered "File deleted".
|
|
4
|
+
// Syntax highlighting comes from the native code renderable (tree-sitter), the language
|
|
5
|
+
// auto-detected from the path; unknown languages simply render unstyled.
|
|
3
6
|
|
|
7
|
+
import { ScrollArea } from "./ScrollArea";
|
|
4
8
|
import React, { useEffect, useRef, useState } from "react";
|
|
9
|
+
import type { CodeRenderable } from "@opentui/core";
|
|
5
10
|
import type { Theme } from "../theme";
|
|
6
11
|
import { useComponentTheme } from "./theme-context";
|
|
12
|
+
import { filetypeForPath, syntaxStyleFor } from "./syntax-highlight";
|
|
7
13
|
|
|
8
14
|
export interface FileContentsViewProps {
|
|
9
15
|
path: string;
|
|
@@ -24,6 +30,9 @@ export function FileContentsView({
|
|
|
24
30
|
}: FileContentsViewProps): React.ReactNode {
|
|
25
31
|
const tokens = useComponentTheme(theme);
|
|
26
32
|
const [loaded, setLoaded] = useState<FileLoad | null>(null);
|
|
33
|
+
// The gutter mirrors the code renderable's line info, so it needs the mounted
|
|
34
|
+
// instance; a state-backed ref rebinds the target once the code renderable exists.
|
|
35
|
+
const [codeTarget, setCodeTarget] = useState<CodeRenderable | null>(null);
|
|
27
36
|
const loadRef = useRef(loadContents);
|
|
28
37
|
useEffect(() => {
|
|
29
38
|
loadRef.current = loadContents;
|
|
@@ -53,26 +62,37 @@ export function FileContentsView({
|
|
|
53
62
|
);
|
|
54
63
|
}
|
|
55
64
|
if (loaded.lines === null) {
|
|
65
|
+
// a file that no longer reads in the Changes/Project view is one the working tree deleted;
|
|
66
|
+
// the bottom pad lifts the text one row so it lines up with the footer-shortened thread empty state
|
|
56
67
|
return (
|
|
57
|
-
<box
|
|
58
|
-
|
|
68
|
+
<box
|
|
69
|
+
style={{ flexGrow: 1, alignItems: "center", justifyContent: "center", paddingBottom: 2 }}
|
|
70
|
+
>
|
|
71
|
+
<text fg={tokens.textDim}>File deleted</text>
|
|
59
72
|
</box>
|
|
60
73
|
);
|
|
61
74
|
}
|
|
62
|
-
const
|
|
75
|
+
const content = loaded.lines.join("\n");
|
|
63
76
|
|
|
64
77
|
return (
|
|
65
|
-
<
|
|
66
|
-
<
|
|
67
|
-
{
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
78
|
+
<ScrollArea>
|
|
79
|
+
<line-number
|
|
80
|
+
target={codeTarget ?? undefined}
|
|
81
|
+
showLineNumbers
|
|
82
|
+
fg={tokens.textDim}
|
|
83
|
+
minWidth={5}
|
|
84
|
+
paddingRight={1}
|
|
85
|
+
style={{ paddingTop: 1, paddingLeft: 1 }}
|
|
86
|
+
>
|
|
87
|
+
<code
|
|
88
|
+
ref={setCodeTarget}
|
|
89
|
+
content={content}
|
|
90
|
+
filetype={filetypeForPath(path)}
|
|
91
|
+
syntaxStyle={syntaxStyleFor(tokens)}
|
|
92
|
+
selectable={false}
|
|
93
|
+
style={{ wrapMode: "none", fg: tokens.text }}
|
|
94
|
+
/>
|
|
95
|
+
</line-number>
|
|
96
|
+
</ScrollArea>
|
|
77
97
|
);
|
|
78
98
|
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import React, { useRef } from "react";
|
|
2
2
|
import type { ScrollBoxRenderable } from "@opentui/core";
|
|
3
3
|
import { DARK } from "../theme";
|
|
4
|
-
import {
|
|
4
|
+
import { OverlayScrollbar } from "./OverlayScrollbar";
|
|
5
5
|
import type { Story, StoryMeta } from "./story";
|
|
6
6
|
|
|
7
|
-
export const meta: StoryMeta = { title: "Chrome/
|
|
7
|
+
export const meta: StoryMeta = { title: "Chrome/OverlayScrollbar" };
|
|
8
8
|
|
|
9
9
|
function TallSurface(): React.ReactNode {
|
|
10
10
|
const scrollRef = useRef<ScrollBoxRenderable | null>(null);
|
|
@@ -21,14 +21,13 @@ function TallSurface(): React.ReactNode {
|
|
|
21
21
|
<text key={index} fg={DARK.textMuted}>{`row ${index + 1}`}</text>
|
|
22
22
|
))}
|
|
23
23
|
</scrollbox>
|
|
24
|
-
<
|
|
24
|
+
<OverlayScrollbar scrollbox={scrollRef} />
|
|
25
25
|
</box>
|
|
26
26
|
);
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
//
|
|
30
|
-
export const
|
|
29
|
+
// At rest the overlay bar is hidden; the thumb only appears while the surface is scrolling.
|
|
30
|
+
export const AtRest: Story = {
|
|
31
31
|
render: () => <TallSurface />,
|
|
32
|
-
expectedColors: [DARK.textDim],
|
|
33
32
|
size: { width: 24, height: 8 },
|
|
34
33
|
};
|
|
@@ -6,16 +6,28 @@
|
|
|
6
6
|
|
|
7
7
|
import { describe, expect, test } from "bun:test";
|
|
8
8
|
import { testRender } from "@opentui/react/test-utils";
|
|
9
|
+
import { ScrollBoxRenderable, type Renderable } from "@opentui/core";
|
|
9
10
|
import React from "react";
|
|
10
|
-
import {
|
|
11
|
+
import { DiffContentView } from "./DiffContentView";
|
|
11
12
|
import { diffRows, marksByRows } from "../view-diff";
|
|
12
13
|
import { allowEventLoopUpdates } from "../test-support";
|
|
13
14
|
import { fixtureDiffSession } from "./story-fixtures";
|
|
14
15
|
import { DARK } from "../theme";
|
|
15
|
-
import { thumbGeometry } from "./
|
|
16
|
+
import { thumbGeometry } from "./OverlayScrollbar";
|
|
16
17
|
|
|
17
18
|
const noop = (): void => {};
|
|
18
19
|
|
|
20
|
+
function findById(node: Renderable, id: string): Renderable | undefined {
|
|
21
|
+
if (node.id === id) return node;
|
|
22
|
+
for (const child of node.getChildren()) {
|
|
23
|
+
const found = findById(child, id);
|
|
24
|
+
|
|
25
|
+
if (found) return found;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
19
31
|
function hex(color: { toInts(): [number, number, number, number] }): string {
|
|
20
32
|
const [red, green, blue] = color.toInts();
|
|
21
33
|
|
|
@@ -69,7 +81,7 @@ describe("the diff sheet's scrollbar", () => {
|
|
|
69
81
|
});
|
|
70
82
|
const width = 60;
|
|
71
83
|
const setup = await testRender(
|
|
72
|
-
<
|
|
84
|
+
<DiffContentView
|
|
73
85
|
rows={rows}
|
|
74
86
|
session={session}
|
|
75
87
|
marks={marksByRows(session.annotations, rows)}
|
|
@@ -85,17 +97,33 @@ describe("the diff sheet's scrollbar", () => {
|
|
|
85
97
|
|
|
86
98
|
allowEventLoopUpdates();
|
|
87
99
|
await setup.waitForVisualIdle();
|
|
100
|
+
// the overlay bar hides at rest; a scroll reveals the thumb
|
|
101
|
+
const scroller = findById(setup.renderer.root, "diff-scroll");
|
|
102
|
+
|
|
103
|
+
if (!(scroller instanceof ScrollBoxRenderable))
|
|
104
|
+
throw new Error("diff-scroll is not a scrollbox");
|
|
105
|
+
const countThumbRows = (): number =>
|
|
106
|
+
setup.captureSpans().lines.filter((line) => {
|
|
107
|
+
let column = 0;
|
|
108
|
+
|
|
109
|
+
for (const span of line.spans) {
|
|
110
|
+
column += span.text.length;
|
|
111
|
+
if (column >= width) return span.bg !== undefined && hex(span.bg) === DARK.textDim;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return false;
|
|
115
|
+
}).length;
|
|
116
|
+
|
|
117
|
+
// the overlay bar reveals on a scroll and hides after an idle delay; alternate the offset to
|
|
118
|
+
// re-trigger the reveal until the thumb is captured, so a slow CI settle never races the hide
|
|
119
|
+
let thumbRows = 0;
|
|
120
|
+
|
|
121
|
+
for (let attempt = 0; attempt < 20 && thumbRows === 0; attempt++) {
|
|
122
|
+
scroller.scrollTo(attempt % 2 === 0 ? 4 : 5);
|
|
123
|
+
await setup.waitForVisualIdle();
|
|
124
|
+
thumbRows = countThumbRows();
|
|
125
|
+
}
|
|
88
126
|
const frame = setup.captureCharFrame().split("\n");
|
|
89
|
-
const thumbRows = setup.captureSpans().lines.filter((line) => {
|
|
90
|
-
let column = 0;
|
|
91
|
-
|
|
92
|
-
for (const span of line.spans) {
|
|
93
|
-
column += span.text.length;
|
|
94
|
-
if (column >= width) return span.bg !== undefined && hex(span.bg) === DARK.textDim;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
return false;
|
|
98
|
-
}).length;
|
|
99
127
|
|
|
100
128
|
// Assert - the thumb occupies the last column at the top (scroll offset 0), and the dot sits
|
|
101
129
|
// to its left in the rail, so the order reads content, dots, scrollbar
|
|
@@ -1,29 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* A one-cell vertical scrollbar that mirrors a scrollbox's scroll state, so the bar can sit
|
|
3
3
|
* where the layout wants it (the panel's rightmost column, past the discussion dots) instead
|
|
4
|
-
* of inside the scrollbox's own viewport row.
|
|
5
|
-
*
|
|
4
|
+
* of inside the scrollbox's own viewport row. It is an overlay: the thumb appears while the
|
|
5
|
+
* surface is scrolling and hides once scrolling has been idle, so a resting surface shows none.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import React, { useRef, type RefObject } from "react";
|
|
8
|
+
import React, { useEffect, useRef, useState, type RefObject } from "react";
|
|
9
9
|
import type { BoxRenderable, ScrollBoxRenderable } from "@opentui/core";
|
|
10
10
|
import type { Theme } from "../theme";
|
|
11
11
|
import { useFrameMeasure } from "../use-frame-measure";
|
|
12
12
|
import { useComponentTheme } from "./theme-context";
|
|
13
13
|
|
|
14
|
+
const HIDE_DELAY_MS = 1500;
|
|
15
|
+
|
|
14
16
|
interface ScrollState {
|
|
15
17
|
top: number;
|
|
16
18
|
height: number;
|
|
17
19
|
viewport: number;
|
|
18
20
|
}
|
|
19
21
|
|
|
20
|
-
/** The thumb's row and height for a viewport of `track` rows over the scroll state. */
|
|
21
22
|
export interface ThumbGeometry {
|
|
22
23
|
row: number;
|
|
23
24
|
height: number;
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
/** Where the thumb sits: proportional to the scroll offset, never shorter than one row, or null when nothing scrolls. */
|
|
27
27
|
export function thumbGeometry(state: ScrollState, track: number): ThumbGeometry | null {
|
|
28
28
|
if (track <= 0 || state.height <= state.viewport || state.viewport <= 0) return null;
|
|
29
29
|
const height = Math.max(1, Math.round((state.viewport / state.height) * track));
|
|
@@ -33,14 +33,19 @@ export function thumbGeometry(state: ScrollState, track: number): ThumbGeometry
|
|
|
33
33
|
return { row: Math.min(track - height, Math.max(0, row)), height };
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
export interface
|
|
36
|
+
export interface OverlayScrollbarProps {
|
|
37
37
|
scrollbox: RefObject<ScrollBoxRenderable | null>;
|
|
38
38
|
theme?: Theme;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
export function
|
|
41
|
+
export function OverlayScrollbar({ scrollbox, theme }: OverlayScrollbarProps): React.ReactNode {
|
|
42
42
|
const tokens = useComponentTheme(theme);
|
|
43
43
|
const trackRef = useRef<BoxRenderable | null>(null);
|
|
44
|
+
const [visible, setVisible] = useState(false);
|
|
45
|
+
const draggingRef = useRef(false);
|
|
46
|
+
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
47
|
+
const prevTopRef = useRef<number | null>(null);
|
|
48
|
+
|
|
44
49
|
const state = useFrameMeasure<ScrollState>(
|
|
45
50
|
() => ({
|
|
46
51
|
top: scrollbox.current?.scrollTop ?? 0,
|
|
@@ -56,25 +61,56 @@ export function SurfaceScrollbar({ scrollbox, theme }: SurfaceScrollbarProps): R
|
|
|
56
61
|
(left, right) => left === right,
|
|
57
62
|
0,
|
|
58
63
|
);
|
|
59
|
-
const thumb = thumbGeometry(state, track);
|
|
60
64
|
|
|
61
|
-
|
|
65
|
+
const reveal = (): void => {
|
|
66
|
+
setVisible(true);
|
|
67
|
+
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
|
|
68
|
+
hideTimerRef.current = setTimeout(() => {
|
|
69
|
+
if (!draggingRef.current) setVisible(false);
|
|
70
|
+
}, HIDE_DELAY_MS);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// reveal on real scroll movement, never on first mount, then hide once idle
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (prevTopRef.current !== null && state.top !== prevTopRef.current) reveal();
|
|
76
|
+
prevTopRef.current = state.top;
|
|
77
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
78
|
+
}, [state.top]);
|
|
79
|
+
|
|
80
|
+
useEffect(
|
|
81
|
+
() => () => {
|
|
82
|
+
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
|
|
83
|
+
},
|
|
84
|
+
[],
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const geometry = thumbGeometry(state, track);
|
|
88
|
+
const thumb = visible ? geometry : null;
|
|
89
|
+
|
|
62
90
|
const scrollToTrackRow = (screenY: number): void => {
|
|
63
91
|
const box = scrollbox.current;
|
|
64
92
|
|
|
65
|
-
if (!box || !
|
|
66
|
-
const row = screenY - (trackRef.current?.y ?? 0) - Math.floor(
|
|
67
|
-
const ratio = Math.min(1, Math.max(0, row / Math.max(1, track -
|
|
93
|
+
if (!box || !geometry) return;
|
|
94
|
+
const row = screenY - (trackRef.current?.y ?? 0) - Math.floor(geometry.height / 2);
|
|
95
|
+
const ratio = Math.min(1, Math.max(0, row / Math.max(1, track - geometry.height)));
|
|
68
96
|
|
|
69
97
|
box.scrollTo(Math.round(ratio * (state.height - state.viewport)));
|
|
98
|
+
reveal();
|
|
70
99
|
};
|
|
71
100
|
|
|
72
101
|
return (
|
|
73
102
|
<box
|
|
74
103
|
ref={trackRef}
|
|
75
104
|
style={{ width: 1, flexShrink: 0, flexDirection: "column" }}
|
|
76
|
-
onMouseDown={(event) =>
|
|
105
|
+
onMouseDown={(event) => {
|
|
106
|
+
draggingRef.current = true;
|
|
107
|
+
scrollToTrackRow(event.y);
|
|
108
|
+
}}
|
|
77
109
|
onMouseDrag={(event) => scrollToTrackRow(event.y)}
|
|
110
|
+
onMouseUp={() => {
|
|
111
|
+
draggingRef.current = false;
|
|
112
|
+
reveal();
|
|
113
|
+
}}
|
|
78
114
|
>
|
|
79
115
|
{thumb ? (
|
|
80
116
|
<box
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Folders start collapsed and expand on click; a file click opens it as a tab. The caller keys this
|
|
3
3
|
// on the session id so switching sessions remounts it and never shows the previous repository's paths.
|
|
4
4
|
|
|
5
|
+
import { ScrollArea } from "./ScrollArea";
|
|
5
6
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
6
7
|
import type { Theme } from "../theme";
|
|
7
8
|
import { useComponentTheme } from "./theme-context";
|
|
@@ -60,7 +61,7 @@ export function ProjectTreeView({
|
|
|
60
61
|
}
|
|
61
62
|
|
|
62
63
|
return (
|
|
63
|
-
<
|
|
64
|
+
<ScrollArea>
|
|
64
65
|
<Tree
|
|
65
66
|
nodes={nodes}
|
|
66
67
|
expandedIds={expandedIds}
|
|
@@ -77,6 +78,6 @@ export function ProjectTreeView({
|
|
|
77
78
|
}
|
|
78
79
|
theme={theme}
|
|
79
80
|
/>
|
|
80
|
-
</
|
|
81
|
+
</ScrollArea>
|
|
81
82
|
);
|
|
82
83
|
}
|
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { DARK } from "../theme";
|
|
3
3
|
import type { Story, StoryMeta } from "./story";
|
|
4
|
-
import {
|
|
4
|
+
import { PrototypeContentView } from "./PrototypeContentView";
|
|
5
5
|
|
|
6
|
-
export const meta: StoryMeta = { title: "Surfaces/
|
|
6
|
+
export const meta: StoryMeta = { title: "Surfaces/PrototypeContentView" };
|
|
7
7
|
|
|
8
|
-
// The story renderer reports no kitty graphics, so the
|
|
8
|
+
// The story renderer reports no kitty graphics, so the view shows its fallback.
|
|
9
9
|
export const Unsupported: Story = {
|
|
10
10
|
render: () => (
|
|
11
|
-
<
|
|
11
|
+
<PrototypeContentView
|
|
12
12
|
prototypePath="/tmp/example.html"
|
|
13
|
-
quickActions={[]}
|
|
14
13
|
canComment={true}
|
|
15
14
|
onCommentElement={() => {}}
|
|
16
15
|
/>
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Prototype review surface: renders the HTML screenshot into a reserved cell
|
|
3
3
|
* region via the kitty graphics protocol, and turns a click into a DOM-element
|
|
4
|
-
* selection
|
|
5
|
-
* element rather than a text span. The
|
|
4
|
+
* selection that opens the shared compose card - the same edit primitive plan
|
|
5
|
+
* and diff use - so a note anchors to an element rather than a text span. The
|
|
6
|
+
* image is painted directly (not through
|
|
6
7
|
* OpenTUI's ImageRenderable, which does not display in every terminal): the
|
|
7
8
|
* region's cells are never repainted, and the picture is re-placed after each
|
|
8
9
|
* frame so it survives OpenTUI's own draws.
|
|
@@ -13,11 +14,9 @@ import type { BoxRenderable } from "@opentui/core";
|
|
|
13
14
|
import * as v from "valibot";
|
|
14
15
|
import { useKeyboard, useRenderer } from "@opentui/react";
|
|
15
16
|
import type { Theme } from "../theme";
|
|
16
|
-
import { quickActionBody, type QuickAction } from "../config";
|
|
17
17
|
import { useComponentTheme } from "./theme-context";
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import { AnnotationCard } from "./AnnotationCard";
|
|
18
|
+
import { Composer, DiscussionCard } from "./AnnotationCards";
|
|
19
|
+
import { annotationPaletteFor } from "../annotation-palette";
|
|
21
20
|
import {
|
|
22
21
|
cssBoxToCell,
|
|
23
22
|
imageCellToCss,
|
|
@@ -34,9 +33,8 @@ import {
|
|
|
34
33
|
type CellRegion,
|
|
35
34
|
} from "../kitty-image";
|
|
36
35
|
|
|
37
|
-
export interface
|
|
36
|
+
export interface PrototypeContentViewProps {
|
|
38
37
|
prototypePath: string;
|
|
39
|
-
quickActions: QuickAction[];
|
|
40
38
|
canComment: boolean;
|
|
41
39
|
onCommentElement: (element: PrototypeElement, body: string) => void;
|
|
42
40
|
/** Signals when the inline compose owns the keyboard, so the app suppresses
|
|
@@ -50,7 +48,7 @@ export interface PrototypeSheetProps {
|
|
|
50
48
|
|
|
51
49
|
type SheetStatus = "loading" | "ready" | "unsupported" | "error";
|
|
52
50
|
|
|
53
|
-
const
|
|
51
|
+
const OVERLAY_ROWS = 3;
|
|
54
52
|
/** Compose-card width, so the floating composer reads like the plan/diff one
|
|
55
53
|
* instead of shrinking to its content. */
|
|
56
54
|
const COMPOSE_COLS = 46;
|
|
@@ -123,16 +121,33 @@ function captureConfig(region: CellRegion, renderer: CaptureRenderer) {
|
|
|
123
121
|
};
|
|
124
122
|
}
|
|
125
123
|
|
|
126
|
-
|
|
124
|
+
/**
|
|
125
|
+
* The terminal's scheme, read from its reading-text color: a dark terminal
|
|
126
|
+
* carries light text, a light terminal dark text. Passed to the renderer as
|
|
127
|
+
* prefers-color-scheme so a theme-aware mockup emerges into the right surface.
|
|
128
|
+
*/
|
|
129
|
+
function terminalColorScheme(textColor: string): "dark" | "light" {
|
|
130
|
+
const hex = textColor.replace("#", "");
|
|
131
|
+
|
|
132
|
+
if (hex.length < 6) return "dark";
|
|
133
|
+
const r = parseInt(hex.slice(0, 2), 16);
|
|
134
|
+
const g = parseInt(hex.slice(2, 4), 16);
|
|
135
|
+
const b = parseInt(hex.slice(4, 6), 16);
|
|
136
|
+
const luminance = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
|
|
137
|
+
|
|
138
|
+
return luminance > 0.5 ? "dark" : "light";
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function PrototypeContentViewImpl({
|
|
127
142
|
prototypePath,
|
|
128
|
-
quickActions,
|
|
129
143
|
canComment,
|
|
130
144
|
onCommentElement,
|
|
131
145
|
onComposingChange,
|
|
132
146
|
hidden = false,
|
|
133
147
|
theme,
|
|
134
|
-
}:
|
|
148
|
+
}: PrototypeContentViewProps): React.ReactNode {
|
|
135
149
|
const tokens = useComponentTheme(theme);
|
|
150
|
+
const palette = annotationPaletteFor(tokens);
|
|
136
151
|
const renderer = useRenderer();
|
|
137
152
|
const regionRef = useRef<BoxRenderable | null>(null);
|
|
138
153
|
const browserRef = useRef<PrototypeRenderer | null>(null);
|
|
@@ -156,9 +171,7 @@ function PrototypeSheetImpl({
|
|
|
156
171
|
top: number;
|
|
157
172
|
regionColumns: number;
|
|
158
173
|
} | null>(null);
|
|
159
|
-
const [actionsOpen, setActionsOpen] = useState(false);
|
|
160
174
|
const [composing, setComposing] = useState(false);
|
|
161
|
-
const [draftText, setDraftText] = useState("");
|
|
162
175
|
|
|
163
176
|
const setPng = (png: Uint8Array): void => {
|
|
164
177
|
pngRef.current = png;
|
|
@@ -247,6 +260,7 @@ function PrototypeSheetImpl({
|
|
|
247
260
|
filePath: prototypePath,
|
|
248
261
|
viewport: capture.viewport,
|
|
249
262
|
deviceScaleFactor: capture.deviceScaleFactor,
|
|
263
|
+
colorScheme: terminalColorScheme(tokens.text),
|
|
250
264
|
});
|
|
251
265
|
|
|
252
266
|
// the sheet may have unmounted while Chromium was launching; close the
|
|
@@ -279,9 +293,7 @@ function PrototypeSheetImpl({
|
|
|
279
293
|
const clearSelection = (): void => {
|
|
280
294
|
setSelected(null);
|
|
281
295
|
setOverlayCell(null);
|
|
282
|
-
setActionsOpen(false);
|
|
283
296
|
setComposing(false);
|
|
284
|
-
setDraftText("");
|
|
285
297
|
};
|
|
286
298
|
|
|
287
299
|
// While the compose textarea owns the keyboard, the global keymap is
|
|
@@ -315,11 +327,17 @@ function PrototypeSheetImpl({
|
|
|
315
327
|
setSelected(element);
|
|
316
328
|
setOverlayCell({
|
|
317
329
|
left: Math.max(0, cell.column - geometry.x),
|
|
318
|
-
top: Math.max(0, topRaw <
|
|
330
|
+
top: Math.max(0, topRaw < OVERLAY_ROWS ? topRaw + 1 : topRaw - OVERLAY_ROWS),
|
|
319
331
|
regionColumns: geometry.width,
|
|
320
332
|
});
|
|
321
|
-
|
|
322
|
-
|
|
333
|
+
// selecting an element opens the compose card directly, the same edit
|
|
334
|
+
// primitive plan and diff use - there is no intermediate marker toolbar
|
|
335
|
+
if (canComment) {
|
|
336
|
+
onComposingChange?.(true);
|
|
337
|
+
setComposing(true);
|
|
338
|
+
} else {
|
|
339
|
+
setComposing(false);
|
|
340
|
+
}
|
|
323
341
|
})().catch((error) => {
|
|
324
342
|
setErrorMessage(error instanceof Error ? error.message : String(error));
|
|
325
343
|
setStatus("error");
|
|
@@ -346,8 +364,8 @@ function PrototypeSheetImpl({
|
|
|
346
364
|
clearSelection();
|
|
347
365
|
};
|
|
348
366
|
|
|
349
|
-
// the overlay carries an opaque fill so the
|
|
350
|
-
//
|
|
367
|
+
// the overlay carries an opaque fill so the compose card reads as a solid
|
|
368
|
+
// card over the image, matching the plan surface
|
|
351
369
|
const overlayStyle = {
|
|
352
370
|
position: "absolute" as const,
|
|
353
371
|
left: overlayCell?.left ?? 0,
|
|
@@ -370,9 +388,6 @@ function PrototypeSheetImpl({
|
|
|
370
388
|
style={{
|
|
371
389
|
flexGrow: 1,
|
|
372
390
|
flexDirection: "column",
|
|
373
|
-
border: true,
|
|
374
|
-
borderStyle: FRAME_BORDER_STYLE,
|
|
375
|
-
borderColor: tokens.text,
|
|
376
391
|
}}
|
|
377
392
|
>
|
|
378
393
|
<box
|
|
@@ -389,40 +404,25 @@ function PrototypeSheetImpl({
|
|
|
389
404
|
<text fg={tokens.textDim}>{statusLine(status, errorMessage)}</text>
|
|
390
405
|
</box>
|
|
391
406
|
) : null}
|
|
392
|
-
{selected && !composing ? (
|
|
393
|
-
<box style={overlayStyle}>
|
|
394
|
-
<MarkerPopover
|
|
395
|
-
view={actionsOpen ? "actions" : "toolbar"}
|
|
396
|
-
actions={quickActions}
|
|
397
|
-
actionIndex={0}
|
|
398
|
-
canCut={false}
|
|
399
|
-
onComment={() => {
|
|
400
|
-
if (!canComment) return;
|
|
401
|
-
onComposingChange?.(true);
|
|
402
|
-
setComposing(true);
|
|
403
|
-
}}
|
|
404
|
-
onCut={() => undefined}
|
|
405
|
-
onOpenActions={() => setActionsOpen(true)}
|
|
406
|
-
onClose={clearSelection}
|
|
407
|
-
onPickAction={(index) => commit(quickActionBody(quickActions[index]!))}
|
|
408
|
-
onBack={() => setActionsOpen(false)}
|
|
409
|
-
theme={theme}
|
|
410
|
-
/>
|
|
411
|
-
</box>
|
|
412
|
-
) : null}
|
|
413
407
|
{selected && composing ? (
|
|
414
408
|
<box style={composeStyle}>
|
|
415
|
-
<
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
409
|
+
<DiscussionCard
|
|
410
|
+
tokens={tokens}
|
|
411
|
+
segments={[
|
|
412
|
+
{
|
|
413
|
+
color: palette.cardEdge,
|
|
414
|
+
node: (
|
|
415
|
+
<Composer
|
|
416
|
+
seed=""
|
|
417
|
+
glyph="●"
|
|
418
|
+
tokens={tokens}
|
|
419
|
+
onSave={commit}
|
|
420
|
+
onReady={() => undefined}
|
|
421
|
+
onInput={() => undefined}
|
|
422
|
+
/>
|
|
423
|
+
),
|
|
424
|
+
},
|
|
425
|
+
]}
|
|
426
426
|
/>
|
|
427
427
|
</box>
|
|
428
428
|
) : null}
|
|
@@ -432,10 +432,10 @@ function PrototypeSheetImpl({
|
|
|
432
432
|
|
|
433
433
|
/**
|
|
434
434
|
* Memoized so an unrelated App re-render (status ticks, a rail-width drag) does
|
|
435
|
-
* not re-render the
|
|
435
|
+
* not re-render the view; the parent passes a stable onCommentElement so the
|
|
436
436
|
* shallow prop compare holds.
|
|
437
437
|
*/
|
|
438
|
-
export const
|
|
438
|
+
export const PrototypeContentView = React.memo(PrototypeContentViewImpl);
|
|
439
439
|
|
|
440
440
|
function statusLine(status: SheetStatus, errorMessage: string): string {
|
|
441
441
|
if (status === "loading") return "rendering prototype…";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { DARK } from "../theme";
|
|
3
|
+
import { ScrollArea } from "./ScrollArea";
|
|
4
|
+
import type { Story, StoryMeta } from "./story";
|
|
5
|
+
|
|
6
|
+
export const meta: StoryMeta = { title: "Chrome/ScrollArea" };
|
|
7
|
+
|
|
8
|
+
// Taller-than-viewport content; the overlay bar stays hidden until the surface scrolls.
|
|
9
|
+
export const AtRest: Story = {
|
|
10
|
+
render: () => (
|
|
11
|
+
<box style={{ width: 24, height: 8, backgroundColor: DARK.background }}>
|
|
12
|
+
<ScrollArea>
|
|
13
|
+
{Array.from({ length: 24 }, (_unused, index) => (
|
|
14
|
+
<text key={index} fg={DARK.textMuted}>{`row ${index + 1}`}</text>
|
|
15
|
+
))}
|
|
16
|
+
</ScrollArea>
|
|
17
|
+
</box>
|
|
18
|
+
),
|
|
19
|
+
size: { width: 24, height: 8 },
|
|
20
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A scrollbox whose bar follows the global rule: no always-on scrollbar. The built-in bar is
|
|
3
|
+
* hidden and an OverlayScrollbar rides the rightmost column, appearing only while scrolling.
|
|
4
|
+
* Pass scrollRef to keep scrollTo / scrollChildIntoView on the underlying ScrollBox.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import React, { useRef, type ReactNode, type RefObject } from "react";
|
|
8
|
+
import type { ScrollBoxRenderable } from "@opentui/core";
|
|
9
|
+
import type { Theme } from "../theme";
|
|
10
|
+
import { OverlayScrollbar } from "./OverlayScrollbar";
|
|
11
|
+
|
|
12
|
+
export interface ScrollAreaProps {
|
|
13
|
+
children: ReactNode;
|
|
14
|
+
focused?: boolean;
|
|
15
|
+
theme?: Theme;
|
|
16
|
+
scrollRef?: RefObject<ScrollBoxRenderable | null>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function ScrollArea({
|
|
20
|
+
children,
|
|
21
|
+
focused = false,
|
|
22
|
+
theme,
|
|
23
|
+
scrollRef,
|
|
24
|
+
}: ScrollAreaProps): React.ReactNode {
|
|
25
|
+
const innerRef = useRef<ScrollBoxRenderable | null>(null);
|
|
26
|
+
const attach = (node: ScrollBoxRenderable | null): void => {
|
|
27
|
+
innerRef.current = node;
|
|
28
|
+
if (scrollRef) scrollRef.current = node;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<box style={{ flexGrow: 1, flexDirection: "row" }}>
|
|
33
|
+
<scrollbox
|
|
34
|
+
ref={attach}
|
|
35
|
+
style={{ flexGrow: 1 }}
|
|
36
|
+
focused={focused}
|
|
37
|
+
verticalScrollbarOptions={{ visible: false }}
|
|
38
|
+
>
|
|
39
|
+
{children}
|
|
40
|
+
</scrollbox>
|
|
41
|
+
<OverlayScrollbar scrollbox={innerRef} theme={theme} />
|
|
42
|
+
</box>
|
|
43
|
+
);
|
|
44
|
+
}
|