gloomberb 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/package.json +2 -1
  2. package/patches/@opentui%2Fcore@0.1.90.patch +48 -24
  3. package/src/app.tsx +9 -6
  4. package/src/cli/registry.test.ts +0 -1
  5. package/src/components/chart/chart-controller.ts +0 -1
  6. package/src/components/chart/chart-data.ts +1 -31
  7. package/src/components/chart/chart-renderer.ts +70 -0
  8. package/src/components/chart/chart-resolution.test.ts +0 -36
  9. package/src/components/chart/chart-resolution.ts +0 -27
  10. package/src/components/chart/chart.test.ts +17 -45
  11. package/src/components/chart/comparison-chart-data.test.ts +1 -5
  12. package/src/components/chart/comparison-chart-data.ts +1 -4
  13. package/src/components/chart/comparison-stock-chart.tsx +62 -77
  14. package/src/components/chart/native/kitty.test.ts +23 -0
  15. package/src/components/chart/native/surface-visibility.ts +48 -0
  16. package/src/components/chart/static-chart-surface.tsx +28 -4
  17. package/src/components/chart/stock-chart.test.ts +0 -16
  18. package/src/components/chart/stock-chart.tsx +52 -71
  19. package/src/components/chart/time-axis-label.tsx +43 -0
  20. package/src/components/command-bar/command-bar.test.tsx +71 -8
  21. package/src/components/command-bar/command-bar.tsx +388 -713
  22. package/src/components/command-bar/confirm-body.tsx +51 -0
  23. package/src/components/command-bar/list-model.ts +77 -0
  24. package/src/components/command-bar/view-model.ts +0 -19
  25. package/src/components/command-bar/workflow-ops.ts +4 -0
  26. package/src/components/data-table-stack-view.test.tsx +39 -4
  27. package/src/components/data-table-stack-view.tsx +11 -104
  28. package/src/components/data-table-view.tsx +43 -79
  29. package/src/components/feed-data-table-stack-view.tsx +10 -17
  30. package/src/components/feed-stack-controller.ts +39 -0
  31. package/src/components/index.ts +6 -2
  32. package/src/components/layout/floating-pane.tsx +30 -2
  33. package/src/components/layout/header.tsx +10 -7
  34. package/src/components/layout/pane-content.tsx +2 -0
  35. package/src/components/layout/pane-cycle-order.test.ts +30 -0
  36. package/src/components/layout/pane-cycle-order.ts +32 -0
  37. package/src/components/layout/pane-footer.tsx +26 -4
  38. package/src/components/layout/pane-header.tsx +5 -5
  39. package/src/components/layout/pane-keyboard-scroll.test.tsx +226 -0
  40. package/src/components/layout/shell.test.tsx +66 -22
  41. package/src/components/layout/shell.tsx +13 -6
  42. package/src/components/onboarding/onboarding-wizard.tsx +1 -2
  43. package/src/components/pane-settings-dialog.tsx +12 -4
  44. package/src/components/pane-template-wizard.test.tsx +3 -4
  45. package/src/components/speedometer-gauge.test.tsx +45 -0
  46. package/src/components/speedometer-gauge.tsx +558 -0
  47. package/src/components/table-view-shared.tsx +158 -0
  48. package/src/components/ticker-list-table-view.tsx +42 -72
  49. package/src/components/ui/confirm-dialog.tsx +66 -0
  50. package/src/components/ui/data-table.tsx +18 -10
  51. package/src/components/ui/index.ts +0 -1
  52. package/src/components/ui/page-stack-view.tsx +39 -10
  53. package/src/components/ui/remote-image.tsx +15 -40
  54. package/src/components/ui/table-layout.test.ts +16 -0
  55. package/src/components/ui/table-layout.ts +10 -3
  56. package/src/components/ui/ui.test.tsx +100 -20
  57. package/src/core/state/app-state.ts +6 -3
  58. package/src/market-data/coordinator.test.ts +1 -1
  59. package/src/market-data/coordinator.ts +0 -1
  60. package/src/news/aggregator.ts +0 -3
  61. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +46 -16
  62. package/src/plugins/builtin/ai/providers.ts +0 -17
  63. package/src/plugins/builtin/ai/screener-pane.test.tsx +3 -4
  64. package/src/plugins/builtin/ai/screener-pane.tsx +0 -3
  65. package/src/plugins/builtin/ask-ai.test.tsx +89 -2
  66. package/src/plugins/builtin/broker-manager/index.test.tsx +2 -2
  67. package/src/plugins/builtin/broker-manager/index.tsx +19 -36
  68. package/src/plugins/builtin/chat-controller.test.ts +105 -0
  69. package/src/plugins/builtin/chat-controller.ts +75 -6
  70. package/src/plugins/builtin/chat.test.tsx +6 -6
  71. package/src/plugins/builtin/chat.tsx +29 -20
  72. package/src/plugins/builtin/cloud-tweets.tsx +0 -1
  73. package/src/plugins/builtin/comparison-chart.test.tsx +53 -2
  74. package/src/plugins/builtin/comparison-chart.tsx +11 -5
  75. package/src/plugins/builtin/correlation/index.tsx +1 -6
  76. package/src/plugins/builtin/debug.tsx +1 -3
  77. package/src/plugins/builtin/econ/index.tsx +1 -2
  78. package/src/plugins/builtin/fear-greed/fear-greed-data.test.ts +72 -0
  79. package/src/plugins/builtin/fear-greed/fear-greed-data.ts +366 -0
  80. package/src/plugins/builtin/fear-greed/index.tsx +478 -0
  81. package/src/plugins/builtin/fx-matrix/index.tsx +2 -2
  82. package/src/plugins/builtin/help.test.tsx +2 -2
  83. package/src/plugins/builtin/help.tsx +2 -2
  84. package/src/plugins/builtin/holders/index.tsx +10 -15
  85. package/src/plugins/builtin/insider/index.tsx +22 -89
  86. package/src/plugins/builtin/market-movers/index.tsx +1 -1
  87. package/src/plugins/builtin/news-wire/index.tsx +41 -2
  88. package/src/plugins/builtin/news-wire/news-table.tsx +11 -11
  89. package/src/plugins/builtin/news.tsx +4 -16
  90. package/src/plugins/builtin/notes.tsx +18 -55
  91. package/src/plugins/builtin/options.test.tsx +58 -2
  92. package/src/plugins/builtin/options.tsx +108 -49
  93. package/src/plugins/builtin/plugin-groups.ts +2 -0
  94. package/src/plugins/builtin/portfolio-list/cli/commands.ts +1 -1
  95. package/src/plugins/builtin/research/index.tsx +5 -21
  96. package/src/plugins/builtin/sec-filing-display.tsx +84 -0
  97. package/src/plugins/builtin/sec.tsx +13 -66
  98. package/src/plugins/builtin/sectors/index.tsx +1 -1
  99. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +0 -1
  100. package/src/plugins/builtin/ticker-detail/index.tsx +0 -1
  101. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +0 -1
  102. package/src/plugins/builtin/ticker-detail/pane.tsx +36 -28
  103. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +69 -4
  104. package/src/plugins/builtin/ticker-detail.test.tsx +102 -0
  105. package/src/plugins/builtin/world-indices/index.tsx +1 -1
  106. package/src/plugins/builtin/yield-curve/index.tsx +1 -1
  107. package/src/plugins/ibkr/broker-adapter.ts +1 -3
  108. package/src/plugins/ibkr/gateway-service-native.ts +1 -5
  109. package/src/plugins/ibkr/gateway-service.ts +1 -1
  110. package/src/plugins/ibkr/index.test.tsx +0 -2
  111. package/src/plugins/ibkr/trade-tab.test.tsx +56 -5
  112. package/src/plugins/ibkr/trade-tab.tsx +13 -11
  113. package/src/plugins/pane-manager.ts +0 -13
  114. package/src/plugins/plugin-runtime.test.tsx +6 -6
  115. package/src/plugins/plugin-runtime.tsx +13 -8
  116. package/src/plugins/prediction-markets/cli.ts +0 -1
  117. package/src/plugins/prediction-markets/controller-data.ts +0 -2
  118. package/src/plugins/prediction-markets/pane.test.tsx +3 -11
  119. package/src/plugins/prediction-markets/test-helpers.tsx +1 -1
  120. package/src/plugins/registry.ts +10 -39
  121. package/src/react/input.ts +11 -2
  122. package/src/renderers/electrobun/view/data-table.tsx +28 -6
  123. package/src/renderers/electrobun/view/desktop-controls.tsx +34 -20
  124. package/src/renderers/electrobun/view/dialog-host.tsx +11 -1
  125. package/src/renderers/electrobun/view/input-host.tsx +123 -12
  126. package/src/renderers/electrobun/view/key-event.ts +1 -1
  127. package/src/renderers/electrobun/view/styles.css +331 -0
  128. package/src/renderers/electrobun/view/ui-host.tsx +22 -3
  129. package/src/renderers/opentui/chart-surface.tsx +179 -0
  130. package/src/renderers/opentui/host.tsx +6 -0
  131. package/src/renderers/opentui/image-loader.test.ts +25 -0
  132. package/src/renderers/opentui/image-loader.ts +105 -0
  133. package/src/renderers/opentui/image-surface.tsx +266 -0
  134. package/src/renderers/opentui/input-host.tsx +45 -6
  135. package/src/renderers/opentui/start.tsx +48 -30
  136. package/src/renderers/opentui/test-utils.tsx +17 -0
  137. package/src/renderers/opentui/ui-host.tsx +7 -3
  138. package/src/sources/provider-router.ts +0 -4
  139. package/src/state/app-context.test.ts +45 -0
  140. package/src/state/app-context.tsx +4 -0
  141. package/src/state/pane-scroll-registry.tsx +254 -0
  142. package/src/state/use-inline-tickers.test.tsx +73 -0
  143. package/src/state/use-inline-tickers.ts +7 -1
  144. package/src/test-support/plugin-runtime.ts +3 -2
  145. package/src/theme/colors.ts +12 -3
  146. package/src/theme/themes.test.ts +23 -1
  147. package/src/theme/themes.ts +115 -0
  148. package/src/types/broker.ts +1 -1
  149. package/src/types/data-provider.ts +0 -1
  150. package/src/types/plugin.ts +0 -2
  151. package/src/ui/host.tsx +4 -0
  152. package/src/ui/index.tsx +9 -1
  153. package/src/utils/back-navigation.ts +24 -1
  154. package/src/version.ts +1 -1
  155. package/src/plugins/builtin/news-wire/feed-pane.tsx +0 -21
  156. package/src/plugins/builtin/news-wire/top-pane.tsx +0 -21
@@ -0,0 +1,51 @@
1
+ import { Box, Text } from "../../ui";
2
+ import { colors } from "../../theme/colors";
3
+ import { Button, Spinner } from "../ui";
4
+ import { truncateText } from "./view-model";
5
+ import type { CommandBarConfirmRoute } from "./workflow-types";
6
+
7
+ export function CommandBarConfirmBody({
8
+ route,
9
+ bodyHeight,
10
+ contentPadding,
11
+ paletteText,
12
+ queryDisplayWidth,
13
+ onConfirm,
14
+ }: {
15
+ route: CommandBarConfirmRoute;
16
+ bodyHeight: number;
17
+ contentPadding: number;
18
+ paletteText: string;
19
+ queryDisplayWidth: number;
20
+ onConfirm: () => void;
21
+ }) {
22
+ return (
23
+ <Box flexDirection="column" height={bodyHeight} paddingX={contentPadding}>
24
+ {route.body.map((line, index) => (
25
+ <Box key={`confirm:${index}`} height={1}>
26
+ <Text fg={paletteText}>{truncateText(line, queryDisplayWidth)}</Text>
27
+ </Box>
28
+ ))}
29
+ <Box height={1} />
30
+ {route.error && (
31
+ <Box height={1}>
32
+ <Text fg={colors.negative}>{truncateText(route.error, queryDisplayWidth)}</Text>
33
+ </Box>
34
+ )}
35
+ {route.pending && (
36
+ <Box height={1}>
37
+ <Spinner label="Working…" />
38
+ </Box>
39
+ )}
40
+ <Box flexGrow={1} />
41
+ <Box flexDirection="row" gap={1}>
42
+ <Button
43
+ label={route.confirmLabel}
44
+ variant={route.tone === "danger" ? "danger" : "primary"}
45
+ onPress={onConfirm}
46
+ disabled={route.pending}
47
+ />
48
+ </Box>
49
+ </Box>
50
+ );
51
+ }
@@ -0,0 +1,77 @@
1
+ import { buildSections } from "./view-model";
2
+
3
+ export interface ResultItem {
4
+ id: string;
5
+ label: string;
6
+ detail: string;
7
+ category: string;
8
+ kind: "command" | "ticker" | "search" | "theme" | "plugin" | "action" | "info";
9
+ right?: string;
10
+ searchText?: string;
11
+ themeId?: string;
12
+ pluginToggle?: () => void | Promise<void>;
13
+ secondaryAction?: () => void | Promise<void>;
14
+ checked?: boolean;
15
+ current?: boolean;
16
+ disabled?: boolean;
17
+ action: () => void | Promise<void>;
18
+ }
19
+
20
+ type ListScreenKind = "root" | "mode" | "picker" | "pane-settings";
21
+
22
+ export interface ListScreenState {
23
+ kind: ListScreenKind;
24
+ title: string;
25
+ subtitle?: string;
26
+ query: string;
27
+ selectedIdx: number;
28
+ hoveredIdx: number | null;
29
+ results: ResultItem[];
30
+ searching: boolean;
31
+ emptyLabel: string;
32
+ emptyDetail: string;
33
+ footerLeft: string;
34
+ footerRight: string;
35
+ }
36
+
37
+ export type CommandBarListRow =
38
+ | { kind: "spacer"; id: string }
39
+ | { kind: "heading"; id: string; label: string }
40
+ | { kind: "item"; item: ResultItem; globalIdx: number }
41
+ | { kind: "message"; id: string; label: string; dim?: boolean }
42
+ | { kind: "spinner"; id: string; label: string }
43
+ | { kind: "filler"; id: string };
44
+
45
+ export function orderListResults(results: ResultItem[]): ResultItem[] {
46
+ return buildSections(results).flatMap((section) => section.items);
47
+ }
48
+
49
+ export function buildListRows(listState: ListScreenState): CommandBarListRow[] {
50
+ const rows: CommandBarListRow[] = [];
51
+ const sections = buildSections(listState.results);
52
+ let globalIdx = 0;
53
+ sections.forEach((section, sectionIndex) => {
54
+ if (sectionIndex > 0) {
55
+ rows.push({ kind: "spacer", id: `spacer:${sectionIndex}:${section.category}` });
56
+ }
57
+ rows.push({ kind: "heading", id: `heading:${sectionIndex}:${section.category}`, label: section.category });
58
+ for (const item of section.items) {
59
+ rows.push({ kind: "item", item, globalIdx });
60
+ globalIdx += 1;
61
+ }
62
+ });
63
+ return rows;
64
+ }
65
+
66
+ export function buildNativeListRows(listState: ListScreenState, rows: CommandBarListRow[]): CommandBarListRow[] {
67
+ if (listState.searching && rows.length === 0) {
68
+ return [{ kind: "spinner", id: "searching", label: "Searching…" }];
69
+ }
70
+ if (rows.length === 0) {
71
+ return [{ kind: "message", id: "empty", label: listState.emptyLabel }];
72
+ }
73
+ if (listState.searching) {
74
+ return [...rows, { kind: "spinner", id: "searching", label: "Searching…" }];
75
+ }
76
+ return rows;
77
+ }
@@ -10,11 +10,6 @@ export interface CommandBarModeInfo {
10
10
  hint: string;
11
11
  }
12
12
 
13
- export interface CommandBarFooterHints {
14
- left: string;
15
- right: string;
16
- }
17
-
18
13
  export interface CommandBarSection<T> {
19
14
  category: string;
20
15
  items: T[];
@@ -83,20 +78,6 @@ export function buildSections<T extends { category: string }>(items: T[]): Array
83
78
  .map(({ section }) => section);
84
79
  }
85
80
 
86
- export function getFooterHints(mode: CommandBarMode, isNarrow: boolean): CommandBarFooterHints {
87
- const moveAndSelect = isNarrow ? "up/down move enter select" : "up/down move enter select";
88
- if (mode === "plugins") {
89
- return {
90
- left: isNarrow ? "space toggle" : `${moveAndSelect} space toggle`,
91
- right: "esc cancel",
92
- };
93
- }
94
- if (mode === "layout") {
95
- return { left: moveAndSelect, right: "esc cancel" };
96
- }
97
- return { left: moveAndSelect, right: "esc cancel" };
98
- }
99
-
100
81
  export function getEmptyState(mode: CommandBarMode, query: string, searchQuery?: string): { label: string; detail: string } {
101
82
  switch (mode) {
102
83
  case "search":
@@ -14,6 +14,7 @@ import { cleanPortfolioPaneSettings, resolvePortfolioPaneCollectionId } from "..
14
14
  import {
15
15
  buildComparisonChartPaneTitle,
16
16
  COMPARISON_CHART_PANE_ID,
17
+ MIN_COMPARISON_CHART_SYMBOLS,
17
18
  } from "../../plugins/builtin/comparison-chart";
18
19
  import { getPaneTemplateDisplayLabel } from "./pane-template-display";
19
20
 
@@ -415,6 +416,9 @@ export async function applyPaneSettingFieldValue(
415
416
  descriptor.context.activeCollectionId,
416
417
  deps,
417
418
  );
419
+ if (symbols.length < MIN_COMPARISON_CHART_SYMBOLS) {
420
+ throw new Error(`Enter at least ${MIN_COMPARISON_CHART_SYMBOLS} tickers.`);
421
+ }
418
422
  const nextLayout = updatePaneInstance(state.config.layout, targetId, (instance) => ({
419
423
  ...instance,
420
424
  title: buildComparisonChartPaneTitle(symbols),
@@ -160,6 +160,13 @@ async function emitKeypress(event: { name?: string; sequence?: string }) {
160
160
  });
161
161
  }
162
162
 
163
+ async function emitTerminalMouseBack(x: number, y: number) {
164
+ await act(async () => {
165
+ await testSetup!.mockMouse.emitMouseEvent("down", x, y, 128 as any);
166
+ await testSetup!.renderOnce();
167
+ });
168
+ }
169
+
163
170
  describe("DataTableStackView", () => {
164
171
  test("owns table navigation, detail open, and back navigation", async () => {
165
172
  testSetup = await testRender(<Harness />, { width: 60, height: 12 });
@@ -179,16 +186,44 @@ describe("DataTableStackView", () => {
179
186
 
180
187
  await emitKeypress({ name: "escape", sequence: "\u001b" });
181
188
  await renderSettled();
182
- expect(testSetup.captureCharFrame()).toContain("Second detail");
183
-
184
- await emitKeypress({ name: "backspace", sequence: "\u007f" });
185
- await renderSettled();
186
189
 
187
190
  const rootFrame = testSetup.captureCharFrame();
188
191
  expect(rootFrame).toContain("Second row");
189
192
  expect(rootFrame).not.toContain("Second detail");
190
193
  });
191
194
 
195
+ test("closes detail from backspace", async () => {
196
+ testSetup = await testRender(<Harness />, { width: 60, height: 12 });
197
+
198
+ await renderSettled();
199
+ await emitKeypress({ name: "enter", sequence: "\r" });
200
+ await renderSettled();
201
+ expect(testSetup.captureCharFrame()).toContain("First detail");
202
+
203
+ await emitKeypress({ name: "backspace", sequence: "\u007f" });
204
+ await renderSettled();
205
+
206
+ const frame = testSetup.captureCharFrame();
207
+ expect(frame).toContain("First row");
208
+ expect(frame).not.toContain("First detail");
209
+ });
210
+
211
+ test("closes detail from the terminal mouse back button", async () => {
212
+ testSetup = await testRender(<Harness />, { width: 60, height: 12 });
213
+
214
+ await renderSettled();
215
+ await emitKeypress({ name: "enter", sequence: "\r" });
216
+ await renderSettled();
217
+ expect(testSetup.captureCharFrame()).toContain("First detail");
218
+
219
+ await emitTerminalMouseBack(30, 4);
220
+ await renderSettled();
221
+
222
+ const frame = testSetup.captureCharFrame();
223
+ expect(frame).toContain("First row");
224
+ expect(frame).not.toContain("First detail");
225
+ });
226
+
192
227
  test("skips section rows while navigating through the stack view", async () => {
193
228
  testSetup = await testRender(<GroupedHarness />, { width: 60, height: 12 });
194
229
 
@@ -1,50 +1,23 @@
1
1
  import { useShortcut } from "../react/input";
2
- import { type ScrollBoxRenderable } from "../ui";
3
- import { type ReactNode, type RefObject } from "react";
4
- import { DataTableView } from "./data-table-view";
5
- import { PageStackView, type DataTableColumn, type DataTableProps } from "./ui";
6
-
7
- interface KeyboardEventLike {
8
- name?: string;
9
- preventDefault?: () => void;
10
- stopPropagation?: () => void;
11
- }
2
+ import { type ReactNode } from "react";
3
+ import {
4
+ DataTableView,
5
+ type DataTableKeyEvent,
6
+ type DataTableViewProps,
7
+ } from "./data-table-view";
8
+ import { PageStackView, type DataTableColumn } from "./ui";
12
9
 
13
10
  export interface DataTableStackViewProps<
14
11
  T,
15
12
  C extends DataTableColumn = DataTableColumn,
16
- > extends Omit<
17
- DataTableProps<T, C>,
18
- | "headerScrollRef"
19
- | "scrollRef"
20
- | "syncHeaderScroll"
21
- | "onBodyScrollActivity"
22
- | "hoveredIdx"
23
- | "setHoveredIdx"
24
- > {
13
+ > extends Omit<DataTableViewProps<T, C>, "focused" | "selectedIndex"> {
25
14
  focused: boolean;
26
15
  detailOpen: boolean;
27
16
  onBack: () => void;
28
17
  detailContent: ReactNode;
29
18
  detailTitle?: string;
30
19
  selectedIndex: number;
31
- onSelectIndex?: (index: number, item: T) => void;
32
- onActivateIndex?: (index: number, item: T) => void;
33
- isNavigable?: (item: T, index: number) => boolean;
34
- rootBefore?: ReactNode;
35
- rootAfter?: ReactNode;
36
- rootWidth?: number;
37
- rootHeight?: number;
38
- rootBackgroundColor?: string;
39
- headerScrollRef?: RefObject<ScrollBoxRenderable | null>;
40
- scrollRef?: RefObject<ScrollBoxRenderable | null>;
41
- syncHeaderScroll?: () => void;
42
- onBodyScrollActivity?: () => void;
43
- hoveredIdx?: number | null;
44
- setHoveredIdx?: (index: number | null) => void;
45
- keyboardNavigation?: boolean;
46
- onRootKeyDown?: (event: KeyboardEventLike) => boolean | void;
47
- onDetailKeyDown?: (event: KeyboardEventLike) => boolean | void;
20
+ onDetailKeyDown?: (event: DataTableKeyEvent) => boolean | void;
48
21
  }
49
22
 
50
23
  export function DataTableStackView<
@@ -56,42 +29,9 @@ export function DataTableStackView<
56
29
  onBack,
57
30
  detailContent,
58
31
  detailTitle,
59
- selectedIndex,
60
- onSelectIndex,
61
- onActivateIndex,
62
- isNavigable,
63
- rootBefore,
64
- rootAfter,
65
- rootWidth,
66
- rootHeight,
67
- rootBackgroundColor,
68
- headerScrollRef,
69
- scrollRef,
70
- syncHeaderScroll,
71
- onBodyScrollActivity,
72
- hoveredIdx,
73
- setHoveredIdx,
74
32
  keyboardNavigation = true,
75
- onRootKeyDown,
76
33
  onDetailKeyDown,
77
- columns,
78
- items,
79
- sortColumnId,
80
- sortDirection,
81
- onHeaderClick,
82
- getItemKey,
83
- isSelected,
84
- onSelect,
85
- onActivate,
86
- renderCell,
87
- renderSectionHeader,
88
- getRowBackgroundColor,
89
- emptyContent,
90
- emptyStateTitle,
91
- emptyStateHint,
92
- virtualize = true,
93
- overscan,
94
- showHorizontalScrollbar,
34
+ ...tableProps
95
35
  }: DataTableStackViewProps<T, C>) {
96
36
  useShortcut((event) => {
97
37
  if (!focused || !detailOpen || !keyboardNavigation) return;
@@ -100,42 +40,9 @@ export function DataTableStackView<
100
40
 
101
41
  const rootContent = (
102
42
  <DataTableView<T, C>
43
+ {...tableProps}
103
44
  focused={focused && !detailOpen}
104
- selectedIndex={selectedIndex}
105
- onSelectIndex={onSelectIndex}
106
- onActivateIndex={onActivateIndex}
107
- isNavigable={isNavigable}
108
- rootBefore={rootBefore}
109
- rootAfter={rootAfter}
110
- rootWidth={rootWidth}
111
- rootHeight={rootHeight}
112
- rootBackgroundColor={rootBackgroundColor}
113
- headerScrollRef={headerScrollRef}
114
- scrollRef={scrollRef}
115
- syncHeaderScroll={syncHeaderScroll}
116
- onBodyScrollActivity={onBodyScrollActivity}
117
- hoveredIdx={hoveredIdx}
118
- setHoveredIdx={setHoveredIdx}
119
45
  keyboardNavigation={keyboardNavigation}
120
- onRootKeyDown={onRootKeyDown}
121
- columns={columns}
122
- items={items}
123
- sortColumnId={sortColumnId}
124
- sortDirection={sortDirection}
125
- onHeaderClick={onHeaderClick}
126
- getItemKey={getItemKey}
127
- isSelected={isSelected}
128
- onSelect={onSelect}
129
- onActivate={onActivate}
130
- renderCell={renderCell}
131
- renderSectionHeader={renderSectionHeader}
132
- getRowBackgroundColor={getRowBackgroundColor}
133
- emptyContent={emptyContent}
134
- emptyStateTitle={emptyStateTitle}
135
- emptyStateHint={emptyStateHint}
136
- virtualize={virtualize}
137
- overscan={overscan}
138
- showHorizontalScrollbar={showHorizontalScrollbar}
139
46
  />
140
47
  );
141
48
 
@@ -2,24 +2,26 @@ import {
2
2
  useCallback,
3
3
  useEffect,
4
4
  useMemo,
5
- useRef,
6
5
  useState,
7
6
  type ReactNode,
8
7
  type RefObject,
9
8
  } from "react";
10
- import { Box, type ScrollBoxRenderable } from "../ui";
9
+ import type { ScrollBoxRenderable } from "../ui";
11
10
  import { useShortcut } from "../react/input";
12
11
  import { DataTable, type DataTableColumn, type DataTableProps } from "./ui";
13
-
14
- export interface DataTableKeyEvent {
15
- name?: string;
16
- ctrl?: boolean;
17
- meta?: boolean;
18
- option?: boolean;
19
- shift?: boolean;
20
- preventDefault?: () => void;
21
- stopPropagation?: () => void;
22
- }
12
+ import {
13
+ isNextTableRowKey,
14
+ isPreviousTableRowKey,
15
+ isTableActivationKey,
16
+ stopTableKey,
17
+ TableViewFrame,
18
+ type TableViewKeyEvent,
19
+ useResetTableScroll,
20
+ useTableBodyScrollActivity,
21
+ useTableViewState,
22
+ } from "./table-view-shared";
23
+
24
+ export type DataTableKeyEvent = TableViewKeyEvent;
23
25
 
24
26
  export interface DataTableViewProps<
25
27
  T,
@@ -54,23 +56,6 @@ export interface DataTableViewProps<
54
56
  resetScrollKey?: unknown;
55
57
  }
56
58
 
57
- function isActivationKey(name: string | undefined): boolean {
58
- return name === "enter" || name === "return";
59
- }
60
-
61
- function isNextRowKey(name: string | undefined): boolean {
62
- return name === "j" || name === "down";
63
- }
64
-
65
- function isPreviousRowKey(name: string | undefined): boolean {
66
- return name === "k" || name === "up";
67
- }
68
-
69
- function stopTableKey(event: DataTableKeyEvent) {
70
- event.stopPropagation?.();
71
- event.preventDefault?.();
72
- }
73
-
74
59
  export function DataTableView<
75
60
  T,
76
61
  C extends DataTableColumn = DataTableColumn,
@@ -97,17 +82,19 @@ export function DataTableView<
97
82
  scrollToIndex,
98
83
  ...tableProps
99
84
  }: DataTableViewProps<T, C>) {
100
- const internalHeaderScrollRef = useRef<ScrollBoxRenderable>(null);
101
- const internalScrollRef = useRef<ScrollBoxRenderable>(null);
102
- const [internalHoveredIdx, setInternalHoveredIdx] = useState<number | null>(
103
- null,
104
- );
105
-
106
- const effectiveHeaderScrollRef = headerScrollRef ?? internalHeaderScrollRef;
107
- const effectiveScrollRef = scrollRef ?? internalScrollRef;
108
- const effectiveHoveredIdx =
109
- hoveredIdx !== undefined ? hoveredIdx : internalHoveredIdx;
110
- const effectiveSetHoveredIdx = setHoveredIdx ?? setInternalHoveredIdx;
85
+ const {
86
+ effectiveHeaderScrollRef,
87
+ effectiveScrollRef,
88
+ effectiveHoveredIdx,
89
+ effectiveSetHoveredIdx,
90
+ effectiveSyncHeaderScroll,
91
+ } = useTableViewState({
92
+ headerScrollRef,
93
+ scrollRef,
94
+ syncHeaderScroll,
95
+ hoveredIdx,
96
+ setHoveredIdx,
97
+ });
111
98
  const [fallbackSelectedIndex, setFallbackSelectedIndex] = useState<
112
99
  number | null
113
100
  >(null);
@@ -148,36 +135,16 @@ export function DataTableView<
148
135
  setFallbackSelectedIndex(nextIndex);
149
136
  }, [fallbackSelectedIndex, navigableIndices, usesFallbackSelection]);
150
137
 
151
- const defaultSyncHeaderScroll = useCallback(() => {
152
- const body = effectiveScrollRef.current;
153
- const header = effectiveHeaderScrollRef.current;
154
- if (!body || !header) return;
155
- if (header.scrollLeft !== body.scrollLeft) {
156
- header.scrollLeft = body.scrollLeft;
157
- }
158
- }, [effectiveHeaderScrollRef, effectiveScrollRef]);
159
- const effectiveSyncHeaderScroll = syncHeaderScroll ?? defaultSyncHeaderScroll;
160
-
161
- const handleBodyScrollActivity = useCallback(() => {
162
- if (onBodyScrollActivity) {
163
- onBodyScrollActivity();
164
- return;
165
- }
166
- queueMicrotask(effectiveSyncHeaderScroll);
167
- }, [effectiveSyncHeaderScroll, onBodyScrollActivity]);
138
+ const handleBodyScrollActivity = useTableBodyScrollActivity({
139
+ onBodyScrollActivity,
140
+ syncHeaderScroll: effectiveSyncHeaderScroll,
141
+ });
168
142
 
169
- useEffect(() => {
170
- if (resetScrollKey === undefined) return;
171
- const body = effectiveScrollRef.current;
172
- if (body) {
173
- body.scrollTop = 0;
174
- body.scrollLeft = 0;
175
- }
176
- const header = effectiveHeaderScrollRef.current;
177
- if (header) {
178
- header.scrollLeft = 0;
179
- }
180
- }, [effectiveHeaderScrollRef, effectiveScrollRef, resetScrollKey]);
143
+ useResetTableScroll({
144
+ headerScrollRef: effectiveHeaderScrollRef,
145
+ scrollRef: effectiveScrollRef,
146
+ resetScrollKey,
147
+ });
181
148
 
182
149
  const selectIndex = useCallback((index: number) => {
183
150
  if (index < 0 || index >= tableProps.items.length) return;
@@ -226,34 +193,32 @@ export function DataTableView<
226
193
  if (onRootKeyDown?.(event)) return;
227
194
  if (tableProps.items.length === 0) return;
228
195
 
229
- if (isNextRowKey(event.name)) {
196
+ if (isNextTableRowKey(event.name)) {
230
197
  stopTableKey(event);
231
198
  selectByOffset(1);
232
199
  return;
233
200
  }
234
201
 
235
- if (isPreviousRowKey(event.name)) {
202
+ if (isPreviousTableRowKey(event.name)) {
236
203
  stopTableKey(event);
237
204
  selectByOffset(-1);
238
205
  return;
239
206
  }
240
207
 
241
- if (isActivationKey(event.name)) {
208
+ if (isTableActivationKey(event.name)) {
242
209
  stopTableKey(event);
243
210
  activateSelection();
244
211
  }
245
212
  });
246
213
 
247
214
  return (
248
- <Box
249
- flexDirection="column"
250
- flexGrow={1}
215
+ <TableViewFrame
251
216
  width={rootWidth}
252
217
  height={rootHeight}
253
218
  backgroundColor={rootBackgroundColor}
254
- overflow="hidden"
219
+ before={rootBefore}
220
+ after={rootAfter}
255
221
  >
256
- {rootBefore}
257
222
  <DataTable<T, C>
258
223
  {...tableProps}
259
224
  headerScrollRef={effectiveHeaderScrollRef}
@@ -273,7 +238,6 @@ export function DataTableView<
273
238
  )
274
239
  )}
275
240
  />
276
- {rootAfter}
277
- </Box>
241
+ </TableViewFrame>
278
242
  );
279
243
  }
@@ -5,6 +5,12 @@ import { colors } from "../theme/colors";
5
5
  import { formatTimeAgo } from "../utils/format";
6
6
  import { toTimestampMillis } from "../utils/timestamp";
7
7
  import { DataTableStackView } from "./data-table-stack-view";
8
+ import {
9
+ activeStackIndex,
10
+ sortIndexedStackRows,
11
+ type IndexedStackRow,
12
+ type StackSortPreference,
13
+ } from "./feed-stack-controller";
8
14
  import { ExternalLink, type DataTableCell, type DataTableColumn } from "./ui";
9
15
  import { wrapTextLines } from "../utils/text-wrap";
10
16
 
@@ -23,15 +29,9 @@ export interface FeedDataTableItem {
23
29
  type DetailColumnId = "time" | "source" | "title";
24
30
  type DetailColumn = DataTableColumn & { id: DetailColumnId };
25
31
 
26
- interface DetailRow {
27
- item: FeedDataTableItem;
28
- itemIndex: number;
29
- }
32
+ type DetailRow = IndexedStackRow<FeedDataTableItem>;
30
33
 
31
- interface SortPreference {
32
- columnId: DetailColumnId;
33
- direction: "asc" | "desc";
34
- }
34
+ type SortPreference = StackSortPreference<DetailColumnId>;
35
35
 
36
36
  interface FeedDataTableStackViewProps {
37
37
  width: number;
@@ -151,19 +151,12 @@ export function FeedDataTableStackView({
151
151
  [items, sourceLabel, titleLabel, width],
152
152
  );
153
153
  const sortedRows = useMemo(() => {
154
- const direction = sortPreference.direction === "asc" ? 1 : -1;
155
- return items
156
- .map((item, itemIndex) => ({ item, itemIndex }))
157
- .sort((a, b) => {
158
- const primary = compareRows(a, b, sortPreference.columnId) * direction;
159
- return primary !== 0 ? primary : a.itemIndex - b.itemIndex;
160
- });
154
+ return sortIndexedStackRows(items, sortPreference, compareRows);
161
155
  }, [items, sortPreference]);
162
156
  const selectedRowIndex = sortedRows.findIndex(
163
157
  (row) => row.itemIndex === selectedIdx,
164
158
  );
165
- const activeRowIndex =
166
- selectedRowIndex >= 0 ? selectedRowIndex : sortedRows.length > 0 ? 0 : -1;
159
+ const activeRowIndex = activeStackIndex(sortedRows.length, selectedRowIndex);
167
160
  const openItem = useMemo(
168
161
  () =>
169
162
  openItemId
@@ -0,0 +1,39 @@
1
+ export interface StackSortPreference<C extends string> {
2
+ columnId: C;
3
+ direction: "asc" | "desc";
4
+ }
5
+
6
+ export interface IndexedStackRow<T> {
7
+ item: T;
8
+ itemIndex: number;
9
+ }
10
+
11
+ export function sortStackItems<T, C extends string>(
12
+ items: T[],
13
+ preference: StackSortPreference<C>,
14
+ compare: (a: T, b: T, columnId: C) => number,
15
+ tieBreak: (a: T, b: T) => number = () => 0,
16
+ ): T[] {
17
+ const direction = preference.direction === "asc" ? 1 : -1;
18
+ return [...items].sort((a, b) => {
19
+ const primary = compare(a, b, preference.columnId) * direction;
20
+ return primary !== 0 ? primary : tieBreak(a, b);
21
+ });
22
+ }
23
+
24
+ export function sortIndexedStackRows<T, C extends string>(
25
+ items: T[],
26
+ preference: StackSortPreference<C>,
27
+ compare: (a: IndexedStackRow<T>, b: IndexedStackRow<T>, columnId: C) => number,
28
+ ): Array<IndexedStackRow<T>> {
29
+ return sortStackItems(
30
+ items.map((item, itemIndex) => ({ item, itemIndex })),
31
+ preference,
32
+ compare,
33
+ (a, b) => a.itemIndex - b.itemIndex,
34
+ );
35
+ }
36
+
37
+ export function activeStackIndex(length: number, selectedIndex: number): number {
38
+ return selectedIndex >= 0 ? selectedIndex : length > 0 ? 0 : -1;
39
+ }