import { useEffect, useRef } from "react";
import { Box, ScrollBox, Text, TextAttributes, useUiCapabilities, type BoxRenderable, type ScrollBoxRenderable } from "../../../ui";
import { Button, NumberField, SectionHeading, SegmentedControl, TextField } from "../../../components";
import {
PRESERVED_PASSWORD_HINT,
type BrokerProfileDraft,
} from "../../../brokers/profile-form";
import { colors } from "../../../theme/colors";
import type { BrokerAdapter, BrokerConfigField } from "../../../types/broker";
import type { BrokerInstanceConfig } from "../../../types/config";
import type { BrokerAccount } from "../../../types/trading";
import { formatCurrency } from "../../../utils/format";
import { t, tf } from "../../../i18n";
import { formatBrokerUpdatedAt, type BrokerProfileRow } from "./model";
import { isBrokerErrorMessage, stateColor, truncate } from "./table";
export type BrokerEditKey = "label" | "enabled" | string;
type RowRef = (node: BoxRenderable | null) => void;
/**
* Scrolls the detail so the active edit row is in view. The terminal measures
* rows in cells from the top of the content; the desktop in pixels, since a
* focused text field scrolls itself but a segmented control does not.
*/
function revealEditRow(scroll: ScrollBoxRenderable | null, top: BoxRenderable | null, row: BoxRenderable | null): void {
if (!scroll || !top || !row) return;
const topRect = top.getBoundingClientRect?.();
const rowRect = row.getBoundingClientRect?.();
const pixels = topRect && rowRect && scroll.viewportPx && typeof scroll.scrollTopPx === "number";
const offset = pixels ? rowRect.y - topRect.y : (row.y ?? 0) - (top.y ?? 0);
const size = pixels ? rowRect.height : row.height ?? 1;
const viewport = pixels ? scroll.viewportPx!.height : scroll.viewport?.height ?? 0;
const current = pixels ? scroll.scrollTopPx! : scroll.scrollTop;
if (viewport <= 0) return;
let next = current;
if (offset < current) next = offset;
else if (offset + size > current + viewport) next = Math.min(offset, offset + size - viewport);
if (next === current) return;
if (pixels) scroll.scrollTopPx = Math.max(0, next);
else scroll.scrollTo(Math.max(0, next));
}
/**
* The desktop field draws its own focus ring, so the label is plain there. The
* terminal field label does not change with focus, so it keeps the marker.
*/
function useFieldLabel(): (label: string, focused: boolean) => string {
const { nativePaneChrome } = useUiCapabilities();
return (label, focused) => nativePaneChrome ? label : `${focused ? "> " : " "}${label}`;
}
function BrokerConfigFieldEditor({
field,
draft,
previous,
adapter,
focused,
width,
scope,
rowRef,
onFocus,
onChange,
onSubmit,
}: {
field: BrokerConfigField;
draft: BrokerProfileDraft;
previous: BrokerInstanceConfig;
adapter: BrokerAdapter;
focused: boolean;
width: number;
scope: string;
rowRef: RowRef;
onFocus: () => void;
onChange: (key: string, value: string) => void;
onSubmit: () => void;
}) {
const fieldLabel = useFieldLabel();
const value = draft.values[field.key] ?? "";
const previousPassword = field.type === "password"
? String(((adapter.toConfigValues?.(previous) ?? previous.config)[field.key] ?? "") || "")
: "";
if (field.type === "select") {
return (
{fieldLabel(t(field.label), focused)}
({ label: t(option.label), value: option.value }))}
onChange={(nextValue) => onChange(field.key, nextValue)}
allowEditable
shortcutScope={scope}
/>
);
}
const Field = field.type === "number" ? NumberField : TextField;
return (
onChange(field.key, nextValue)}
onSubmit={onSubmit}
/>
);
}
function accountDetail(account: BrokerAccount): string {
const parts = [
account.accountId,
account.netLiquidation != null ? tf("{value} net liq", { value: formatCurrency(account.netLiquidation, account.currency || "USD") }) : null,
account.buyingPower != null ? tf("{value} buying power", { value: formatCurrency(account.buyingPower, account.currency || "USD") }) : null,
];
return parts.filter(Boolean).join(" · ");
}
export function BrokerDetailContent({
row,
accounts,
editDraft,
editFields,
activeEditKey,
busy,
message,
width,
editScope,
paneFocused,
onActiveEditKeyChange,
onDraftLabelChange,
onDraftEnabledChange,
onDraftValueChange,
onSaveEdit,
onCancelEdit,
}: {
row: BrokerProfileRow | null;
accounts: BrokerAccount[];
editDraft: BrokerProfileDraft | null;
editFields: BrokerConfigField[];
activeEditKey: BrokerEditKey;
busy: string | null;
message: string | null;
width: number;
/** The edit form's shortcut scope, shared with the pane's field ring. */
editScope: string;
/** The form's fields take keys only while the pane has the keyboard. */
paneFocused: boolean;
onActiveEditKeyChange: (key: BrokerEditKey) => void;
onDraftLabelChange: (label: string) => void;
onDraftEnabledChange: (enabled: boolean) => void;
onDraftValueChange: (key: string, value: string) => void;
onSaveEdit: () => void;
onCancelEdit: () => void;
}) {
const fieldLabel = useFieldLabel();
const scrollRef = useRef(null);
const topRef = useRef(null);
const rowRefs = useRef(new Map());
const rowRef = (key: BrokerEditKey): RowRef => (node) => {
if (node) rowRefs.current.set(key, node);
else rowRefs.current.delete(key);
};
const editing = !!editDraft;
const shownEditKey = useRef(null);
useEffect(() => {
if (!editing) {
shownEditKey.current = null;
return;
}
// The form opens at its first field; after that the view follows the active row.
const previous = shownEditKey.current;
shownEditKey.current = activeEditKey;
if (previous === null || previous === activeEditKey) return;
revealEditRow(scrollRef.current, topRef.current, rowRefs.current.get(activeEditKey) ?? null);
}, [activeEditKey, editing]);
if (!row) return ;
// Never wider than the detail pane, so a narrow floating pane shrinks instead of clipping.
const fieldWidth = Math.max(12, Math.min(34, width - 2));
const detailStatusMessage = isBrokerErrorMessage(message) ? message : row.message || t("No status message.");
const editAdapter = row.adapter;
return (
{/* The stack title already names the profile; the body starts with its state. */}
{truncate(row.stateLabel, width)}
{truncate(`${row.brokerName} · ${row.mode} · ${row.id}`, width)}
{detailStatusMessage}
{tf("Last sync {time}", { time: formatBrokerUpdatedAt(row.lastSyncedAt) })}
{tf("Status updated {time}", { time: formatBrokerUpdatedAt(row.updatedAt) })}
{editDraft && editAdapter ? (
onActiveEditKeyChange("label")}>
onActiveEditKeyChange("enabled")}>
{fieldLabel(t("Enabled"), activeEditKey === "enabled")}
onDraftEnabledChange(value === "yes")}
allowEditable
shortcutScope={editScope}
/>
{editFields.map((field) => (
onActiveEditKeyChange(field.key)}
onChange={onDraftValueChange}
onSubmit={onSaveEdit}
/>
))}
) : (
// Edit, test, sync, open and disconnect are the footer's e, c, s, o and d.
{accounts.length === 0 ? (
{t("No accounts loaded. Test/connect or sync this profile.")}
) : accounts.map((account) => (
{truncate(accountDetail(account), width)}
))}
)}
);
}