gloomberb 0.7.4 → 0.7.5
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 +1 -1
- package/src/app.tsx +59 -30
- package/src/components/command-bar/command-bar.tsx +19 -1
- package/src/components/layout/shell.test.tsx +26 -0
- package/src/components/layout/shell.tsx +69 -21
- package/src/plugins/builtin/holders/index.test.ts +30 -1
- package/src/plugins/builtin/holders/index.tsx +7 -3
- package/src/plugins/pane-manager.test.ts +31 -0
- package/src/plugins/pane-manager.ts +32 -6
- package/src/renderers/electrobun/bun/index.ts +83 -3
- package/src/renderers/electrobun/shared/protocol.ts +8 -1
- package/src/renderers/electrobun/view/backend-rpc.ts +5 -0
- package/src/renderers/electrobun/view/fatal-screen.tsx +118 -0
- package/src/renderers/electrobun/view/main.tsx +38 -24
- package/src/renderers/electrobun/view/styles.css +48 -4
- package/src/utils/ticker-search.ts +72 -1
- package/src/version.ts +1 -1
package/package.json
CHANGED
package/src/app.tsx
CHANGED
|
@@ -40,7 +40,7 @@ import {
|
|
|
40
40
|
type PaneBinding,
|
|
41
41
|
type PaneInstanceConfig,
|
|
42
42
|
} from "./types/config";
|
|
43
|
-
import type { CliLaunchRequest, PaneTemplateCreateOptions, PaneTemplateInstanceConfig, WizardStep } from "./types/plugin";
|
|
43
|
+
import type { CliLaunchRequest, PaneTemplateCreateOptions, PaneTemplateInstanceConfig, PinTickerOptions, WizardStep } from "./types/plugin";
|
|
44
44
|
import type { PaneSettingField } from "./types/plugin";
|
|
45
45
|
import type { TickerRecord } from "./types/ticker";
|
|
46
46
|
import type { DataProvider } from "./types/data-provider";
|
|
@@ -48,7 +48,7 @@ import type { TickerFinancials } from "./types/financials";
|
|
|
48
48
|
import type { BrokerAccount } from "./types/trading";
|
|
49
49
|
import type { DesktopDockPreviewState, DesktopSharedStateSnapshot, DesktopThemePreviewState, DesktopWindowBridge } from "./types/desktop-window";
|
|
50
50
|
import type { DesktopApplicationMenuBridge } from "./types/desktop-menu";
|
|
51
|
-
import {
|
|
51
|
+
import { resolveTickerOpenTarget, type TickerOpenTarget } from "./utils/ticker-search";
|
|
52
52
|
|
|
53
53
|
import {
|
|
54
54
|
clearPersistedBrokerAccounts,
|
|
@@ -70,6 +70,7 @@ import {
|
|
|
70
70
|
gridlockAllPanes,
|
|
71
71
|
isPaneInLayout,
|
|
72
72
|
removePane,
|
|
73
|
+
type LayoutBounds,
|
|
73
74
|
} from "./plugins/pane-manager";
|
|
74
75
|
import { notifyGridlockComplete } from "./plugins/gridlock-notification";
|
|
75
76
|
import {
|
|
@@ -206,6 +207,7 @@ function AppInner({
|
|
|
206
207
|
const isDetachedWindow = desktopWindowBridge?.kind === "detached";
|
|
207
208
|
const detachedPaneId = isDetachedWindow ? desktopWindowBridge.paneId ?? null : null;
|
|
208
209
|
const [desktopDockPreview, setDesktopDockPreview] = useState<DesktopDockPreviewState | null>(null);
|
|
210
|
+
const [commandBarNativeOccluder, setCommandBarNativeOccluder] = useState<LayoutBounds | null>(null);
|
|
209
211
|
appActiveRef.current = appActive;
|
|
210
212
|
const appNotifier = useMemo(() => createAppNotifier({
|
|
211
213
|
isAppActive: () => appActiveRef.current,
|
|
@@ -1359,14 +1361,45 @@ function AppInner({
|
|
|
1359
1361
|
|
|
1360
1362
|
focusVisiblePane(instanceId);
|
|
1361
1363
|
};
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
+
const resolveOpenTickerTarget = async (rawSymbol: string): Promise<TickerOpenTarget | null> => {
|
|
1365
|
+
try {
|
|
1366
|
+
const target = await resolveTickerOpenTarget({
|
|
1367
|
+
query: rawSymbol,
|
|
1368
|
+
tickers: stateRef.current.tickers,
|
|
1369
|
+
dataProvider,
|
|
1370
|
+
tickerRepository,
|
|
1371
|
+
});
|
|
1372
|
+
if (!target) {
|
|
1373
|
+
pluginRegistry.notify({ body: `Could not open ${rawSymbol}.`, type: "error" });
|
|
1374
|
+
}
|
|
1375
|
+
return target;
|
|
1376
|
+
} catch (err) {
|
|
1377
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1378
|
+
pluginRegistry.notify({ body: `Failed to open ${rawSymbol}: ${message}`, type: "error" });
|
|
1379
|
+
return null;
|
|
1380
|
+
}
|
|
1381
|
+
};
|
|
1382
|
+
const publishTickerOpenTarget = (target: TickerOpenTarget) => {
|
|
1383
|
+
const currentTicker = stateRef.current.tickers.get(target.symbol);
|
|
1384
|
+
if (currentTicker !== target.ticker) {
|
|
1385
|
+
dispatch({ type: "UPDATE_TICKER", ticker: target.ticker });
|
|
1386
|
+
}
|
|
1387
|
+
if (target.created) {
|
|
1388
|
+
pluginRegistry.events.emit("ticker:added", { symbol: target.symbol, ticker: target.ticker });
|
|
1389
|
+
}
|
|
1390
|
+
};
|
|
1391
|
+
const placePinnedTickerTarget = (target: TickerOpenTarget, options?: PinTickerOptions) => {
|
|
1364
1392
|
const paneType = options?.paneType ?? "ticker-detail";
|
|
1365
1393
|
const paneDef = pluginRegistry.panes.get(paneType);
|
|
1366
1394
|
if (!paneDef) return;
|
|
1395
|
+
|
|
1396
|
+
publishTickerOpenTarget(target);
|
|
1397
|
+
const symbol = target.symbol;
|
|
1398
|
+
const currentState = stateRef.current;
|
|
1399
|
+
const currentLayout = currentState.config.layout;
|
|
1367
1400
|
const existing = options?.forceNewPane
|
|
1368
1401
|
? null
|
|
1369
|
-
: findFixedTickerPaneForSymbol(
|
|
1402
|
+
: findFixedTickerPaneForSymbol(currentLayout, paneType, symbol);
|
|
1370
1403
|
if (existing) {
|
|
1371
1404
|
focusVisiblePane(existing.instanceId);
|
|
1372
1405
|
return;
|
|
@@ -1379,45 +1412,38 @@ function AppInner({
|
|
|
1379
1412
|
const { width, height } = pluginRegistry.getTermSizeFn();
|
|
1380
1413
|
const shouldFloat = options?.floating ?? true;
|
|
1381
1414
|
const nextLayout = shouldFloat
|
|
1382
|
-
? addPaneFloating(
|
|
1415
|
+
? addPaneFloating(currentLayout, instance, width, height, paneDef)
|
|
1383
1416
|
: addPaneToLayout(
|
|
1384
|
-
|
|
1417
|
+
currentLayout,
|
|
1385
1418
|
instance,
|
|
1386
1419
|
{
|
|
1387
|
-
relativeTo:
|
|
1388
|
-
?
|
|
1389
|
-
: (getDockedPaneIds(
|
|
1420
|
+
relativeTo: currentState.focusedPaneId && isPaneInLayout(currentLayout, currentState.focusedPaneId)
|
|
1421
|
+
? currentState.focusedPaneId
|
|
1422
|
+
: (getDockedPaneIds(currentLayout).at(-1) ?? instance.instanceId),
|
|
1390
1423
|
position: "right",
|
|
1391
1424
|
},
|
|
1392
1425
|
);
|
|
1393
1426
|
persistLayout(nextLayout);
|
|
1394
1427
|
activatePane(instance.instanceId, nextLayout);
|
|
1395
1428
|
};
|
|
1429
|
+
const openPinnedTicker = async (rawSymbol: string, options?: PinTickerOptions) => {
|
|
1430
|
+
const target = await resolveOpenTickerTarget(rawSymbol);
|
|
1431
|
+
if (!target) return;
|
|
1432
|
+
placePinnedTickerTarget(target, options);
|
|
1433
|
+
};
|
|
1434
|
+
pluginRegistry.pinTickerFn = (symbol, options) => {
|
|
1435
|
+
if (isDetachedWindow) return;
|
|
1436
|
+
void openPinnedTicker(symbol, options);
|
|
1437
|
+
};
|
|
1396
1438
|
|
|
1397
1439
|
pluginRegistry.navigateTickerFn = (rawSymbol, options) => {
|
|
1398
1440
|
if (isDetachedWindow) return;
|
|
1399
1441
|
const sourcePaneId = options?.sourcePaneId ?? stateRef.current.focusedPaneId;
|
|
1400
1442
|
(async () => {
|
|
1401
1443
|
try {
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
activeTicker: null,
|
|
1406
|
-
tickers: stateRef.current.tickers,
|
|
1407
|
-
dataProvider,
|
|
1408
|
-
});
|
|
1409
|
-
|
|
1410
|
-
let symbol = rawSymbol;
|
|
1411
|
-
if (resolved?.kind === "local") {
|
|
1412
|
-
symbol = resolved.symbol;
|
|
1413
|
-
} else if (resolved?.kind === "provider" && resolved.result) {
|
|
1414
|
-
const { ticker, created } = await upsertTickerFromSearchResult(tickerRepository, resolved.result);
|
|
1415
|
-
symbol = ticker.metadata.ticker;
|
|
1416
|
-
dispatch({ type: "UPDATE_TICKER", ticker });
|
|
1417
|
-
if (created) {
|
|
1418
|
-
pluginRegistry.events.emit("ticker:added", { symbol, ticker });
|
|
1419
|
-
}
|
|
1420
|
-
}
|
|
1444
|
+
const target = await resolveOpenTickerTarget(rawSymbol);
|
|
1445
|
+
if (!target) return;
|
|
1446
|
+
const symbol = target.symbol;
|
|
1421
1447
|
|
|
1422
1448
|
// Only actions that originate from a ticker detail replace that exact pane.
|
|
1423
1449
|
// Everything else opens or focuses a fixed ticker detail for the symbol.
|
|
@@ -1436,6 +1462,7 @@ function AppInner({
|
|
|
1436
1462
|
};
|
|
1437
1463
|
|
|
1438
1464
|
if (detailPane) {
|
|
1465
|
+
publishTickerOpenTarget(target);
|
|
1439
1466
|
const nextLayout = {
|
|
1440
1467
|
...currentLayout,
|
|
1441
1468
|
instances: currentLayout.instances.map((instance) => (
|
|
@@ -1451,7 +1478,7 @@ function AppInner({
|
|
|
1451
1478
|
currentFocusedPaneId: stateRef.current.focusedPaneId,
|
|
1452
1479
|
targetPaneId: null,
|
|
1453
1480
|
})) {
|
|
1454
|
-
|
|
1481
|
+
placePinnedTickerTarget(target, { floating: false });
|
|
1455
1482
|
}
|
|
1456
1483
|
} catch (err) {
|
|
1457
1484
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -1618,6 +1645,7 @@ function AppInner({
|
|
|
1618
1645
|
pluginRegistry={pluginRegistry}
|
|
1619
1646
|
desktopWindowBridge={desktopWindowBridge}
|
|
1620
1647
|
desktopDockPreview={desktopDockPreview}
|
|
1648
|
+
commandBarNativeOccluder={commandBarNativeOccluder}
|
|
1621
1649
|
/>
|
|
1622
1650
|
<StatusBar />
|
|
1623
1651
|
{state.commandBarOpen && (
|
|
@@ -1627,6 +1655,7 @@ function AppInner({
|
|
|
1627
1655
|
pluginRegistry={pluginRegistry}
|
|
1628
1656
|
quitApp={() => rendererHost.requestExit()}
|
|
1629
1657
|
onCheckForUpdates={() => runUpdateCheck(true)}
|
|
1658
|
+
onNativeOccluderChange={setCommandBarNativeOccluder}
|
|
1630
1659
|
/>
|
|
1631
1660
|
)}
|
|
1632
1661
|
<ToastViewport position="bottom-right" />
|
|
@@ -73,8 +73,10 @@ import {
|
|
|
73
73
|
movePaneRelative,
|
|
74
74
|
removePane,
|
|
75
75
|
swapPanes,
|
|
76
|
+
type LayoutBounds,
|
|
76
77
|
} from "../../plugins/pane-manager";
|
|
77
78
|
import { notifyGridlockComplete } from "../../plugins/gridlock-notification";
|
|
79
|
+
import { resolveAppHeaderHeightCells } from "../layout/shell";
|
|
78
80
|
import { CHART_RENDERER_PREFERENCES } from "../chart/chart-types";
|
|
79
81
|
import {
|
|
80
82
|
getEmptyState,
|
|
@@ -168,6 +170,7 @@ interface CommandBarProps {
|
|
|
168
170
|
pluginRegistry: PluginRegistry;
|
|
169
171
|
quitApp: () => void;
|
|
170
172
|
onCheckForUpdates?: () => void | Promise<void>;
|
|
173
|
+
onNativeOccluderChange?: (rect: LayoutBounds | null) => void;
|
|
171
174
|
}
|
|
172
175
|
|
|
173
176
|
type WorkflowStringValues = Record<string, string>;
|
|
@@ -645,6 +648,7 @@ export function CommandBar({
|
|
|
645
648
|
pluginRegistry,
|
|
646
649
|
quitApp,
|
|
647
650
|
onCheckForUpdates,
|
|
651
|
+
onNativeOccluderChange,
|
|
648
652
|
}: CommandBarProps) {
|
|
649
653
|
const dispatch = useAppDispatch();
|
|
650
654
|
const themeColors = useThemeColors();
|
|
@@ -709,7 +713,7 @@ export function CommandBar({
|
|
|
709
713
|
}, []);
|
|
710
714
|
const { symbol: activeTickerSymbol, ticker: activeTickerData, financials: activeFinancials } = useFocusedTicker();
|
|
711
715
|
const { width: termWidth, height: termHeight } = useViewport();
|
|
712
|
-
const { nativePaneChrome, cellWidthPx = 8, cellHeightPx = 18 } = useUiCapabilities();
|
|
716
|
+
const { nativePaneChrome, cellWidthPx = 8, cellHeightPx = 18, titleBarOverlay } = useUiCapabilities();
|
|
713
717
|
const availableCommands = useMemo(
|
|
714
718
|
() => nativePaneChrome
|
|
715
719
|
? commands.filter((command) => command.id !== "cycle-chart-renderer")
|
|
@@ -5390,6 +5394,13 @@ export function CommandBar({
|
|
|
5390
5394
|
: bodyHeight + 7;
|
|
5391
5395
|
const barLeft = Math.max(4, Math.floor((termWidth - barWidth) / 2));
|
|
5392
5396
|
const barTop = Math.max(1, Math.floor((termHeight - barHeight) / 2));
|
|
5397
|
+
const appHeaderHeight = resolveAppHeaderHeightCells({ titleBarOverlay, cellHeightPx });
|
|
5398
|
+
const nativeOccluderRect = useMemo<LayoutBounds>(() => ({
|
|
5399
|
+
x: barLeft,
|
|
5400
|
+
y: barTop - appHeaderHeight,
|
|
5401
|
+
width: barWidth,
|
|
5402
|
+
height: barHeight,
|
|
5403
|
+
}), [appHeaderHeight, barHeight, barLeft, barTop, barWidth]);
|
|
5393
5404
|
const resultsInnerWidth = Math.max(12, barWidth - nativePanelPaddingColumns - contentPadding * 2);
|
|
5394
5405
|
const trailingWidth = Math.max(8, Math.min(12, Math.floor(resultsInnerWidth * 0.18)));
|
|
5395
5406
|
const labelWidth = Math.max(10, resultsInnerWidth - trailingWidth);
|
|
@@ -5419,6 +5430,13 @@ export function CommandBar({
|
|
|
5419
5430
|
}
|
|
5420
5431
|
}, [listBodyHeight, selectedScrollRowIndex, visibleListState?.kind, visibleListState?.query]);
|
|
5421
5432
|
|
|
5433
|
+
useLayoutEffect(() => {
|
|
5434
|
+
onNativeOccluderChange?.(nativeOccluderRect);
|
|
5435
|
+
return () => {
|
|
5436
|
+
onNativeOccluderChange?.(null);
|
|
5437
|
+
};
|
|
5438
|
+
}, [nativeOccluderRect, onNativeOccluderChange]);
|
|
5439
|
+
|
|
5422
5440
|
useEffect(() => {
|
|
5423
5441
|
if (!nativePaneChrome || currentRoute?.kind !== "workflow") return;
|
|
5424
5442
|
const scrollBox = workflowScrollRef.current;
|
|
@@ -560,6 +560,32 @@ describe("Shell", () => {
|
|
|
560
560
|
]);
|
|
561
561
|
});
|
|
562
562
|
|
|
563
|
+
test("keeps command bar native occlusion scoped to the panel", () => {
|
|
564
|
+
const state = buildNativeWindowState(
|
|
565
|
+
["portfolio-list:main"],
|
|
566
|
+
[],
|
|
567
|
+
null,
|
|
568
|
+
{ open: false, width: 120, contentHeight: 40 },
|
|
569
|
+
[
|
|
570
|
+
{
|
|
571
|
+
id: "command-bar:panel",
|
|
572
|
+
rect: { x: 24, y: 8, width: 72, height: 14 },
|
|
573
|
+
zIndex: Number.MAX_SAFE_INTEGER,
|
|
574
|
+
},
|
|
575
|
+
],
|
|
576
|
+
);
|
|
577
|
+
|
|
578
|
+
expect(state.occluders).toEqual([
|
|
579
|
+
{
|
|
580
|
+
id: "command-bar:panel",
|
|
581
|
+
paneId: null,
|
|
582
|
+
rect: { x: 24, y: 9, width: 72, height: 14 },
|
|
583
|
+
zIndex: Number.MAX_SAFE_INTEGER,
|
|
584
|
+
},
|
|
585
|
+
]);
|
|
586
|
+
expect(state.occluders.some((occluder) => occluder.id === "overlay:global")).toBe(false);
|
|
587
|
+
});
|
|
588
|
+
|
|
563
589
|
test("adds dock dividers as global native occluders", () => {
|
|
564
590
|
const state = buildNativeWindowState(
|
|
565
591
|
["left:main", "right:main"],
|
|
@@ -76,6 +76,7 @@ interface ShellProps {
|
|
|
76
76
|
pluginRegistry: PluginRegistry;
|
|
77
77
|
desktopWindowBridge?: DesktopWindowBridge;
|
|
78
78
|
desktopDockPreview?: DesktopDockPreviewState | null;
|
|
79
|
+
commandBarNativeOccluder?: LayoutBounds | null;
|
|
79
80
|
}
|
|
80
81
|
|
|
81
82
|
interface HoverOverlay {
|
|
@@ -192,6 +193,7 @@ const DEFAULT_HEADER_HEIGHT = 1;
|
|
|
192
193
|
const MENU_MIN_WIDTH = 18;
|
|
193
194
|
const MENU_MAX_WIDTH = 44;
|
|
194
195
|
const MENU_Z_INDEX = 10_000;
|
|
196
|
+
const DOCK_DIVIDER_SIZE = 1;
|
|
195
197
|
const PANE_DRAG_THRESHOLD = 2;
|
|
196
198
|
const PRECISE_PANE_DRAG_THRESHOLD = 0.15;
|
|
197
199
|
const PANE_MANAGEMENT_ACCELERATORS = {
|
|
@@ -498,6 +500,39 @@ export function resolveNativeDockDividers(
|
|
|
498
500
|
));
|
|
499
501
|
}
|
|
500
502
|
|
|
503
|
+
function resolveDividerPreviewRect(
|
|
504
|
+
axis: "horizontal" | "vertical",
|
|
505
|
+
bounds: LayoutBounds,
|
|
506
|
+
ratio: number,
|
|
507
|
+
nativePaneChrome: boolean,
|
|
508
|
+
): LayoutBounds {
|
|
509
|
+
if (axis === "horizontal") {
|
|
510
|
+
const offset = nativePaneChrome
|
|
511
|
+
? bounds.width * ratio
|
|
512
|
+
: bounds.width > DOCK_DIVIDER_SIZE
|
|
513
|
+
? Math.round((bounds.width - DOCK_DIVIDER_SIZE) * ratio)
|
|
514
|
+
: Math.max(0, Math.round(bounds.width * ratio) - DOCK_DIVIDER_SIZE);
|
|
515
|
+
return {
|
|
516
|
+
x: nativePaneChrome ? bounds.x + offset - (DOCK_DIVIDER_SIZE / 2) : bounds.x + offset,
|
|
517
|
+
y: bounds.y,
|
|
518
|
+
width: DOCK_DIVIDER_SIZE,
|
|
519
|
+
height: bounds.height,
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const offset = nativePaneChrome
|
|
524
|
+
? bounds.height * ratio
|
|
525
|
+
: bounds.height > DOCK_DIVIDER_SIZE
|
|
526
|
+
? Math.round((bounds.height - DOCK_DIVIDER_SIZE) * ratio)
|
|
527
|
+
: Math.max(0, Math.round(bounds.height * ratio) - DOCK_DIVIDER_SIZE);
|
|
528
|
+
return {
|
|
529
|
+
x: bounds.x,
|
|
530
|
+
y: nativePaneChrome ? bounds.y + offset - (DOCK_DIVIDER_SIZE / 2) : bounds.y + offset,
|
|
531
|
+
width: bounds.width,
|
|
532
|
+
height: DOCK_DIVIDER_SIZE,
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
|
|
501
536
|
export function finalizePaneDragRelease(
|
|
502
537
|
layout: LayoutConfig,
|
|
503
538
|
paneId: string,
|
|
@@ -789,7 +824,12 @@ function resolveExternalDockPreview(
|
|
|
789
824
|
}
|
|
790
825
|
}
|
|
791
826
|
|
|
792
|
-
export function Shell({
|
|
827
|
+
export function Shell({
|
|
828
|
+
pluginRegistry,
|
|
829
|
+
desktopWindowBridge,
|
|
830
|
+
desktopDockPreview,
|
|
831
|
+
commandBarNativeOccluder = null,
|
|
832
|
+
}: ShellProps) {
|
|
793
833
|
useThemeColors();
|
|
794
834
|
const dispatch = useAppDispatch();
|
|
795
835
|
const config = useAppSelector((state) => state.config);
|
|
@@ -1047,7 +1087,9 @@ export function Shell({ pluginRegistry, desktopWindowBridge, desktopDockPreview
|
|
|
1047
1087
|
event.stopPropagation();
|
|
1048
1088
|
});
|
|
1049
1089
|
|
|
1050
|
-
const dockGeometryOptions = useMemo(() => (
|
|
1090
|
+
const dockGeometryOptions = useMemo(() => (
|
|
1091
|
+
nativePaneChrome ? { precise: true } : { reserveDividerGutters: true }
|
|
1092
|
+
), [nativePaneChrome]);
|
|
1051
1093
|
const dockLeafLayouts = useMemo(() => getDockLeafLayouts(visibleLayout, bounds, dockGeometryOptions), [bounds, dockGeometryOptions, visibleLayout]);
|
|
1052
1094
|
const dockDividerLayouts = useMemo(() => getDockDividerLayouts(visibleLayout, bounds, dockGeometryOptions), [bounds, dockGeometryOptions, visibleLayout]);
|
|
1053
1095
|
const snapGuides = useMemo(() => makeSnapGuides(width, contentHeight), [contentHeight, width]);
|
|
@@ -1092,8 +1134,29 @@ export function Shell({ pluginRegistry, desktopWindowBridge, desktopDockPreview
|
|
|
1092
1134
|
});
|
|
1093
1135
|
}
|
|
1094
1136
|
|
|
1137
|
+
if (menuState) {
|
|
1138
|
+
occluders.push({
|
|
1139
|
+
id: `pane-menu:${menuState.paneId}`,
|
|
1140
|
+
rect: {
|
|
1141
|
+
x: menuState.x,
|
|
1142
|
+
y: menuState.y,
|
|
1143
|
+
width: menuState.width,
|
|
1144
|
+
height: menuState.items.length + 2,
|
|
1145
|
+
},
|
|
1146
|
+
zIndex: MENU_Z_INDEX,
|
|
1147
|
+
});
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
if (commandBarNativeOccluder) {
|
|
1151
|
+
occluders.push({
|
|
1152
|
+
id: "command-bar:panel",
|
|
1153
|
+
rect: commandBarNativeOccluder,
|
|
1154
|
+
zIndex: Number.MAX_SAFE_INTEGER,
|
|
1155
|
+
});
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1095
1158
|
return occluders;
|
|
1096
|
-
}, [activeHoverOverlay, activePaneDrag, dragFloatingRect, effectiveDockPreview]);
|
|
1159
|
+
}, [activeHoverOverlay, activePaneDrag, commandBarNativeOccluder, dragFloatingRect, effectiveDockPreview, menuState]);
|
|
1097
1160
|
const nativeDockDividers = useMemo(
|
|
1098
1161
|
() => resolveNativeDockDividers(dockDividerLayouts, dividerPreview),
|
|
1099
1162
|
[dividerPreview, dockDividerLayouts],
|
|
@@ -1107,12 +1170,12 @@ export function Shell({ pluginRegistry, desktopWindowBridge, desktopDockPreview
|
|
|
1107
1170
|
zIndex: pane.floating?.zIndex ?? 50,
|
|
1108
1171
|
})),
|
|
1109
1172
|
dragFloatingRect,
|
|
1110
|
-
{ open:
|
|
1173
|
+
{ open: dialogOpen, width, contentHeight },
|
|
1111
1174
|
nativeTransientOccluders,
|
|
1112
1175
|
nativeDockDividers,
|
|
1113
1176
|
appHeaderHeight,
|
|
1114
1177
|
),
|
|
1115
|
-
[appHeaderHeight, contentHeight, dockedPanes, dragFloatingRect, nativeDockDividers, nativeTransientOccluders,
|
|
1178
|
+
[appHeaderHeight, contentHeight, dialogOpen, dockedPanes, dragFloatingRect, nativeDockDividers, nativeTransientOccluders, visibleFloatingPanes, width],
|
|
1116
1179
|
);
|
|
1117
1180
|
|
|
1118
1181
|
useEffect(() => {
|
|
@@ -1194,22 +1257,7 @@ export function Shell({ pluginRegistry, desktopWindowBridge, desktopDockPreview
|
|
|
1194
1257
|
const total = drag.axis === "horizontal" ? drag.bounds.width : drag.bounds.height;
|
|
1195
1258
|
const delta = drag.axis === "horizontal" ? preciseX - drag.startX : preciseShellY - drag.startY;
|
|
1196
1259
|
const nextRatio = Math.max(0.1, Math.min(0.9, drag.startRatio + (delta / Math.max(1, total))));
|
|
1197
|
-
const
|
|
1198
|
-
? drag.bounds.width * nextRatio
|
|
1199
|
-
: drag.bounds.height * nextRatio;
|
|
1200
|
-
const nextRect = drag.axis === "horizontal"
|
|
1201
|
-
? {
|
|
1202
|
-
x: nativePaneChrome ? drag.bounds.x + offset - 0.5 : drag.bounds.x + Math.round(offset) - 1,
|
|
1203
|
-
y: drag.bounds.y,
|
|
1204
|
-
width: 1,
|
|
1205
|
-
height: drag.bounds.height,
|
|
1206
|
-
}
|
|
1207
|
-
: {
|
|
1208
|
-
x: drag.bounds.x,
|
|
1209
|
-
y: nativePaneChrome ? drag.bounds.y + offset - 0.5 : drag.bounds.y + Math.round(offset) - 1,
|
|
1210
|
-
width: drag.bounds.width,
|
|
1211
|
-
height: 1,
|
|
1212
|
-
};
|
|
1260
|
+
const nextRect = resolveDividerPreviewRect(drag.axis, drag.bounds, nextRatio, nativePaneChrome);
|
|
1213
1261
|
updateDividerPreview({ pathKey: drag.path.join("."), rect: nextRect, ratio: nextRatio });
|
|
1214
1262
|
} else if (drag.type === "pane-drag") {
|
|
1215
1263
|
if (!isMeaningfulPaneDrag(drag.startX, drag.startY, preciseX, preciseShellY, dragThreshold)) {
|
|
@@ -1,12 +1,22 @@
|
|
|
1
|
-
import { expect, test } from "bun:test";
|
|
1
|
+
import { afterEach, expect, test } from "bun:test";
|
|
2
2
|
import {
|
|
3
3
|
buildTreemap,
|
|
4
4
|
buildTreemapRects,
|
|
5
5
|
formatHolderOwnershipPercent,
|
|
6
6
|
resolveHolderOwnershipPercent,
|
|
7
|
+
tileTextColor,
|
|
7
8
|
} from "./index";
|
|
9
|
+
import { applyTheme, colors } from "../../../theme/colors";
|
|
10
|
+
import { contrastRatio } from "../../../theme/color-utils";
|
|
11
|
+
import { DEFAULT_THEME, themes } from "../../../theme/themes";
|
|
8
12
|
import type { HolderRecord } from "../../../types/financials";
|
|
9
13
|
|
|
14
|
+
const TILE_TEXT_MIN_CONTRAST = 4.5;
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
applyTheme(DEFAULT_THEME);
|
|
18
|
+
});
|
|
19
|
+
|
|
10
20
|
function holder(name: string, value: number): HolderRecord & { id: string } {
|
|
11
21
|
return {
|
|
12
22
|
id: name,
|
|
@@ -55,3 +65,22 @@ test("resolves holder ownership from provider percent before value over market c
|
|
|
55
65
|
expect(resolveHolderOwnershipPercent({ value: 120 }, undefined)).toBeUndefined();
|
|
56
66
|
expect(formatHolderOwnershipPercent(0.085)).toBe("8.50%");
|
|
57
67
|
});
|
|
68
|
+
|
|
69
|
+
test("keeps holder treemap text readable on semantic tile backgrounds", () => {
|
|
70
|
+
for (const themeId of Object.keys(themes)) {
|
|
71
|
+
applyTheme(themeId);
|
|
72
|
+
const backgrounds = {
|
|
73
|
+
positive: colors.positive,
|
|
74
|
+
negative: colors.negative,
|
|
75
|
+
neutral: colors.neutral,
|
|
76
|
+
selected: colors.selected,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
for (const [name, background] of Object.entries(backgrounds)) {
|
|
80
|
+
expect(
|
|
81
|
+
contrastRatio(tileTextColor(background), background),
|
|
82
|
+
`${themeId} ${name}`,
|
|
83
|
+
).toBeGreaterThanOrEqual(TILE_TEXT_MIN_CONTRAST);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
});
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
} from "../../../components";
|
|
12
12
|
import { useShortcut } from "../../../react/input";
|
|
13
13
|
import { blendHex, colors, priceColor } from "../../../theme/colors";
|
|
14
|
-
import { higherContrast } from "../../../theme/color-utils";
|
|
14
|
+
import { blendForContrast, higherContrast } from "../../../theme/color-utils";
|
|
15
15
|
import type { HolderData, HolderRecord } from "../../../types/financials";
|
|
16
16
|
import type { GloomPlugin } from "../../../types/plugin";
|
|
17
17
|
import { formatCompact, formatPercent, formatPercentRaw, padTo } from "../../../utils/format";
|
|
@@ -447,12 +447,16 @@ function desktopTileColor(row: HolderRow): string {
|
|
|
447
447
|
return blendHex(colors.panel, row.changePercent > 0 ? colors.positive : colors.negative, intensity);
|
|
448
448
|
}
|
|
449
449
|
|
|
450
|
-
|
|
451
|
-
|
|
450
|
+
const TILE_TEXT_MIN_CONTRAST = 4.5;
|
|
451
|
+
|
|
452
|
+
export function tileTextColor(backgroundColor: string): string {
|
|
453
|
+
const preferred = higherContrast(
|
|
452
454
|
higherContrast(colors.text, colors.textBright, backgroundColor),
|
|
453
455
|
colors.selectedText,
|
|
454
456
|
backgroundColor,
|
|
455
457
|
);
|
|
458
|
+
const fallback = higherContrast("#000000", "#ffffff", backgroundColor);
|
|
459
|
+
return blendForContrast(preferred, backgroundColor, fallback, TILE_TEXT_MIN_CONTRAST);
|
|
456
460
|
}
|
|
457
461
|
|
|
458
462
|
function Tile({ tile, selected, currency, marketCap, onSelect }: {
|
|
@@ -5,6 +5,8 @@ import {
|
|
|
5
5
|
applyDrop,
|
|
6
6
|
floatAtRect,
|
|
7
7
|
gridlockAllPanes,
|
|
8
|
+
getDockDividerLayouts,
|
|
9
|
+
getDockLeafLayouts,
|
|
8
10
|
getDockedPaneIds,
|
|
9
11
|
getLeafRect,
|
|
10
12
|
simulateDrop,
|
|
@@ -44,6 +46,35 @@ describe("pane-manager split-tree drops", () => {
|
|
|
44
46
|
expect(getDockedPaneIds(next)).toContain(notesPane.instanceId);
|
|
45
47
|
});
|
|
46
48
|
|
|
49
|
+
test("can reserve a gutter for dock dividers without overlapping pane rects", () => {
|
|
50
|
+
const layout = {
|
|
51
|
+
dockRoot: {
|
|
52
|
+
kind: "split" as const,
|
|
53
|
+
axis: "horizontal" as const,
|
|
54
|
+
ratio: 0.5,
|
|
55
|
+
first: { kind: "pane" as const, instanceId: "left:main" },
|
|
56
|
+
second: { kind: "pane" as const, instanceId: "right:main" },
|
|
57
|
+
},
|
|
58
|
+
instances: [
|
|
59
|
+
createPaneInstance("left", { instanceId: "left:main" }),
|
|
60
|
+
createPaneInstance("right", { instanceId: "right:main" }),
|
|
61
|
+
],
|
|
62
|
+
floating: [],
|
|
63
|
+
detached: [],
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const leaves = getDockLeafLayouts(layout, BOUNDS, { reserveDividerGutters: true });
|
|
67
|
+
const divider = getDockDividerLayouts(layout, BOUNDS, { reserveDividerGutters: true })[0];
|
|
68
|
+
const left = leaves.find((leaf) => leaf.instanceId === "left:main");
|
|
69
|
+
const right = leaves.find((leaf) => leaf.instanceId === "right:main");
|
|
70
|
+
|
|
71
|
+
expect(left).toBeDefined();
|
|
72
|
+
expect(right).toBeDefined();
|
|
73
|
+
expect(divider).toBeDefined();
|
|
74
|
+
expect(divider!.rect.x).toBe(left!.rect.x + left!.rect.width);
|
|
75
|
+
expect(right!.rect.x).toBe(divider!.rect.x + divider!.rect.width);
|
|
76
|
+
});
|
|
77
|
+
|
|
47
78
|
test("splits the hovered pane on leaf drops and matches the preview", () => {
|
|
48
79
|
const config = createDefaultConfig("/tmp/gloomberb-test");
|
|
49
80
|
const notesPane = createPaneInstance("chat");
|
|
@@ -84,6 +84,7 @@ export interface DockDividerLayout {
|
|
|
84
84
|
export interface DockGeometryOptions {
|
|
85
85
|
precise?: boolean;
|
|
86
86
|
dividerSize?: number;
|
|
87
|
+
reserveDividerGutters?: boolean;
|
|
87
88
|
}
|
|
88
89
|
|
|
89
90
|
export interface LayoutSimulation {
|
|
@@ -430,18 +431,31 @@ function collectDockGeometry(
|
|
|
430
431
|
|
|
431
432
|
const precise = options.precise === true;
|
|
432
433
|
const dividerSize = options.dividerSize ?? 1;
|
|
434
|
+
const reserveDividerGutters = options.reserveDividerGutters === true && !precise;
|
|
433
435
|
|
|
434
436
|
if (node.axis === "horizontal") {
|
|
435
|
-
const
|
|
437
|
+
const reserveDividerGutter = reserveDividerGutters && bounds.width > dividerSize;
|
|
438
|
+
const splitWidth = reserveDividerGutter ? bounds.width - dividerSize : bounds.width;
|
|
439
|
+
const [firstWidth, secondWidth] = resolveSplitSizes(splitWidth, node.ratio, MIN_PANE_WIDTH, precise);
|
|
436
440
|
const firstBounds = { x: bounds.x, y: bounds.y, width: firstWidth, height: bounds.height };
|
|
437
|
-
const
|
|
441
|
+
const dividerX = reserveDividerGutter
|
|
442
|
+
? bounds.x + firstWidth
|
|
443
|
+
: precise
|
|
444
|
+
? bounds.x + firstWidth - (dividerSize / 2)
|
|
445
|
+
: bounds.x + firstWidth - 1;
|
|
446
|
+
const secondBounds = {
|
|
447
|
+
x: reserveDividerGutter ? bounds.x + firstWidth + dividerSize : bounds.x + firstWidth,
|
|
448
|
+
y: bounds.y,
|
|
449
|
+
width: secondWidth,
|
|
450
|
+
height: bounds.height,
|
|
451
|
+
};
|
|
438
452
|
dividers.push({
|
|
439
453
|
path,
|
|
440
454
|
axis: node.axis,
|
|
441
455
|
bounds: { ...bounds },
|
|
442
456
|
ratio: node.ratio,
|
|
443
457
|
rect: {
|
|
444
|
-
x:
|
|
458
|
+
x: dividerX,
|
|
445
459
|
y: bounds.y,
|
|
446
460
|
width: dividerSize,
|
|
447
461
|
height: bounds.height,
|
|
@@ -452,9 +466,21 @@ function collectDockGeometry(
|
|
|
452
466
|
return { leaves, dividers };
|
|
453
467
|
}
|
|
454
468
|
|
|
455
|
-
const
|
|
469
|
+
const reserveDividerGutter = reserveDividerGutters && bounds.height > dividerSize;
|
|
470
|
+
const splitHeight = reserveDividerGutter ? bounds.height - dividerSize : bounds.height;
|
|
471
|
+
const [firstHeight, secondHeight] = resolveSplitSizes(splitHeight, node.ratio, MIN_DOCKED_HEIGHT, precise);
|
|
456
472
|
const firstBounds = { x: bounds.x, y: bounds.y, width: bounds.width, height: firstHeight };
|
|
457
|
-
const
|
|
473
|
+
const dividerY = reserveDividerGutter
|
|
474
|
+
? bounds.y + firstHeight
|
|
475
|
+
: precise
|
|
476
|
+
? bounds.y + firstHeight - (dividerSize / 2)
|
|
477
|
+
: bounds.y + firstHeight - 1;
|
|
478
|
+
const secondBounds = {
|
|
479
|
+
x: bounds.x,
|
|
480
|
+
y: reserveDividerGutter ? bounds.y + firstHeight + dividerSize : bounds.y + firstHeight,
|
|
481
|
+
width: bounds.width,
|
|
482
|
+
height: secondHeight,
|
|
483
|
+
};
|
|
458
484
|
dividers.push({
|
|
459
485
|
path,
|
|
460
486
|
axis: node.axis,
|
|
@@ -462,7 +488,7 @@ function collectDockGeometry(
|
|
|
462
488
|
ratio: node.ratio,
|
|
463
489
|
rect: {
|
|
464
490
|
x: bounds.x,
|
|
465
|
-
y:
|
|
491
|
+
y: dividerY,
|
|
466
492
|
width: bounds.width,
|
|
467
493
|
height: dividerSize,
|
|
468
494
|
},
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync, mkdirSync } from "fs";
|
|
2
|
-
import { join } from "path";
|
|
2
|
+
import { dirname, join } from "path";
|
|
3
3
|
import { Buffer } from "node:buffer";
|
|
4
4
|
import Electrobun, { ApplicationMenu, BrowserView, BrowserWindow, Utils, ContextMenu, type UpdateStatusEntry } from "electrobun/bun";
|
|
5
5
|
import {
|
|
@@ -17,7 +17,7 @@ import type { DesktopDockPreviewState, DesktopSharedStateSnapshot, DesktopThemeP
|
|
|
17
17
|
import type { ReleaseInfo, UpdateCheckResult, UpdateProgress } from "../../../updater";
|
|
18
18
|
import { buildSoundCommand } from "../../../notifications/app-notifier";
|
|
19
19
|
import { isPaneDetached } from "../../../plugins/pane-manager";
|
|
20
|
-
import { ELECTROBUN_CONTEXT_MENU_ACTION, type ElectrobunDesktopRpcSchema } from "../shared/protocol";
|
|
20
|
+
import { ELECTROBUN_CONTEXT_MENU_ACTION, type DesktopRestartMessage, type ElectrobunDesktopRpcSchema } from "../shared/protocol";
|
|
21
21
|
import { decodeRpcValue, encodeRpcValue } from "../view/rpc-codec";
|
|
22
22
|
import { contextMenuSelectionMessage } from "./context-menu-click";
|
|
23
23
|
import { getContextMenuRequestId, normalizeContextMenuItems } from "./context-menu-normalize";
|
|
@@ -65,6 +65,7 @@ let desktopWorkspace: DesktopWorkspace | null = null;
|
|
|
65
65
|
let currentDockPreview: DesktopDockPreviewState = { paneId: null, edge: null };
|
|
66
66
|
let currentThemePreview: DesktopThemePreviewState = { theme: null };
|
|
67
67
|
let desktopUpdateInProgress = false;
|
|
68
|
+
let desktopRestartInProgress = false;
|
|
68
69
|
|
|
69
70
|
const detachedWindows = new Map<string, BrowserWindow>();
|
|
70
71
|
const detachedFrameTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
@@ -82,6 +83,19 @@ function summarizeError(error: unknown): string {
|
|
|
82
83
|
return error instanceof Error ? error.stack ?? error.message : String(error);
|
|
83
84
|
}
|
|
84
85
|
|
|
86
|
+
function shellQuote(value: string): string {
|
|
87
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function findMacAppBundle(execPath: string): string | null {
|
|
91
|
+
let current = dirname(execPath);
|
|
92
|
+
while (current && current !== dirname(current)) {
|
|
93
|
+
if (current.endsWith(".app")) return current;
|
|
94
|
+
current = dirname(current);
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
85
99
|
function requireServices(): AppServices {
|
|
86
100
|
if (!services) throw new Error("Backend services have not been initialized.");
|
|
87
101
|
return services;
|
|
@@ -633,6 +647,62 @@ function teardownServices(): void {
|
|
|
633
647
|
services = null;
|
|
634
648
|
}
|
|
635
649
|
|
|
650
|
+
function spawnDetached(command: string[]): void {
|
|
651
|
+
const child = Bun.spawn(command, {
|
|
652
|
+
detached: true,
|
|
653
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
654
|
+
} as any);
|
|
655
|
+
child.unref();
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function scheduleDesktopRelaunch(): void {
|
|
659
|
+
const pid = process.pid;
|
|
660
|
+
const macAppBundle = process.platform === "darwin" ? findMacAppBundle(process.execPath) : null;
|
|
661
|
+
|
|
662
|
+
if (macAppBundle) {
|
|
663
|
+
spawnDetached([
|
|
664
|
+
"sh",
|
|
665
|
+
"-c",
|
|
666
|
+
`while kill -0 ${pid} 2>/dev/null; do sleep 0.25; done; sleep 0.5; open ${shellQuote(macAppBundle)}`,
|
|
667
|
+
]);
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
if (process.platform === "win32") {
|
|
672
|
+
spawnDetached([process.execPath, ...process.argv.slice(1)]);
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const args = process.argv.slice(1).map(shellQuote).join(" ");
|
|
677
|
+
spawnDetached([
|
|
678
|
+
"sh",
|
|
679
|
+
"-c",
|
|
680
|
+
`while kill -0 ${pid} 2>/dev/null; do sleep 0.25; done; cd ${shellQuote(process.cwd())}; ${shellQuote(process.execPath)}${args ? ` ${args}` : ""}`,
|
|
681
|
+
]);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function restartDesktopApp(message: DesktopRestartMessage = {}): void {
|
|
685
|
+
if (desktopRestartInProgress) return;
|
|
686
|
+
desktopRestartInProgress = true;
|
|
687
|
+
console.error("[desktop-recovery] restart requested", {
|
|
688
|
+
reason: message.reason,
|
|
689
|
+
source: message.source,
|
|
690
|
+
pid: process.pid,
|
|
691
|
+
execPath: process.execPath,
|
|
692
|
+
argv: process.argv,
|
|
693
|
+
});
|
|
694
|
+
try {
|
|
695
|
+
scheduleDesktopRelaunch();
|
|
696
|
+
} catch (error) {
|
|
697
|
+
desktopRestartInProgress = false;
|
|
698
|
+
console.error("[desktop-recovery] failed to schedule restart", summarizeError(error));
|
|
699
|
+
throw error;
|
|
700
|
+
}
|
|
701
|
+
closeAllDetachedWindows();
|
|
702
|
+
teardownServices();
|
|
703
|
+
Utils.quit();
|
|
704
|
+
}
|
|
705
|
+
|
|
636
706
|
function resolveDetachedWindowTitle(instanceId: string): string {
|
|
637
707
|
const instance = currentConfig ? findPaneInstance(currentConfig.layout, instanceId) : null;
|
|
638
708
|
if (!instance) return "Gloomberb";
|
|
@@ -1087,6 +1157,12 @@ async function handleBackendRequest(
|
|
|
1087
1157
|
requireServices().persistence.pluginState.delete(payload.pluginId as string, payload.key as string);
|
|
1088
1158
|
syncBackendCloudAuthState(payload.pluginId as string, payload.key as string, null);
|
|
1089
1159
|
return null;
|
|
1160
|
+
case "host.restart":
|
|
1161
|
+
restartDesktopApp({
|
|
1162
|
+
reason: typeof payload.reason === "string" ? payload.reason : undefined,
|
|
1163
|
+
source: typeof payload.source === "string" ? payload.source : "backend-request",
|
|
1164
|
+
});
|
|
1165
|
+
return null;
|
|
1090
1166
|
case "host.exit":
|
|
1091
1167
|
closeAllDetachedWindows();
|
|
1092
1168
|
teardownServices();
|
|
@@ -1153,7 +1229,11 @@ function createWindowRpc(key: string): DesktopRpc {
|
|
|
1153
1229
|
requests: {
|
|
1154
1230
|
"backend.request": async ({ method, payload }) => encodeRpcValue(await handleBackendRequest(rpc, method, payload)),
|
|
1155
1231
|
},
|
|
1156
|
-
messages: {
|
|
1232
|
+
messages: {
|
|
1233
|
+
"host.restart": (message) => {
|
|
1234
|
+
restartDesktopApp(message);
|
|
1235
|
+
},
|
|
1236
|
+
},
|
|
1157
1237
|
},
|
|
1158
1238
|
});
|
|
1159
1239
|
registerWindowRpc(key, rpc);
|
|
@@ -53,6 +53,11 @@ export interface CapabilityEventMessage {
|
|
|
53
53
|
event: unknown;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
export interface DesktopRestartMessage {
|
|
57
|
+
reason?: string;
|
|
58
|
+
source?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
56
61
|
export interface ElectrobunDesktopRpcSchema {
|
|
57
62
|
bun: {
|
|
58
63
|
requests: {
|
|
@@ -61,7 +66,9 @@ export interface ElectrobunDesktopRpcSchema {
|
|
|
61
66
|
response: unknown;
|
|
62
67
|
};
|
|
63
68
|
};
|
|
64
|
-
messages: {
|
|
69
|
+
messages: {
|
|
70
|
+
"host.restart": DesktopRestartMessage;
|
|
71
|
+
};
|
|
65
72
|
};
|
|
66
73
|
webview: {
|
|
67
74
|
requests: {};
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
type CapabilityEventMessage,
|
|
6
6
|
type ContextMenuSelectMessage,
|
|
7
7
|
type DesktopDockPreviewMessage,
|
|
8
|
+
type DesktopRestartMessage,
|
|
8
9
|
type DesktopStateMessage,
|
|
9
10
|
type DesktopThemePreviewMessage,
|
|
10
11
|
type ElectrobunBackendInit,
|
|
@@ -131,6 +132,10 @@ export async function initElectrobunBackend(payload?: { kind?: "main" | "detache
|
|
|
131
132
|
return initSnapshot;
|
|
132
133
|
}
|
|
133
134
|
|
|
135
|
+
export function requestElectrobunRestart(message: DesktopRestartMessage = {}): void {
|
|
136
|
+
rpc.send["host.restart"](message);
|
|
137
|
+
}
|
|
138
|
+
|
|
134
139
|
export function getElectrobunBackendInitSnapshot(): ElectrobunBackendInit | null {
|
|
135
140
|
return initSnapshot;
|
|
136
141
|
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/** @jsxImportSource react */
|
|
3
|
+
import { Component, useMemo, useState, type ErrorInfo, type ReactNode } from "react";
|
|
4
|
+
import { backendRequest, requestElectrobunRestart } from "./backend-rpc";
|
|
5
|
+
|
|
6
|
+
declare global {
|
|
7
|
+
interface Window {
|
|
8
|
+
__gloomRenderFatalError?: (error: unknown, details?: string) => void;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface DesktopFatalScreenProps {
|
|
13
|
+
title?: string;
|
|
14
|
+
error: unknown;
|
|
15
|
+
details?: string;
|
|
16
|
+
source: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface ElectrobunErrorBoundaryState {
|
|
20
|
+
hasError: boolean;
|
|
21
|
+
error: unknown;
|
|
22
|
+
details?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function formatFatalError(error: unknown, details?: string): string {
|
|
26
|
+
const message = error instanceof Error ? error.stack ?? error.message : String(error);
|
|
27
|
+
return [message, details].filter((value): value is string => Boolean(value)).join("\n");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function DesktopFatalScreen({
|
|
31
|
+
title = "Gloomberb crashed",
|
|
32
|
+
error,
|
|
33
|
+
details,
|
|
34
|
+
source,
|
|
35
|
+
}: DesktopFatalScreenProps) {
|
|
36
|
+
const [status, setStatus] = useState<string | null>(null);
|
|
37
|
+
const errorText = useMemo(() => formatFatalError(error, details), [details, error]);
|
|
38
|
+
|
|
39
|
+
const reloadWindow = () => {
|
|
40
|
+
window.location.reload();
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const restartApp = () => {
|
|
44
|
+
setStatus("Restart requested...");
|
|
45
|
+
try {
|
|
46
|
+
requestElectrobunRestart({
|
|
47
|
+
source,
|
|
48
|
+
reason: errorText.slice(0, 240),
|
|
49
|
+
});
|
|
50
|
+
window.setTimeout(() => {
|
|
51
|
+
setStatus("If the app stays open, quit it and open it again.");
|
|
52
|
+
}, 2500);
|
|
53
|
+
} catch (restartError) {
|
|
54
|
+
setStatus(`Restart request failed: ${formatFatalError(restartError)}`);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const copyError = async () => {
|
|
59
|
+
try {
|
|
60
|
+
if (navigator.clipboard?.writeText) {
|
|
61
|
+
await navigator.clipboard.writeText(errorText);
|
|
62
|
+
} else {
|
|
63
|
+
await backendRequest("host.copyText", { text: errorText });
|
|
64
|
+
}
|
|
65
|
+
setStatus("Error copied.");
|
|
66
|
+
} catch (copyError) {
|
|
67
|
+
setStatus(`Copy failed: ${formatFatalError(copyError)}`);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div className="gloom-fatal">
|
|
73
|
+
<h1>{title}</h1>
|
|
74
|
+
<div className="gloom-fatal-actions">
|
|
75
|
+
<button type="button" data-variant="primary" onClick={restartApp}>Restart app</button>
|
|
76
|
+
<button type="button" onClick={reloadWindow}>Reload window</button>
|
|
77
|
+
<button type="button" onClick={copyError}>Copy error</button>
|
|
78
|
+
</div>
|
|
79
|
+
{status && <div className="gloom-fatal-status" aria-live="polite">{status}</div>}
|
|
80
|
+
<pre>{errorText}</pre>
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export class ElectrobunErrorBoundary extends Component<
|
|
86
|
+
{ children: ReactNode },
|
|
87
|
+
ElectrobunErrorBoundaryState
|
|
88
|
+
> {
|
|
89
|
+
override state: ElectrobunErrorBoundaryState = { hasError: false, error: null };
|
|
90
|
+
|
|
91
|
+
static getDerivedStateFromError(error: unknown): ElectrobunErrorBoundaryState {
|
|
92
|
+
return { hasError: true, error };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
override componentDidCatch(error: unknown, errorInfo: ErrorInfo): void {
|
|
96
|
+
console.error("[desktop-recovery] renderer error boundary", error, errorInfo.componentStack);
|
|
97
|
+
this.setState({
|
|
98
|
+
hasError: true,
|
|
99
|
+
error,
|
|
100
|
+
details: errorInfo.componentStack ?? undefined,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
override render(): ReactNode {
|
|
105
|
+
if (this.state.hasError) {
|
|
106
|
+
return (
|
|
107
|
+
<DesktopFatalScreen
|
|
108
|
+
title="Gloomberb crashed"
|
|
109
|
+
error={this.state.error}
|
|
110
|
+
details={this.state.details}
|
|
111
|
+
source="react-error-boundary"
|
|
112
|
+
/>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return this.props.children;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
installElectrobunHttpFetchTransport,
|
|
16
16
|
} from "./http-fetch";
|
|
17
17
|
import { installElectrobunUpdateHost } from "./update-host";
|
|
18
|
+
import { DesktopFatalScreen, ElectrobunErrorBoundary } from "./fatal-screen";
|
|
18
19
|
import { WebInputHostProvider } from "./input-host";
|
|
19
20
|
import { webNativeRenderer } from "./native-renderer";
|
|
20
21
|
import { WebToastHostProvider } from "./toast-host";
|
|
@@ -27,16 +28,32 @@ const rootElement = document.getElementById("root");
|
|
|
27
28
|
if (!rootElement) {
|
|
28
29
|
throw new Error("Missing root element");
|
|
29
30
|
}
|
|
31
|
+
const appRootElement = rootElement;
|
|
30
32
|
|
|
31
|
-
const root = createRoot(
|
|
33
|
+
const root = createRoot(appRootElement);
|
|
32
34
|
const bootLog = debugLog.createLogger("electrobun-web-boot");
|
|
33
35
|
|
|
34
|
-
|
|
36
|
+
appRootElement.tabIndex = -1;
|
|
35
37
|
root.render(<div className="gloom-loading">Starting Gloomberb...</div>);
|
|
36
38
|
|
|
39
|
+
function renderFatalError(error: unknown, details?: string, title = "Gloomberb failed to start"): void {
|
|
40
|
+
root.render(
|
|
41
|
+
<DesktopFatalScreen
|
|
42
|
+
title={title}
|
|
43
|
+
error={error}
|
|
44
|
+
details={details}
|
|
45
|
+
source="renderer-fatal"
|
|
46
|
+
/>,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
window.__gloomRenderFatalError = (error, details) => {
|
|
51
|
+
renderFatalError(error, details, "Gloomberb crashed");
|
|
52
|
+
};
|
|
53
|
+
|
|
37
54
|
function focusWebSurface(): void {
|
|
38
55
|
window.focus();
|
|
39
|
-
|
|
56
|
+
appRootElement.focus({ preventScroll: true });
|
|
40
57
|
}
|
|
41
58
|
|
|
42
59
|
function requestStartupFocus(): void {
|
|
@@ -69,21 +86,23 @@ async function boot() {
|
|
|
69
86
|
const desktopApplicationMenuBridge = createApplicationMenuBridge();
|
|
70
87
|
measurePerfAsync("startup.electrobun.root-render", async () => {
|
|
71
88
|
root.render(
|
|
72
|
-
<
|
|
73
|
-
<
|
|
74
|
-
<
|
|
75
|
-
<
|
|
76
|
-
<
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
89
|
+
<ElectrobunErrorBoundary>
|
|
90
|
+
<UiHostProvider ui={webUiHost} renderer={webRendererHost} nativeRenderer={webNativeRenderer}>
|
|
91
|
+
<WebInputHostProvider>
|
|
92
|
+
<WebToastHostProvider>
|
|
93
|
+
<WebDialogHostProvider>
|
|
94
|
+
<App
|
|
95
|
+
config={config}
|
|
96
|
+
desktopWindowBridge={desktopWindowBridge}
|
|
97
|
+
desktopApplicationMenuBridge={desktopApplicationMenuBridge}
|
|
98
|
+
desktopSnapshot={desktopSnapshot}
|
|
99
|
+
desktopThemePreview={init.desktopThemePreview}
|
|
100
|
+
/>
|
|
101
|
+
</WebDialogHostProvider>
|
|
102
|
+
</WebToastHostProvider>
|
|
103
|
+
</WebInputHostProvider>
|
|
104
|
+
</UiHostProvider>
|
|
105
|
+
</ElectrobunErrorBoundary>,
|
|
87
106
|
);
|
|
88
107
|
});
|
|
89
108
|
requestStartupFocus();
|
|
@@ -96,10 +115,5 @@ async function boot() {
|
|
|
96
115
|
}
|
|
97
116
|
|
|
98
117
|
boot().catch((error) => {
|
|
99
|
-
|
|
100
|
-
<div className="gloom-fatal">
|
|
101
|
-
<h1>Gloomberb failed to start</h1>
|
|
102
|
-
<pre>{error instanceof Error ? error.stack ?? error.message : String(error)}</pre>
|
|
103
|
-
</div>,
|
|
104
|
-
);
|
|
118
|
+
renderFatalError(error);
|
|
105
119
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
@keyframes gloom-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
|
2
|
-
html, body, #root { width: 100%; height: 100%; min-height: 100%; overflow: hidden; background: var(--gloom-bg); }
|
|
2
|
+
html, body, #root { width: 100%; height: 100%; min-height: 100%; overflow: hidden; background: var(--gloom-bg, #272a34); }
|
|
3
3
|
#root:focus { outline: none; }
|
|
4
4
|
body {
|
|
5
5
|
--cell-w: 8px;
|
|
@@ -8,7 +8,7 @@ body {
|
|
|
8
8
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
|
9
9
|
font-size: 12px;
|
|
10
10
|
line-height: var(--cell-h);
|
|
11
|
-
color: var(--gloom-text);
|
|
11
|
+
color: var(--gloom-text, #f2f2f2);
|
|
12
12
|
cursor: default;
|
|
13
13
|
user-select: none;
|
|
14
14
|
color-scheme: dark;
|
|
@@ -284,8 +284,52 @@ body.gloom-dragging {
|
|
|
284
284
|
top: 50%;
|
|
285
285
|
height: 1px;
|
|
286
286
|
}
|
|
287
|
-
.gloom-loading, .gloom-fatal {
|
|
288
|
-
|
|
287
|
+
.gloom-loading, .gloom-fatal {
|
|
288
|
+
padding: 24px;
|
|
289
|
+
color: var(--gloom-text, #f2f2f2);
|
|
290
|
+
background: var(--gloom-bg, #272a34);
|
|
291
|
+
}
|
|
292
|
+
.gloom-fatal {
|
|
293
|
+
display: flex;
|
|
294
|
+
flex-direction: column;
|
|
295
|
+
gap: 14px;
|
|
296
|
+
overflow: auto;
|
|
297
|
+
}
|
|
298
|
+
.gloom-fatal h1 {
|
|
299
|
+
margin: 0 0 6px;
|
|
300
|
+
font-size: 28px;
|
|
301
|
+
line-height: 1.25;
|
|
302
|
+
}
|
|
303
|
+
.gloom-fatal-actions {
|
|
304
|
+
display: flex;
|
|
305
|
+
flex-wrap: wrap;
|
|
306
|
+
gap: 8px;
|
|
307
|
+
}
|
|
308
|
+
.gloom-fatal button {
|
|
309
|
+
border: 1px solid var(--gloom-border, #5b6070);
|
|
310
|
+
border-radius: 6px;
|
|
311
|
+
padding: 5px 10px;
|
|
312
|
+
color: var(--gloom-text, #f2f2f2);
|
|
313
|
+
background: var(--gloom-panel, #1f222b);
|
|
314
|
+
}
|
|
315
|
+
.gloom-fatal button:hover {
|
|
316
|
+
background: color-mix(in srgb, var(--gloom-text-bright, #fff) 10%, var(--gloom-panel, #1f222b));
|
|
317
|
+
}
|
|
318
|
+
.gloom-fatal button[data-variant="primary"] {
|
|
319
|
+
border-color: var(--gloom-border-focused, #8cb4ff);
|
|
320
|
+
color: var(--gloom-bg, #111318);
|
|
321
|
+
background: var(--gloom-border-focused, #8cb4ff);
|
|
322
|
+
}
|
|
323
|
+
.gloom-fatal-status {
|
|
324
|
+
min-height: var(--cell-h);
|
|
325
|
+
color: var(--gloom-text-dim, #a7acba);
|
|
326
|
+
}
|
|
327
|
+
.gloom-fatal pre {
|
|
328
|
+
margin: 0;
|
|
329
|
+
white-space: pre-wrap;
|
|
330
|
+
color: var(--gloom-negative, #ff5f5f);
|
|
331
|
+
user-select: text;
|
|
332
|
+
}
|
|
289
333
|
.gloom-toast-viewport {
|
|
290
334
|
position: fixed;
|
|
291
335
|
right: 16px;
|
|
@@ -84,6 +84,12 @@ export type ResolvedTickerSearch =
|
|
|
84
84
|
| { kind: "local"; symbol: string; ticker: TickerRecord }
|
|
85
85
|
| { kind: "provider"; symbol: string; result: InstrumentSearchResult };
|
|
86
86
|
|
|
87
|
+
export interface TickerOpenTarget {
|
|
88
|
+
symbol: string;
|
|
89
|
+
ticker: TickerRecord;
|
|
90
|
+
created: boolean;
|
|
91
|
+
}
|
|
92
|
+
|
|
87
93
|
interface SearchQueryIntent {
|
|
88
94
|
normalizedQuery: string;
|
|
89
95
|
compactQuery: string;
|
|
@@ -297,6 +303,71 @@ export async function upsertTickerFromSearchResult(
|
|
|
297
303
|
return { ticker, created };
|
|
298
304
|
}
|
|
299
305
|
|
|
306
|
+
export async function resolveTickerOpenTarget({
|
|
307
|
+
query,
|
|
308
|
+
tickers,
|
|
309
|
+
dataProvider,
|
|
310
|
+
tickerRepository,
|
|
311
|
+
searchContext,
|
|
312
|
+
}: {
|
|
313
|
+
query: string;
|
|
314
|
+
tickers: ReadonlyMap<string, TickerRecord>;
|
|
315
|
+
dataProvider: DataProvider;
|
|
316
|
+
tickerRepository: TickerRepository;
|
|
317
|
+
searchContext?: SearchRequestContext;
|
|
318
|
+
}): Promise<TickerOpenTarget | null> {
|
|
319
|
+
const symbol = normalizeTickerInput(null, query);
|
|
320
|
+
if (!symbol) return null;
|
|
321
|
+
|
|
322
|
+
let resolved: ResolvedTickerSearch | null = null;
|
|
323
|
+
try {
|
|
324
|
+
resolved = await resolveTickerSearch({
|
|
325
|
+
query: symbol,
|
|
326
|
+
activeTicker: null,
|
|
327
|
+
tickers,
|
|
328
|
+
dataProvider,
|
|
329
|
+
searchContext,
|
|
330
|
+
});
|
|
331
|
+
} catch {
|
|
332
|
+
resolved = null;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (resolved?.kind === "local") {
|
|
336
|
+
return { symbol: resolved.symbol, ticker: resolved.ticker, created: false };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (resolved?.kind === "provider") {
|
|
340
|
+
const { ticker, created } = await upsertTickerFromSearchResult(tickerRepository, resolved.result);
|
|
341
|
+
return { symbol: ticker.metadata.ticker, ticker, created };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
try {
|
|
345
|
+
const quote = await dataProvider.getQuote(symbol, "");
|
|
346
|
+
const quoteSymbol = normalizeTickerSymbol(quote.symbol || symbol);
|
|
347
|
+
const existing = await tickerRepository.loadTicker(quoteSymbol);
|
|
348
|
+
if (existing) {
|
|
349
|
+
return { symbol: existing.metadata.ticker, ticker: existing, created: false };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const ticker = await tickerRepository.createTicker({
|
|
353
|
+
ticker: quoteSymbol,
|
|
354
|
+
exchange: quote.listingExchangeName ?? quote.exchangeName ?? "",
|
|
355
|
+
currency: quote.currency || "USD",
|
|
356
|
+
name: quote.name || quoteSymbol,
|
|
357
|
+
assetCategory: undefined,
|
|
358
|
+
portfolios: [],
|
|
359
|
+
watchlists: [],
|
|
360
|
+
positions: [],
|
|
361
|
+
custom: {},
|
|
362
|
+
tags: [],
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
return { symbol: ticker.metadata.ticker, ticker, created: true };
|
|
366
|
+
} catch {
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
300
371
|
export function findExactTickerSearchMatch<T extends Pick<TickerSearchRankableItem, "label"> & Partial<TickerSearchRankableItem>>(
|
|
301
372
|
items: T[],
|
|
302
373
|
query: string,
|
|
@@ -618,7 +689,7 @@ function analyzeSearchQuery(query: string): SearchQueryIntent {
|
|
|
618
689
|
.map((token) => ASSET_HINT_MAP[token])
|
|
619
690
|
.filter((token): token is TickerSearchInstrumentClass => token != null),
|
|
620
691
|
));
|
|
621
|
-
const assetPreference = assetHints.length === 1 ? assetHints[0] : null;
|
|
692
|
+
const assetPreference = assetHints.length === 1 ? assetHints[0]! : null;
|
|
622
693
|
|
|
623
694
|
const companyTokens = tokens.filter((token) => !(token in EXCHANGE_HINT_ALIASES) && !(token in ASSET_HINT_MAP));
|
|
624
695
|
const companyQuery = companyTokens.join(" ");
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.7.
|
|
1
|
+
export const VERSION = "0.7.5";
|