[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
279
components/terminal/PasswordCredentialPicker.tsx
Normal file
279
components/terminal/PasswordCredentialPicker.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* Floating credential list for sudo/su password-prompt assist (picker mode).
|
||||
* Positioned next to the terminal cursor using the same anchor/placement
|
||||
* helpers as AutocompletePopup. Secrets are never shown.
|
||||
*/
|
||||
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { KeyRound } from "lucide-react";
|
||||
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
import type { PasswordPromptPickerItem } from "./runtime/terminalSudoAutofill";
|
||||
import {
|
||||
clampAutocompletePopupGeometry,
|
||||
computeAutocompletePopupPlacement,
|
||||
resolveAutocompleteAnchorInViewport,
|
||||
resolveAutocompleteClampViewport,
|
||||
} from "./autocomplete/terminalAutocompleteLayout";
|
||||
|
||||
export type PasswordCredentialPickerProps = {
|
||||
items: PasswordPromptPickerItem[];
|
||||
selectedIndex: number;
|
||||
visible: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
title: string;
|
||||
emptyText: string;
|
||||
themeColors?: {
|
||||
background?: string;
|
||||
foreground?: string;
|
||||
selection?: string;
|
||||
cursor?: string;
|
||||
};
|
||||
termRef?: React.RefObject<XTerm | null>;
|
||||
containerRef?: React.RefObject<HTMLDivElement | null>;
|
||||
};
|
||||
|
||||
const ROW_HEIGHT = 28;
|
||||
const HEADER_HEIGHT = 32;
|
||||
const LIST_PADDING = 8;
|
||||
const MAX_LIST_HEIGHT = 192;
|
||||
const POPUP_MIN_WIDTH = 240;
|
||||
const POPUP_MAX_WIDTH = 360;
|
||||
|
||||
const PasswordCredentialPicker: React.FC<PasswordCredentialPickerProps> = ({
|
||||
items,
|
||||
selectedIndex,
|
||||
visible,
|
||||
onSelect,
|
||||
title,
|
||||
emptyText,
|
||||
themeColors,
|
||||
termRef,
|
||||
containerRef,
|
||||
}) => {
|
||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
const listRef = useRef<HTMLDivElement | null>(null);
|
||||
const selectedRef = useRef<HTMLButtonElement | null>(null);
|
||||
const [anchorTick, setAnchorTick] = useState(0);
|
||||
const [measuredSize, setMeasuredSize] = useState<{ width: number; height: number } | null>(null);
|
||||
|
||||
const requestReposition = useCallback(() => {
|
||||
setAnchorTick((n) => n + 1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
selectedRef.current?.scrollIntoView({ block: "nearest" });
|
||||
}, [visible, selectedIndex]);
|
||||
|
||||
// Recalculate when the terminal/container resizes or the window moves.
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
|
||||
let frameId = 0;
|
||||
const schedule = () => {
|
||||
if (frameId) cancelAnimationFrame(frameId);
|
||||
frameId = requestAnimationFrame(() => {
|
||||
frameId = 0;
|
||||
requestReposition();
|
||||
});
|
||||
};
|
||||
|
||||
const container = containerRef?.current;
|
||||
const observer = container ? new ResizeObserver(schedule) : null;
|
||||
if (container) observer?.observe(container);
|
||||
window.addEventListener("resize", schedule);
|
||||
window.addEventListener("scroll", schedule, true);
|
||||
|
||||
// Two rAFs so xterm has finished layout after the password line paints.
|
||||
let first = 0;
|
||||
let second = 0;
|
||||
first = requestAnimationFrame(() => {
|
||||
requestReposition();
|
||||
second = requestAnimationFrame(requestReposition);
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (frameId) cancelAnimationFrame(frameId);
|
||||
if (first) cancelAnimationFrame(first);
|
||||
if (second) cancelAnimationFrame(second);
|
||||
observer?.disconnect();
|
||||
window.removeEventListener("resize", schedule);
|
||||
window.removeEventListener("scroll", schedule, true);
|
||||
};
|
||||
}, [visible, containerRef, requestReposition, items.length]);
|
||||
|
||||
const itemCount = Math.max(1, items.length);
|
||||
const estimatedListHeight = Math.min(MAX_LIST_HEIGHT, itemCount * ROW_HEIGHT + LIST_PADDING);
|
||||
const estimatedPopupHeight = estimatedListHeight + HEADER_HEIGHT;
|
||||
|
||||
const placement = useMemo(() => {
|
||||
// anchorTick forces recompute when the cursor/container moves.
|
||||
void anchorTick;
|
||||
const term = termRef?.current ?? null;
|
||||
const container = containerRef?.current ?? null;
|
||||
const clampViewport = resolveAutocompleteClampViewport(container);
|
||||
const empty = {
|
||||
left: clampViewport.left + 8,
|
||||
top: clampViewport.top + 8,
|
||||
maxHeight: MAX_LIST_HEIGHT,
|
||||
renderUpward: true,
|
||||
};
|
||||
if (!term || !visible) return empty;
|
||||
|
||||
const anchor = resolveAutocompleteAnchorInViewport(term, container, itemCount);
|
||||
const result = computeAutocompletePopupPlacement({
|
||||
anchorTop: anchor.anchorTop,
|
||||
anchorBottom: anchor.anchorBottom,
|
||||
anchorLeft: anchor.anchorLeft,
|
||||
viewportWidth: clampViewport.width,
|
||||
viewportHeight: clampViewport.height,
|
||||
clampViewport,
|
||||
desiredHeight: estimatedPopupHeight,
|
||||
totalWidth: POPUP_MAX_WIDTH,
|
||||
clampWidth: POPUP_MAX_WIDTH,
|
||||
maxHeight: estimatedPopupHeight,
|
||||
anchorGap: 8,
|
||||
viewportPadding: 8,
|
||||
// Password prompts sit on the input line; prefer opening upward so the
|
||||
// list does not cover what the user is about to type.
|
||||
expandUpwardHint: true,
|
||||
});
|
||||
return {
|
||||
left: result.left,
|
||||
top: result.top,
|
||||
maxHeight: Math.max(ROW_HEIGHT + LIST_PADDING, result.maxHeight - HEADER_HEIGHT),
|
||||
renderUpward: result.renderUpward,
|
||||
};
|
||||
}, [
|
||||
anchorTick,
|
||||
termRef,
|
||||
containerRef,
|
||||
visible,
|
||||
itemCount,
|
||||
estimatedPopupHeight,
|
||||
]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!visible) {
|
||||
setMeasuredSize((current) => (current === null ? current : null));
|
||||
return;
|
||||
}
|
||||
const rect = wrapperRef.current?.getBoundingClientRect();
|
||||
if (!rect || rect.width <= 0 || rect.height <= 0) return;
|
||||
setMeasuredSize((current) => {
|
||||
if (
|
||||
current
|
||||
&& Math.abs(current.width - rect.width) < 0.5
|
||||
&& Math.abs(current.height - rect.height) < 0.5
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
return { width: rect.width, height: rect.height };
|
||||
});
|
||||
}, [visible, placement.left, placement.top, items.length, selectedIndex]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const clampViewport = resolveAutocompleteClampViewport(containerRef?.current ?? null);
|
||||
const finalGeometry = measuredSize
|
||||
? clampAutocompletePopupGeometry({
|
||||
left: placement.left,
|
||||
top: placement.top,
|
||||
width: measuredSize.width,
|
||||
height: measuredSize.height,
|
||||
clampViewport,
|
||||
viewportPadding: 8,
|
||||
})
|
||||
: { left: placement.left, top: placement.top };
|
||||
|
||||
const background = themeColors?.background ?? "hsl(var(--popover))";
|
||||
const foreground = themeColors?.foreground ?? "hsl(var(--popover-foreground))";
|
||||
const selection = themeColors?.selection ?? "hsl(var(--accent))";
|
||||
const border = themeColors?.cursor
|
||||
? `${themeColors.cursor}55`
|
||||
: "hsl(var(--border))";
|
||||
|
||||
const node = (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
role="listbox"
|
||||
aria-label={title}
|
||||
data-testid="password-credential-picker"
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: `${finalGeometry.left}px`,
|
||||
top: `${finalGeometry.top}px`,
|
||||
zIndex: 10000,
|
||||
minWidth: POPUP_MIN_WIDTH,
|
||||
maxWidth: POPUP_MAX_WIDTH,
|
||||
overflow: "hidden",
|
||||
borderRadius: 6,
|
||||
border: `1px solid ${border}`,
|
||||
background,
|
||||
color: foreground,
|
||||
boxShadow: placement.renderUpward
|
||||
? "0 -2px 6px rgba(0, 0, 0, 0.15)"
|
||||
: "0 2px 6px rgba(0, 0, 0, 0.15)",
|
||||
fontSize: 13,
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
// Keep terminal focus; prevent selection loss before click select.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-1.5 border-b px-3 py-1.5 text-[11px] font-medium uppercase tracking-wide opacity-70"
|
||||
style={{ borderColor: border, height: HEADER_HEIGHT, boxSizing: "border-box" }}
|
||||
>
|
||||
<KeyRound size={12} />
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
<div
|
||||
ref={listRef}
|
||||
style={{
|
||||
maxHeight: `${placement.maxHeight}px`,
|
||||
overflowY: "auto",
|
||||
padding: "4px 0",
|
||||
}}
|
||||
>
|
||||
{items.length === 0 ? (
|
||||
<div className="px-3 py-2 text-xs opacity-70">{emptyText}</div>
|
||||
) : (
|
||||
items.map((item, index) => {
|
||||
const selected = index === selectedIndex;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
ref={selected ? selectedRef : undefined}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm transition-colors"
|
||||
style={{
|
||||
background: selected ? selection : undefined,
|
||||
height: ROW_HEIGHT,
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
onClick={() => onSelect(item.id)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate font-medium">{item.label}</span>
|
||||
{item.username ? (
|
||||
<span className="shrink-0 font-mono text-xs opacity-70">{item.username}</span>
|
||||
) : null}
|
||||
<span className="shrink-0 font-mono text-xs opacity-50">••••••••</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Portal to body so overflow:hidden on terminal chrome cannot clip the list
|
||||
// (same pattern as AutocompletePopup).
|
||||
return ReactDOM.createPortal(node, document.body);
|
||||
};
|
||||
|
||||
export default memo(PasswordCredentialPicker);
|
||||
Reference in New Issue
Block a user