gloomberb 0.10.2 → 0.10.4
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/README.md +3 -3
- package/README.zh-CN.md +2 -2
- package/package.json +2 -1
- package/src/api-client/auth.ts +8 -0
- package/src/api-client/index.ts +70 -0
- package/src/api-client/types.ts +49 -0
- package/src/app/runtime/desktop-deeplink.test.ts +8 -0
- package/src/app/runtime/desktop-deeplink.ts +14 -1
- package/src/components/chart/composite/composite-chart.test.tsx +340 -17
- package/src/components/chart/composite/composite-chart.tsx +859 -68
- package/src/components/chart/composite/interactions.test.ts +78 -22
- package/src/components/chart/composite/interactions.ts +45 -17
- package/src/components/chart/composite/rasterizer.ts +8 -2
- package/src/components/chart/composite/renderers.test.ts +61 -1
- package/src/components/chart/composite/scene.ts +14 -4
- package/src/components/chart/composite/tools.test.ts +371 -0
- package/src/components/chart/composite/tools.ts +508 -0
- package/src/components/chart/composite/types.ts +4 -0
- package/src/components/chart/core/pointer.ts +2 -0
- package/src/components/chart/native/raster/primitives.ts +18 -0
- package/src/components/chart/price-axis-labels.tsx +42 -2
- package/src/components/chart/static/chart/axis-overlays.test.tsx +2 -0
- package/src/components/chart/static/chart/axis-overlays.tsx +46 -0
- package/src/components/command-bar/action-runtime.ts +3 -0
- package/src/components/command-bar/assist/inventory.test.ts +93 -0
- package/src/components/command-bar/assist/inventory.ts +92 -0
- package/src/components/command-bar/assist/model.test.ts +104 -0
- package/src/components/command-bar/assist/model.ts +169 -0
- package/src/components/command-bar/assist/runtime.ts +185 -0
- package/src/components/command-bar/commands/direct/index.ts +1 -1
- package/src/components/command-bar/keyboard-shortcuts.ts +5 -0
- package/src/components/command-bar/list/model.ts +15 -2
- package/src/components/command-bar/list/navigation.ts +6 -0
- package/src/components/command-bar/list/view.tsx +16 -3
- package/src/components/command-bar/pane-templates/workflow.ts +1 -0
- package/src/components/command-bar/panel/body.tsx +2 -0
- package/src/components/command-bar/panel/index.tsx +3 -0
- package/src/components/command-bar/panel/runtime.ts +6 -0
- package/src/components/command-bar/panel/types.ts +1 -0
- package/src/components/command-bar/routes/root/results.ts +28 -2
- package/src/components/command-bar/routes/root/runtime.ts +66 -36
- package/src/components/command-bar/routes/root/shortcuts.ts +8 -4
- package/src/components/command-bar/routing/navigation-state.ts +17 -0
- package/src/components/command-bar/selection-runtime.ts +26 -0
- package/src/components/command-bar/surface/assist.test.tsx +276 -0
- package/src/components/command-bar/surface/index.tsx +93 -2
- package/src/components/command-bar/surface/portfolio.test.tsx +2 -1
- package/src/components/command-bar/view-model.ts +7 -1
- package/src/components/command-bar/workflow/broker.test.ts +22 -0
- package/src/components/command-bar/workflow/broker.ts +1 -0
- package/src/components/index.ts +1 -1
- package/src/components/layout/detached-pane-shell.tsx +40 -2
- package/src/components/layout/floating-pane.tsx +4 -1
- package/src/components/layout/pane/header.tsx +64 -6
- package/src/components/layout/pane/index.tsx +4 -1
- package/src/components/layout/shell/index.tsx +23 -0
- package/src/components/layout/shell/pane/layers.tsx +5 -0
- package/src/components/onboarding/account-step/chooser-panel.tsx +74 -0
- package/src/components/onboarding/account-step/form-panel.tsx +157 -0
- package/src/components/onboarding/account-step/index.tsx +58 -0
- package/src/components/onboarding/account-step/model.test.ts +67 -0
- package/src/components/onboarding/account-step/model.ts +126 -0
- package/src/components/onboarding/account-step/signed-in-panel.tsx +31 -0
- package/src/components/onboarding/onboarding-steps.tsx +91 -55
- package/src/components/onboarding/onboarding-wizard.test.tsx +73 -6
- package/src/components/onboarding/onboarding-wizard.tsx +93 -9
- package/src/components/onboarding/portfolio-step/broker-fields-panel.tsx +3 -3
- package/src/components/onboarding/wizard-account.ts +235 -0
- package/src/components/onboarding/wizard-keyboard.ts +67 -1
- package/src/components/onboarding/wizard-model.ts +2 -2
- package/src/components/ui/data-table/index.tsx +1 -0
- package/src/components/ui/data-table/opentui/index.tsx +44 -2
- package/src/components/ui/data-table/types.ts +10 -0
- package/src/components/ui/data-table/visible-range.test.ts +29 -0
- package/src/components/ui/data-table/visible-range.ts +21 -0
- package/src/components/ui/ui.test.tsx +23 -1
- package/src/i18n/es.ts +1093 -0
- package/src/i18n/index.test.ts +5 -2
- package/src/i18n/index.ts +3 -0
- package/src/i18n/ja.ts +66 -3
- package/src/i18n/ko.ts +66 -3
- package/src/i18n/languages.test.ts +4 -1
- package/src/i18n/languages.ts +12 -1
- package/src/i18n/zh-cn.ts +66 -3
- package/src/i18n/zh-tw.ts +66 -3
- package/src/market-data/coordinator/index.ts +1 -1
- package/src/market-data/coordinator/quotes.ts +64 -1
- package/src/market-data/coordinator/subscriptions.test.ts +72 -0
- package/src/market-data/quotes/cadence.ts +2 -0
- package/src/plugins/builtin/account-management/footer.ts +9 -3
- package/src/plugins/builtin/account-management/model.ts +53 -1
- package/src/plugins/builtin/account-management/pane.tsx +107 -24
- package/src/plugins/builtin/ai/index.tsx +7 -2
- package/src/plugins/builtin/ai/screener/market-runtime.ts +4 -2
- package/src/plugins/builtin/ai/screener/pane.tsx +3 -0
- package/src/plugins/builtin/chart-composer/editor.tsx +0 -1
- package/src/plugins/builtin/chart-composer/index.tsx +10 -2
- package/src/plugins/builtin/chart-composer/pane.tsx +111 -44
- package/src/plugins/builtin/chart-composer/quick-add.tsx +27 -3
- package/src/plugins/builtin/chart-composer/semantic.ts +21 -0
- package/src/plugins/builtin/chat/controller/persistence.ts +5 -0
- package/src/plugins/builtin/cloud/plugin.tsx +9 -1
- package/src/plugins/builtin/cloud/upgrade-command.ts +28 -0
- package/src/plugins/builtin/cloud/upgrade-status-widget.tsx +54 -0
- package/src/plugins/builtin/help/index.tsx +38 -0
- package/src/plugins/builtin/news/wire/news/footer.ts +18 -38
- package/src/plugins/builtin/options/footer.test.ts +20 -112
- package/src/plugins/builtin/options/footer.ts +35 -78
- package/src/plugins/builtin/options/index.tsx +6 -0
- package/src/plugins/builtin/options/live-quotes.test.ts +117 -14
- package/src/plugins/builtin/options/live-quotes.ts +93 -39
- package/src/plugins/builtin/options/view.test.tsx +5 -2
- package/src/plugins/builtin/options/view.tsx +49 -9
- package/src/plugins/builtin/portfolio-list/index.tsx +12 -4
- package/src/plugins/builtin/portfolio-list/pane/index.tsx +22 -1
- package/src/plugins/builtin/portfolio-list/pane/streaming.ts +17 -13
- package/src/plugins/builtin/portfolio-list/use-throttled-ticker-order.test.tsx +76 -0
- package/src/plugins/builtin/portfolio-list/use-throttled-ticker-order.ts +78 -0
- package/src/plugins/builtin/shared/cloud-upgrade.ts +131 -0
- package/src/plugins/builtin/shared/live-streaming.ts +60 -0
- package/src/plugins/builtin/shared/plan-access.test.ts +69 -0
- package/src/plugins/builtin/shared/plan-access.ts +109 -0
- package/src/plugins/builtin/ticker-detail/chart-tab-switch.test.tsx +1 -1
- package/src/plugins/builtin/ticker-detail/index.tsx +11 -2
- package/src/plugins/builtin/ticker-detail/pane.tsx +21 -3
- package/src/plugins/builtin/ticker-detail/quote-monitor/index.tsx +4 -2
- package/src/plugins/registry/index.test.ts +51 -0
- package/src/plugins/registry/index.ts +12 -0
- package/src/plugins/registry/pane-settings.ts +29 -0
- package/src/renderers/electrobun/view/data-table/index.tsx +44 -3
- package/src/renderers/electrobun/view/host/box.test.tsx +97 -0
- package/src/renderers/electrobun/view/host/box.tsx +3 -0
- package/src/renderers/electrobun/view/host/chart-surface.tsx +85 -1
- package/src/renderers/electrobun/view/host/focus-scope.test.ts +37 -0
- package/src/renderers/electrobun/view/host/focus-scope.ts +56 -0
- package/src/renderers/electrobun/view/host/input.tsx +16 -1
- package/src/renderers/electrobun/view/host/mouse.ts +1 -0
- package/src/renderers/electrobun/view/host/style.ts +1 -1
- package/src/renderers/electrobun/view/host/tabs.tsx +4 -2
- package/src/renderers/electrobun/view/main.tsx +2 -0
- package/src/renderers/opentui/chart-surface.tsx +2 -0
- package/src/renderers/opentui/host.tsx +3 -0
- package/src/sources/gloomberb-cloud/index.test.ts +8 -0
- package/src/sources/gloomberb-cloud/index.ts +4 -4
- package/src/sources/provider-router/history.test.ts +3 -3
- package/src/sources/provider-router/history.ts +15 -4
- package/src/sources/yahoo-finance/history.test.ts +47 -0
- package/src/sources/yahoo-finance/history.ts +5 -1
- package/src/state/hooks/quote-streaming.test.tsx +181 -1
- package/src/state/hooks/quote-streaming.ts +93 -7
- package/src/theme/colors.ts +20 -1
- package/src/theme/themes.test.ts +3 -0
- package/src/{sources/gloomberb-cloud → time-series}/history-quality.ts +27 -3
- package/src/time-series/resolution.test.ts +51 -0
- package/src/time-series/resolution.ts +41 -0
- package/src/time-series/resolve.test.ts +207 -1
- package/src/time-series/resolve.ts +196 -29
- package/src/time-series/spec-studies.test.ts +4 -0
- package/src/time-series/spec.ts +15 -1
- package/src/time-series/types.ts +7 -0
- package/src/time-series/use-chart-resolution.test.tsx +37 -0
- package/src/time-series/use-chart-resolution.ts +17 -1
- package/src/types/plugin.ts +9 -0
- package/src/ui/host.tsx +17 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { useCallback, useRef, useState, type Dispatch, type SetStateAction } from "react";
|
|
2
|
+
import { apiClient } from "../../api-client";
|
|
3
|
+
import { chatController } from "../../plugins/builtin/chat/controller";
|
|
4
|
+
import { debugLog } from "../../utils/debug-log";
|
|
5
|
+
import {
|
|
6
|
+
advanceAccountField,
|
|
7
|
+
classifyAccountError,
|
|
8
|
+
deriveUsernameFromEmail,
|
|
9
|
+
isUsernameConflictError,
|
|
10
|
+
validateAccountEmail,
|
|
11
|
+
validateAccountPassword,
|
|
12
|
+
type AccountMode,
|
|
13
|
+
type AccountOutcome,
|
|
14
|
+
type AccountSub,
|
|
15
|
+
type AccountSubmitError,
|
|
16
|
+
} from "./account-step/model";
|
|
17
|
+
|
|
18
|
+
const onboardingLog = debugLog.createLogger("onboarding");
|
|
19
|
+
|
|
20
|
+
export interface OnboardingAccountState {
|
|
21
|
+
accountSub: AccountSub;
|
|
22
|
+
accountChoiceIdx: number;
|
|
23
|
+
accountEmail: string;
|
|
24
|
+
accountPassword: string;
|
|
25
|
+
accountFieldIdx: number;
|
|
26
|
+
accountSubmitting: boolean;
|
|
27
|
+
accountSubmitError: AccountSubmitError | null;
|
|
28
|
+
accountValidationError: string | null;
|
|
29
|
+
accountOutcome: AccountOutcome | null;
|
|
30
|
+
setAccountChoiceIdx: Dispatch<SetStateAction<number>>;
|
|
31
|
+
setAccountEmail: (value: string) => void;
|
|
32
|
+
setAccountPassword: (value: string) => void;
|
|
33
|
+
beginAccountMode: (mode: AccountMode) => void;
|
|
34
|
+
returnToAccountChooser: () => void;
|
|
35
|
+
switchToAccountLogin: () => void;
|
|
36
|
+
submitAccountField: () => void;
|
|
37
|
+
submitAccount: () => void;
|
|
38
|
+
syncExistingAccountSession: () => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Owns the account step's sub-state machine and the auth calls behind it.
|
|
43
|
+
* Skipping never routes through here, so a failed or hung request can't keep
|
|
44
|
+
* the user from finishing onboarding.
|
|
45
|
+
*/
|
|
46
|
+
export function useOnboardingAccount({
|
|
47
|
+
nextStep,
|
|
48
|
+
setEditingField,
|
|
49
|
+
}: {
|
|
50
|
+
nextStep: () => void;
|
|
51
|
+
setEditingField: (editing: boolean) => void;
|
|
52
|
+
}): OnboardingAccountState {
|
|
53
|
+
const attemptRef = useRef(0);
|
|
54
|
+
// The text input re-emits its value on enter, so compare before clearing an
|
|
55
|
+
// error the same keypress just produced.
|
|
56
|
+
const emailRef = useRef("");
|
|
57
|
+
const passwordRef = useRef("");
|
|
58
|
+
const [accountSub, setAccountSub] = useState<AccountSub>("choose");
|
|
59
|
+
const [accountChoiceIdx, setAccountChoiceIdx] = useState(0);
|
|
60
|
+
const [accountEmail, setAccountEmailValue] = useState("");
|
|
61
|
+
const [accountPassword, setAccountPasswordValue] = useState("");
|
|
62
|
+
const [accountFieldIdx, setAccountFieldIdx] = useState(0);
|
|
63
|
+
const [accountSubmitting, setAccountSubmitting] = useState(false);
|
|
64
|
+
const [accountSubmitError, setAccountSubmitError] = useState<AccountSubmitError | null>(null);
|
|
65
|
+
const [accountValidationError, setAccountValidationError] = useState<string | null>(null);
|
|
66
|
+
const [accountOutcome, setAccountOutcome] = useState<AccountOutcome | null>(null);
|
|
67
|
+
|
|
68
|
+
const clearErrors = useCallback(() => {
|
|
69
|
+
setAccountSubmitError(null);
|
|
70
|
+
setAccountValidationError(null);
|
|
71
|
+
}, []);
|
|
72
|
+
|
|
73
|
+
const setAccountEmail = useCallback((value: string) => {
|
|
74
|
+
if (emailRef.current === value) return;
|
|
75
|
+
emailRef.current = value;
|
|
76
|
+
setAccountEmailValue(value);
|
|
77
|
+
clearErrors();
|
|
78
|
+
}, [clearErrors]);
|
|
79
|
+
|
|
80
|
+
const setAccountPassword = useCallback((value: string) => {
|
|
81
|
+
if (passwordRef.current === value) return;
|
|
82
|
+
passwordRef.current = value;
|
|
83
|
+
setAccountPasswordValue(value);
|
|
84
|
+
clearErrors();
|
|
85
|
+
}, [clearErrors]);
|
|
86
|
+
|
|
87
|
+
const resetAccountPassword = useCallback(() => {
|
|
88
|
+
passwordRef.current = "";
|
|
89
|
+
setAccountPasswordValue("");
|
|
90
|
+
}, []);
|
|
91
|
+
|
|
92
|
+
const beginAccountMode = useCallback((mode: AccountMode) => {
|
|
93
|
+
attemptRef.current += 1;
|
|
94
|
+
clearErrors();
|
|
95
|
+
setAccountSubmitting(false);
|
|
96
|
+
setAccountFieldIdx(0);
|
|
97
|
+
setAccountSub(mode);
|
|
98
|
+
setEditingField(true);
|
|
99
|
+
}, [clearErrors, setEditingField]);
|
|
100
|
+
|
|
101
|
+
const returnToAccountChooser = useCallback(() => {
|
|
102
|
+
attemptRef.current += 1;
|
|
103
|
+
clearErrors();
|
|
104
|
+
setAccountSubmitting(false);
|
|
105
|
+
setAccountFieldIdx(0);
|
|
106
|
+
setAccountSub("choose");
|
|
107
|
+
setEditingField(false);
|
|
108
|
+
}, [clearErrors, setEditingField]);
|
|
109
|
+
|
|
110
|
+
const switchToAccountLogin = useCallback(() => {
|
|
111
|
+
attemptRef.current += 1;
|
|
112
|
+
clearErrors();
|
|
113
|
+
setAccountSubmitting(false);
|
|
114
|
+
resetAccountPassword();
|
|
115
|
+
setAccountFieldIdx(1);
|
|
116
|
+
setAccountSub("login");
|
|
117
|
+
setEditingField(true);
|
|
118
|
+
}, [clearErrors, resetAccountPassword, setEditingField]);
|
|
119
|
+
|
|
120
|
+
const submitAccount = useCallback(() => {
|
|
121
|
+
if (accountSubmitting) return;
|
|
122
|
+
const mode: AccountMode = accountSub === "login" ? "login" : "signup";
|
|
123
|
+
const email = accountEmail.trim();
|
|
124
|
+
const password = accountPassword;
|
|
125
|
+
|
|
126
|
+
const emailError = validateAccountEmail(email);
|
|
127
|
+
if (emailError) {
|
|
128
|
+
setAccountFieldIdx(0);
|
|
129
|
+
setAccountValidationError(emailError);
|
|
130
|
+
setEditingField(true);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const passwordError = validateAccountPassword(password, mode);
|
|
134
|
+
if (passwordError) {
|
|
135
|
+
setAccountFieldIdx(1);
|
|
136
|
+
setAccountValidationError(passwordError);
|
|
137
|
+
setEditingField(true);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const attemptId = attemptRef.current + 1;
|
|
142
|
+
attemptRef.current = attemptId;
|
|
143
|
+
setEditingField(false);
|
|
144
|
+
setAccountSubmitting(true);
|
|
145
|
+
clearErrors();
|
|
146
|
+
|
|
147
|
+
void (async () => {
|
|
148
|
+
try {
|
|
149
|
+
if (mode === "signup") {
|
|
150
|
+
try {
|
|
151
|
+
const username = deriveUsernameFromEmail(email);
|
|
152
|
+
await apiClient.signUp(email, username, username, password);
|
|
153
|
+
} catch (error) {
|
|
154
|
+
if (!isUsernameConflictError(error)) throw error;
|
|
155
|
+
const retryUsername = deriveUsernameFromEmail(email, 1);
|
|
156
|
+
await apiClient.signUp(email, retryUsername, retryUsername, password);
|
|
157
|
+
}
|
|
158
|
+
await apiClient.sendVerification().catch(() => {});
|
|
159
|
+
} else {
|
|
160
|
+
await apiClient.signIn(email, password);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Hands the freshly captured session cookie to the chat controller so it
|
|
164
|
+
// reaches plugin persistence instead of living only in this process.
|
|
165
|
+
chatController.clearSession();
|
|
166
|
+
await chatController.refreshSession().catch(() => {});
|
|
167
|
+
|
|
168
|
+
if (attemptRef.current !== attemptId) return;
|
|
169
|
+
onboardingLog.info("Onboarding account step completed", { mode });
|
|
170
|
+
setAccountSubmitting(false);
|
|
171
|
+
resetAccountPassword();
|
|
172
|
+
setAccountOutcome({ mode, email });
|
|
173
|
+
setAccountSub("signed-in");
|
|
174
|
+
nextStep();
|
|
175
|
+
} catch (error) {
|
|
176
|
+
if (attemptRef.current !== attemptId) return;
|
|
177
|
+
const submitError = classifyAccountError(error, mode);
|
|
178
|
+
onboardingLog.error("Onboarding account step failed", { mode, error: submitError.message });
|
|
179
|
+
setAccountSubmitting(false);
|
|
180
|
+
setAccountSubmitError(submitError);
|
|
181
|
+
}
|
|
182
|
+
})();
|
|
183
|
+
}, [accountEmail, accountPassword, accountSub, accountSubmitting, clearErrors, nextStep, resetAccountPassword, setEditingField]);
|
|
184
|
+
|
|
185
|
+
const submitAccountField = useCallback(() => {
|
|
186
|
+
if (accountSub !== "signup" && accountSub !== "login") return;
|
|
187
|
+
const advance = advanceAccountField({
|
|
188
|
+
mode: accountSub,
|
|
189
|
+
email: accountEmail.trim(),
|
|
190
|
+
password: accountPassword,
|
|
191
|
+
fieldIdx: accountFieldIdx,
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
if (advance.action === "invalid") {
|
|
195
|
+
setAccountValidationError(advance.message);
|
|
196
|
+
setEditingField(true);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (advance.action === "next-field") {
|
|
200
|
+
setAccountValidationError(null);
|
|
201
|
+
setAccountFieldIdx(advance.fieldIdx);
|
|
202
|
+
setEditingField(true);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
submitAccount();
|
|
206
|
+
}, [accountEmail, accountFieldIdx, accountPassword, accountSub, setEditingField, submitAccount]);
|
|
207
|
+
|
|
208
|
+
const syncExistingAccountSession = useCallback(() => {
|
|
209
|
+
if (accountOutcome || accountSub !== "choose" || !apiClient.getSessionToken()) return;
|
|
210
|
+
const user = apiClient.getCurrentUser();
|
|
211
|
+
setAccountOutcome({ mode: "login", email: user?.email?.trim() || user?.username?.trim() || "" });
|
|
212
|
+
setAccountSub("signed-in");
|
|
213
|
+
}, [accountOutcome, accountSub]);
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
accountSub,
|
|
217
|
+
accountChoiceIdx,
|
|
218
|
+
accountEmail,
|
|
219
|
+
accountPassword,
|
|
220
|
+
accountFieldIdx,
|
|
221
|
+
accountSubmitting,
|
|
222
|
+
accountSubmitError,
|
|
223
|
+
accountValidationError,
|
|
224
|
+
accountOutcome,
|
|
225
|
+
setAccountChoiceIdx,
|
|
226
|
+
setAccountEmail,
|
|
227
|
+
setAccountPassword,
|
|
228
|
+
beginAccountMode,
|
|
229
|
+
returnToAccountChooser,
|
|
230
|
+
switchToAccountLogin,
|
|
231
|
+
submitAccountField,
|
|
232
|
+
submitAccount,
|
|
233
|
+
syncExistingAccountSession,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
@@ -4,6 +4,12 @@ import { isBackNavigationKey, isPlainEscape } from "../../utils/back-navigation"
|
|
|
4
4
|
import { isPlainKey } from "../../utils/keyboard";
|
|
5
5
|
import type { ListViewItem } from "../ui";
|
|
6
6
|
import { useShortcut } from "../../react/input";
|
|
7
|
+
import {
|
|
8
|
+
ACCOUNT_CHOICE_IDS,
|
|
9
|
+
type AccountMode,
|
|
10
|
+
type AccountSub,
|
|
11
|
+
type AccountSubmitError,
|
|
12
|
+
} from "./account-step/model";
|
|
7
13
|
import type { PortfolioSub } from "./onboarding-steps";
|
|
8
14
|
import type { BrokerOption, OnboardingStep } from "./wizard-model";
|
|
9
15
|
|
|
@@ -29,6 +35,16 @@ export function useOnboardingKeyboard({
|
|
|
29
35
|
setEditingField,
|
|
30
36
|
isBrokerSyncing,
|
|
31
37
|
brokerSyncError,
|
|
38
|
+
accountSub,
|
|
39
|
+
accountChoiceIdx,
|
|
40
|
+
setAccountChoiceIdx,
|
|
41
|
+
accountSubmitting,
|
|
42
|
+
accountSubmitError,
|
|
43
|
+
beginAccountMode,
|
|
44
|
+
returnToAccountChooser,
|
|
45
|
+
switchToAccountLogin,
|
|
46
|
+
submitAccountField,
|
|
47
|
+
submitAccount,
|
|
32
48
|
isFinishing,
|
|
33
49
|
nextStep,
|
|
34
50
|
prevStep,
|
|
@@ -58,6 +74,16 @@ export function useOnboardingKeyboard({
|
|
|
58
74
|
setEditingField: Dispatch<SetStateAction<boolean>>;
|
|
59
75
|
isBrokerSyncing: boolean;
|
|
60
76
|
brokerSyncError: string | null;
|
|
77
|
+
accountSub: AccountSub;
|
|
78
|
+
accountChoiceIdx: number;
|
|
79
|
+
setAccountChoiceIdx: Dispatch<SetStateAction<number>>;
|
|
80
|
+
accountSubmitting: boolean;
|
|
81
|
+
accountSubmitError: AccountSubmitError | null;
|
|
82
|
+
beginAccountMode: (mode: AccountMode) => void;
|
|
83
|
+
returnToAccountChooser: () => void;
|
|
84
|
+
switchToAccountLogin: () => void;
|
|
85
|
+
submitAccountField: () => void;
|
|
86
|
+
submitAccount: () => void;
|
|
61
87
|
isFinishing: boolean;
|
|
62
88
|
nextStep: () => void;
|
|
63
89
|
prevStep: () => void;
|
|
@@ -74,6 +100,10 @@ export function useOnboardingKeyboard({
|
|
|
74
100
|
if (editingField) {
|
|
75
101
|
if (event.name === "enter" || event.name === "return") {
|
|
76
102
|
setEditingField(false);
|
|
103
|
+
if (step === "account") {
|
|
104
|
+
submitAccountField();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
77
107
|
if (portfolioSub === "broker-fields" && selectedBrokerId) {
|
|
78
108
|
const currentField = activeBrokerFields[brokerFieldIdx];
|
|
79
109
|
if (!currentField) {
|
|
@@ -102,7 +132,9 @@ export function useOnboardingKeyboard({
|
|
|
102
132
|
}
|
|
103
133
|
} else if (isPlainEscape(event)) {
|
|
104
134
|
setEditingField(false);
|
|
105
|
-
if (
|
|
135
|
+
if (step === "account") {
|
|
136
|
+
returnToAccountChooser();
|
|
137
|
+
} else if (portfolioSub === "broker-fields") {
|
|
106
138
|
setPortfolioSub("broker-setup");
|
|
107
139
|
} else {
|
|
108
140
|
setPortfolioSub("choose");
|
|
@@ -117,6 +149,30 @@ export function useOnboardingKeyboard({
|
|
|
117
149
|
finish();
|
|
118
150
|
return;
|
|
119
151
|
}
|
|
152
|
+
if (step === "account") {
|
|
153
|
+
if (accountSubmitting) return;
|
|
154
|
+
if (accountSub === "choose") {
|
|
155
|
+
const choice = ACCOUNT_CHOICE_IDS[accountChoiceIdx];
|
|
156
|
+
if (!choice || choice === "skip") {
|
|
157
|
+
nextStep();
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
beginAccountMode(choice);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (accountSub === "signup" || accountSub === "login") {
|
|
164
|
+
if (accountSubmitError?.kind === "switch-to-login") {
|
|
165
|
+
switchToAccountLogin();
|
|
166
|
+
} else if (accountSubmitError) {
|
|
167
|
+
submitAccount();
|
|
168
|
+
} else {
|
|
169
|
+
setEditingField(true);
|
|
170
|
+
}
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
nextStep();
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
120
176
|
if (step === "portfolio") {
|
|
121
177
|
if (portfolioSub === "choose") {
|
|
122
178
|
const choice = portfolioChoices[portfolioOptionIdx]!;
|
|
@@ -189,6 +245,10 @@ export function useOnboardingKeyboard({
|
|
|
189
245
|
}
|
|
190
246
|
nextStep();
|
|
191
247
|
} else if (isBackNavigationKey(event) || event.name === "left") {
|
|
248
|
+
if (step === "account" && (accountSub === "signup" || accountSub === "login")) {
|
|
249
|
+
returnToAccountChooser();
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
192
252
|
if (step === "portfolio" && portfolioSub === "broker-sync") {
|
|
193
253
|
resetBrokerSync();
|
|
194
254
|
setPortfolioSub("broker-fields");
|
|
@@ -219,6 +279,12 @@ export function useOnboardingKeyboard({
|
|
|
219
279
|
} else if (isPlainKey(event, "down", "j")) {
|
|
220
280
|
setThemeIdx((index) => Math.min(themeIds.length - 1, index + 1));
|
|
221
281
|
}
|
|
282
|
+
} else if (step === "account" && accountSub === "choose") {
|
|
283
|
+
if (isPlainKey(event, "up", "k")) {
|
|
284
|
+
setAccountChoiceIdx((index) => Math.max(0, index - 1));
|
|
285
|
+
} else if (isPlainKey(event, "down", "j")) {
|
|
286
|
+
setAccountChoiceIdx((index) => Math.min(ACCOUNT_CHOICE_IDS.length - 1, index + 1));
|
|
287
|
+
}
|
|
222
288
|
} else if (step === "portfolio" && portfolioSub === "choose") {
|
|
223
289
|
if (isPlainKey(event, "up", "k")) {
|
|
224
290
|
setPortfolioOptionIdx((index) => Math.max(0, index - 1));
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { BrokerAdapter } from "../../types/broker";
|
|
2
2
|
|
|
3
|
-
export type OnboardingStep = "welcome" | "theme" | "portfolio" | "shortcuts" | "ready";
|
|
3
|
+
export type OnboardingStep = "welcome" | "theme" | "portfolio" | "shortcuts" | "account" | "ready";
|
|
4
4
|
|
|
5
|
-
export const ONBOARDING_STEPS: OnboardingStep[] = ["welcome", "theme", "portfolio", "shortcuts", "ready"];
|
|
5
|
+
export const ONBOARDING_STEPS: OnboardingStep[] = ["welcome", "theme", "portfolio", "shortcuts", "account", "ready"];
|
|
6
6
|
|
|
7
7
|
export interface BrokerOption {
|
|
8
8
|
id: string;
|
|
@@ -19,7 +19,9 @@ import {
|
|
|
19
19
|
import type {
|
|
20
20
|
DataTableColumn,
|
|
21
21
|
DataTableProps,
|
|
22
|
+
DataTableVisibleRange,
|
|
22
23
|
} from "../types";
|
|
24
|
+
import { resolveDataTableVisibleRange } from "../visible-range";
|
|
23
25
|
import {
|
|
24
26
|
resolveDataTableScrollTop,
|
|
25
27
|
resolveDataTableVisibleWindow,
|
|
@@ -47,6 +49,8 @@ export function OpenTuiDataTable<T, C extends DataTableColumn = DataTableColumn>
|
|
|
47
49
|
onSelect,
|
|
48
50
|
onActivate,
|
|
49
51
|
onTableMouseDown,
|
|
52
|
+
visibleRangeKey,
|
|
53
|
+
onVisibleRangeChange,
|
|
50
54
|
onRowMouseDown,
|
|
51
55
|
onRowContextMenu,
|
|
52
56
|
rowContextMenuSurface = false,
|
|
@@ -74,6 +78,10 @@ export function OpenTuiDataTable<T, C extends DataTableColumn = DataTableColumn>
|
|
|
74
78
|
const nativeRenderer = useNativeRenderer();
|
|
75
79
|
const [scrollVersion, setScrollVersion] = useState(0);
|
|
76
80
|
const lastAppliedScrollRequestRef = useRef<string | null>(null);
|
|
81
|
+
const lastVisibleRangeRef = useRef<{
|
|
82
|
+
key: string | number | undefined;
|
|
83
|
+
range: DataTableVisibleRange;
|
|
84
|
+
} | null>(null);
|
|
77
85
|
const scrollTop = virtualize ? (scrollRef.current?.scrollTop ?? 0) : 0;
|
|
78
86
|
const measuredViewportHeight = scrollRef.current?.viewport?.height;
|
|
79
87
|
const tableWindow = useMemo(
|
|
@@ -123,6 +131,25 @@ export function OpenTuiDataTable<T, C extends DataTableColumn = DataTableColumn>
|
|
|
123
131
|
() => expandTableColumns(columns, contentWidth, columnGap, horizontalPadding),
|
|
124
132
|
[columnGap, columns, contentWidth, horizontalPadding],
|
|
125
133
|
);
|
|
134
|
+
const emitVisibleRange = useCallback(() => {
|
|
135
|
+
if (!onVisibleRangeChange) return;
|
|
136
|
+
const scrollBox = scrollRef.current;
|
|
137
|
+
const range = resolveDataTableVisibleRange({
|
|
138
|
+
itemCount: items.length,
|
|
139
|
+
rowSize: 1,
|
|
140
|
+
scrollOffset: scrollBox?.scrollTop ?? scrollTop,
|
|
141
|
+
viewportSize: scrollBox?.viewport?.height ?? viewportHeight,
|
|
142
|
+
});
|
|
143
|
+
const previous = lastVisibleRangeRef.current;
|
|
144
|
+
if (
|
|
145
|
+
previous !== null
|
|
146
|
+
&& previous.key === visibleRangeKey
|
|
147
|
+
&& previous.range.start === range.start
|
|
148
|
+
&& previous.range.end === range.end
|
|
149
|
+
) return;
|
|
150
|
+
lastVisibleRangeRef.current = { key: visibleRangeKey, range };
|
|
151
|
+
onVisibleRangeChange(range);
|
|
152
|
+
}, [items.length, onVisibleRangeChange, scrollRef, scrollTop, viewportHeight, visibleRangeKey]);
|
|
126
153
|
const handleRowMouseDown =
|
|
127
154
|
useDoubleClickActivation<DataTableRowPointerTarget<T>>({
|
|
128
155
|
onSelect: ({ item, index }) => {
|
|
@@ -140,13 +167,26 @@ export function OpenTuiDataTable<T, C extends DataTableColumn = DataTableColumn>
|
|
|
140
167
|
setScrollVersion((current) => current + 1);
|
|
141
168
|
}
|
|
142
169
|
onBodyScrollActivity();
|
|
170
|
+
emitVisibleRange();
|
|
143
171
|
nativeRenderer.requestRender();
|
|
144
|
-
}, [nativeRenderer, onBodyScrollActivity, virtualize]);
|
|
172
|
+
}, [emitVisibleRange, nativeRenderer, onBodyScrollActivity, virtualize]);
|
|
145
173
|
useScrollBoxScrollActivity({
|
|
146
174
|
scrollRef,
|
|
147
175
|
onVerticalScroll: handleBodyScrollActivity,
|
|
148
176
|
onHorizontalScroll: syncHeaderScroll,
|
|
149
177
|
});
|
|
178
|
+
const handleBodySizeChange = useCallback(() => {
|
|
179
|
+
measureContentWidth();
|
|
180
|
+
if (virtualize) {
|
|
181
|
+
setScrollVersion((current) => current + 1);
|
|
182
|
+
}
|
|
183
|
+
queueMicrotask(emitVisibleRange);
|
|
184
|
+
}, [emitVisibleRange, measureContentWidth, virtualize]);
|
|
185
|
+
|
|
186
|
+
useEffect(() => {
|
|
187
|
+
emitVisibleRange();
|
|
188
|
+
queueMicrotask(emitVisibleRange);
|
|
189
|
+
}, [emitVisibleRange]);
|
|
150
190
|
const focusPane = useCallback(() => {
|
|
151
191
|
if (!paneInstanceId) return;
|
|
152
192
|
dispatch({ type: "FOCUS_PANE", paneId: paneInstanceId });
|
|
@@ -178,6 +218,7 @@ export function OpenTuiDataTable<T, C extends DataTableColumn = DataTableColumn>
|
|
|
178
218
|
setScrollVersion((current) => current + 1);
|
|
179
219
|
}
|
|
180
220
|
syncHeaderScroll();
|
|
221
|
+
queueMicrotask(emitVisibleRange);
|
|
181
222
|
return true;
|
|
182
223
|
}, [
|
|
183
224
|
appViewport.height,
|
|
@@ -185,6 +226,7 @@ export function OpenTuiDataTable<T, C extends DataTableColumn = DataTableColumn>
|
|
|
185
226
|
scrollRef,
|
|
186
227
|
scrollToIndex,
|
|
187
228
|
scrollToIndexAlign,
|
|
229
|
+
emitVisibleRange,
|
|
188
230
|
syncHeaderScroll,
|
|
189
231
|
virtualize,
|
|
190
232
|
]);
|
|
@@ -324,7 +366,7 @@ export function OpenTuiDataTable<T, C extends DataTableColumn = DataTableColumn>
|
|
|
324
366
|
focusPane();
|
|
325
367
|
onTableMouseDown?.({});
|
|
326
368
|
}}
|
|
327
|
-
onSizeChange={
|
|
369
|
+
onSizeChange={handleBodySizeChange}
|
|
328
370
|
>
|
|
329
371
|
{items.length === 0 ? (
|
|
330
372
|
emptyContent ?? (
|
|
@@ -33,6 +33,13 @@ export interface DataTableRowState {
|
|
|
33
33
|
selected: boolean;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
export interface DataTableVisibleRange {
|
|
37
|
+
/** First visible row index. */
|
|
38
|
+
start: number;
|
|
39
|
+
/** Exclusive end of the visible row range. */
|
|
40
|
+
end: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
36
43
|
export interface DataTableProps<
|
|
37
44
|
T,
|
|
38
45
|
C extends DataTableColumn = DataTableColumn,
|
|
@@ -53,6 +60,9 @@ export interface DataTableProps<
|
|
|
53
60
|
onSelect: (item: T, index: number) => void;
|
|
54
61
|
onActivate?: (item: T, index: number) => void;
|
|
55
62
|
onTableMouseDown?: (event: any) => void;
|
|
63
|
+
/** Changes when the meaning of row indexes changes without changing table geometry. */
|
|
64
|
+
visibleRangeKey?: string | number;
|
|
65
|
+
onVisibleRangeChange?: (range: DataTableVisibleRange) => void;
|
|
56
66
|
onRowMouseDown?: (item: T, index: number, event: any) => boolean | void;
|
|
57
67
|
onRowContextMenu?: (item: T, index: number, event: any) => void;
|
|
58
68
|
rowContextMenuSurface?: boolean;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { resolveDataTableVisibleRange } from "./visible-range";
|
|
3
|
+
|
|
4
|
+
describe("data table visible range", () => {
|
|
5
|
+
test("includes partially visible desktop rows without counting the sticky header", () => {
|
|
6
|
+
expect(resolveDataTableVisibleRange({
|
|
7
|
+
itemCount: 100,
|
|
8
|
+
rowSize: 20,
|
|
9
|
+
scrollOffset: 10,
|
|
10
|
+
viewportSize: 180,
|
|
11
|
+
})).toEqual({ start: 0, end: 10 });
|
|
12
|
+
|
|
13
|
+
expect(resolveDataTableVisibleRange({
|
|
14
|
+
itemCount: 100,
|
|
15
|
+
rowSize: 20,
|
|
16
|
+
scrollOffset: 30,
|
|
17
|
+
viewportSize: 180,
|
|
18
|
+
})).toEqual({ start: 1, end: 11 });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("clamps a terminal range to the available rows", () => {
|
|
22
|
+
expect(resolveDataTableVisibleRange({
|
|
23
|
+
itemCount: 8,
|
|
24
|
+
rowSize: 1,
|
|
25
|
+
scrollOffset: 6,
|
|
26
|
+
viewportSize: 5,
|
|
27
|
+
})).toEqual({ start: 6, end: 8 });
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { DataTableVisibleRange } from "./types";
|
|
2
|
+
|
|
3
|
+
export function resolveDataTableVisibleRange({
|
|
4
|
+
itemCount,
|
|
5
|
+
rowSize,
|
|
6
|
+
scrollOffset,
|
|
7
|
+
viewportSize,
|
|
8
|
+
}: {
|
|
9
|
+
itemCount: number;
|
|
10
|
+
rowSize: number;
|
|
11
|
+
scrollOffset: number;
|
|
12
|
+
viewportSize: number;
|
|
13
|
+
}): DataTableVisibleRange {
|
|
14
|
+
const count = Math.max(0, Math.floor(itemCount));
|
|
15
|
+
const size = Number.isFinite(rowSize) && rowSize > 0 ? rowSize : 1;
|
|
16
|
+
const offset = Number.isFinite(scrollOffset) ? Math.max(0, scrollOffset) : 0;
|
|
17
|
+
const viewport = Number.isFinite(viewportSize) ? Math.max(0, viewportSize) : 0;
|
|
18
|
+
const start = Math.min(count, Math.floor(offset / size));
|
|
19
|
+
const end = Math.min(count, Math.max(start, Math.ceil((offset + viewport) / size)));
|
|
20
|
+
return { start, end };
|
|
21
|
+
}
|
|
@@ -3,7 +3,7 @@ import { act, createRef, useEffect, useRef, useState } from "react";
|
|
|
3
3
|
import { TestDialogProvider, testRender } from "../../renderers/opentui/test-utils";
|
|
4
4
|
import type { BoxRenderable, ScrollBoxRenderable } from "@opentui/core";
|
|
5
5
|
import { ChoiceDialog } from "./choice-dialog";
|
|
6
|
-
import { DataTable, type DataTableColumn } from "./data-table";
|
|
6
|
+
import { DataTable, type DataTableColumn, type DataTableVisibleRange } from "./data-table";
|
|
7
7
|
import { TextField } from "./fields";
|
|
8
8
|
import { ListView } from "./list-view";
|
|
9
9
|
import { MultiSelectDialogButton, type MultiSelectDialogButtonHandle } from "./multi-select/dialog";
|
|
@@ -26,6 +26,8 @@ let setListSelection: ((index: number) => void) | null = null;
|
|
|
26
26
|
let selectedTableRow: string | null = null;
|
|
27
27
|
let activatedTableRow: string | null = null;
|
|
28
28
|
let tableScrollBoxForTest: ScrollBoxRenderable | null = null;
|
|
29
|
+
let tableVisibleRanges: DataTableVisibleRange[] = [];
|
|
30
|
+
let setTableVisibleRangeKey: ((key: string) => void) | null = null;
|
|
29
31
|
let closedTab: string | null = null;
|
|
30
32
|
let addedTab = false;
|
|
31
33
|
let resolvedChoice: string | null = null;
|
|
@@ -176,6 +178,7 @@ function DataTableHorizontalScrollHarness({
|
|
|
176
178
|
function DataTableVirtualizationHarness() {
|
|
177
179
|
const headerScrollRef = useRef<ScrollBoxRenderable>(null);
|
|
178
180
|
const scrollRef = useRef<ScrollBoxRenderable>(null);
|
|
181
|
+
const [visibleRangeKey, setVisibleRangeKey] = useState("first");
|
|
179
182
|
const rows = Array.from({ length: 100 }, (_, index) => ({
|
|
180
183
|
id: `row-${index}`,
|
|
181
184
|
name: `Row ${index}`,
|
|
@@ -183,6 +186,7 @@ function DataTableVirtualizationHarness() {
|
|
|
183
186
|
|
|
184
187
|
useEffect(() => {
|
|
185
188
|
tableScrollBoxForTest = scrollRef.current;
|
|
189
|
+
setTableVisibleRangeKey = setVisibleRangeKey;
|
|
186
190
|
return () => {
|
|
187
191
|
if (tableScrollBoxForTest === scrollRef.current) {
|
|
188
192
|
tableScrollBoxForTest = null;
|
|
@@ -201,6 +205,8 @@ function DataTableVirtualizationHarness() {
|
|
|
201
205
|
scrollRef={scrollRef}
|
|
202
206
|
syncHeaderScroll={() => {}}
|
|
203
207
|
onBodyScrollActivity={() => {}}
|
|
208
|
+
visibleRangeKey={visibleRangeKey}
|
|
209
|
+
onVisibleRangeChange={(range) => tableVisibleRanges.push(range)}
|
|
204
210
|
getItemKey={(row) => row.id}
|
|
205
211
|
isSelected={() => false}
|
|
206
212
|
onSelect={() => {}}
|
|
@@ -287,6 +293,8 @@ afterEach(async () => {
|
|
|
287
293
|
selectedTableRow = null;
|
|
288
294
|
activatedTableRow = null;
|
|
289
295
|
tableScrollBoxForTest = null;
|
|
296
|
+
tableVisibleRanges = [];
|
|
297
|
+
setTableVisibleRangeKey = null;
|
|
290
298
|
closedTab = null;
|
|
291
299
|
addedTab = false;
|
|
292
300
|
resolvedChoice = null;
|
|
@@ -906,6 +914,16 @@ describe("shared UI kit", () => {
|
|
|
906
914
|
expect(frame).toContain("Row 0");
|
|
907
915
|
expect(frame).not.toContain("Row 99");
|
|
908
916
|
expect(tableScrollBoxForTest?.scrollTop).toBe(0);
|
|
917
|
+
expect(tableVisibleRanges.at(-1)).toEqual({ start: 0, end: 5 });
|
|
918
|
+
|
|
919
|
+
const initialRangeCount = tableVisibleRanges.length;
|
|
920
|
+
await act(async () => {
|
|
921
|
+
setTableVisibleRangeKey!("second");
|
|
922
|
+
await Promise.resolve();
|
|
923
|
+
await testSetup!.renderOnce();
|
|
924
|
+
});
|
|
925
|
+
expect(tableVisibleRanges.length).toBeGreaterThan(initialRangeCount);
|
|
926
|
+
expect(tableVisibleRanges.at(-1)).toEqual({ start: 0, end: 5 });
|
|
909
927
|
|
|
910
928
|
await act(async () => {
|
|
911
929
|
for (let index = 0; index < 12; index++) {
|
|
@@ -919,6 +937,10 @@ describe("shared UI kit", () => {
|
|
|
919
937
|
|
|
920
938
|
const scrollTop = tableScrollBoxForTest?.scrollTop ?? 0;
|
|
921
939
|
expect(scrollTop).toBeGreaterThan(0);
|
|
940
|
+
expect(tableVisibleRanges.at(-1)).toEqual({
|
|
941
|
+
start: scrollTop,
|
|
942
|
+
end: Math.min(100, scrollTop + 5),
|
|
943
|
+
});
|
|
922
944
|
|
|
923
945
|
const scrolledFrame = testSetup.captureCharFrame();
|
|
924
946
|
expect(scrolledFrame).toContain(`Row ${scrollTop}`);
|