diff --git a/web/src/components/WebTerminal.tsx b/web/src/components/WebTerminal.tsx index 6620ec9..4dc8fad 100644 --- a/web/src/components/WebTerminal.tsx +++ b/web/src/components/WebTerminal.tsx @@ -217,8 +217,11 @@ export const WebTerminal = forwardRef(function WebTerm const pasteQueueRef = useRef>(Promise.resolve()); /** Updated whenever device stdout arrives; used to pace paste by echo, not fixed sleep. */ const lastStdoutAtRef = useRef(0); + /** Snapshot selection when opening the context menu — click on menu clears xterm selection. */ + const menuSelectionRef = useRef(""); const keywordHighlightRef = useRef(keywordHighlight); const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null); + const [clipStatus, setClipStatus] = useState(null); const [findOpen, setFindOpen] = useState(false); const [findQuery, setFindQuery] = useState(""); const [pasteStatus, setPasteStatus] = useState<{ done: number; total: number } | null>(null); @@ -635,6 +638,40 @@ export const WebTerminal = forwardRef(function WebTerm return; } + // CRT-style clipboard: Ctrl+Shift+C / Ctrl+Insert copy; Ctrl+Shift+V / Shift+Insert paste. + const mod = e.ctrlKey || e.metaKey; + if (mod && e.shiftKey && (e.key === "C" || e.key === "c")) { + e.preventDefault(); + e.stopPropagation(); + const text = term.getSelection() || ""; + if (text && navigator.clipboard?.writeText) { + void navigator.clipboard.writeText(text).catch(() => undefined); + } + return; + } + if ((mod && e.shiftKey && (e.key === "V" || e.key === "v")) || (e.shiftKey && e.key === "Insert")) { + e.preventDefault(); + e.stopPropagation(); + void (async () => { + try { + const text = await navigator.clipboard.readText(); + if (text) sendStdinThrottled(text); + } catch { + /* permission denied */ + } + })(); + return; + } + if (mod && e.key === "Insert") { + e.preventDefault(); + e.stopPropagation(); + const text = term.getSelection() || ""; + if (text && navigator.clipboard?.writeText) { + void navigator.clipboard.writeText(text).catch(() => undefined); + } + return; + } + if (e.key === "Backspace") { e.preventDefault(); } @@ -656,6 +693,7 @@ export const WebTerminal = forwardRef(function WebTerm const onContextMenu = (e: MouseEvent) => { e.preventDefault(); + menuSelectionRef.current = term.getSelection() || ""; setCtxMenu({ x: e.clientX, y: e.clientY }); }; host.addEventListener("contextmenu", onContextMenu); @@ -723,20 +761,30 @@ export const WebTerminal = forwardRef(function WebTerm const pasteFromClipboard = async () => { try { const text = await navigator.clipboard.readText(); - sendStdinThrottled(text); + if (text) sendStdinThrottled(text); + else setClipStatus(t("webcrt.term.pasteEmpty")); } catch { - /* ignore */ + setClipStatus(t("webcrt.term.pasteFailed")); } setCtxMenu(null); focusTerminal(); + window.setTimeout(() => setClipStatus(null), 2200); }; const copySelection = async () => { - const text = termRef.current?.getSelection() || ""; - if (text && navigator.clipboard?.writeText) { - await navigator.clipboard.writeText(text); + const text = menuSelectionRef.current || termRef.current?.getSelection() || ""; + try { + if (text && navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + setClipStatus(t("webcrt.actions.copied")); + } else { + setClipStatus(t("webcrt.actions.copyEmpty")); + } + } catch { + setClipStatus(t("webcrt.actions.copyFailed")); } setCtxMenu(null); + window.setTimeout(() => setClipStatus(null), 2200); }; return ( @@ -746,6 +794,11 @@ export const WebTerminal = forwardRef(function WebTerm {t("webcrt.term.pasting", { done: pasteStatus.done, total: pasteStatus.total })} ) : null} + {clipStatus ? ( +
+ {clipStatus} +
+ ) : null} {findOpen ? (
{ const src = e.status === "stale" || e.status === "missing" ? "stale" : e.source || "manual"; - const label = [e.a_port, e.b_port].filter(Boolean).join(SEP); + const label = formatPortPairLabel(e.a_port || "", e.b_port || ""); const data: EdgeStyleData = { source: src, source_port: e.a_port || "", @@ -705,6 +707,8 @@ export function TopologyPage() { const [hideIp, setHideIp] = useState(true); const [hideVendor, setHideVendor] = useState(true); const [hidePorts, setHidePorts] = useState(true); + /** Default: logical aggregate of parallel links; expand to see each physical edge. */ + const [expandPhysicalLinks, setExpandPhysicalLinks] = useState(false); const [edgeFlow, setEdgeFlow] = useState(false); const [edgeDefaults, setEdgeDefaults] = useState(() => loadEdgeDefaults()); const [toolMode, setToolMode] = useState("select"); @@ -812,15 +816,25 @@ export function TopologyPage() { () => ({ hideIp, hideVendor, hidePorts, connectMode: toolMode === "connect" }), [hideIp, hideVendor, hidePorts, toolMode], ); - const displayEdges = useMemo( - () => - edges.map((e) => ({ + const displayEdges = useMemo(() => { + const built = buildLinkDisplayEdges(edges, expandPhysicalLinks).map((e) => + withEdgeVisual(e, edgeDefaults), + ); + return built.map((e) => { + const d = (e.data || {}) as EdgeStyleData; + const count = Number(d.member_count || 1); + const keepCountLabel = !expandPhysicalLinks && count > 1; + const selected = + e.id === selectedEdgeId || + Boolean(d.members?.some((m: LinkMember) => m.id === selectedEdgeId)); + return { ...e, - label: hidePorts ? undefined : e.label, + selected, + label: hidePorts && !keepCountLabel ? undefined : e.label, animated: edgeFlow, - })), - [edges, hidePorts, edgeFlow], - ); + }; + }); + }, [edges, expandPhysicalLinks, hidePorts, edgeFlow, edgeDefaults, selectedEdgeId]); const treeQuery = useQuery({ queryKey: queryKeys.topologyTree, @@ -1593,12 +1607,18 @@ export function TopologyPage() { const selectedNodes = useMemo(() => nodes.filter((n) => n.selected), [nodes]); const selectedNode = selectedNodes[0] || null; const selectedNodeIds = useMemo(() => selectedNodes.map((n) => n.id), [selectedNodes]); - const selectedEdge = useMemo( - () => (selectedEdgeId ? edges.find((e) => e.id === selectedEdgeId) || null : null), - [edges, selectedEdgeId], - ); + const selectedEdge = useMemo(() => { + if (!selectedEdgeId) return null; + return ( + displayEdges.find((e) => e.id === selectedEdgeId) || + edges.find((e) => e.id === selectedEdgeId) || + null + ); + }, [selectedEdgeId, displayEdges, edges]); const selectedEdgeData = (selectedEdge?.data || {}) as EdgeStyleData; const selectedEdgeResolved = selectedEdge ? resolveEdgeStyle(selectedEdgeData, edgeDefaults) : null; + const selectedEdgeSourceNode = selectedEdge ? nodes.find((n) => n.id === selectedEdge.source) : null; + const selectedEdgeTargetNode = selectedEdge ? nodes.find((n) => n.id === selectedEdge.target) : null; const patchSelectedEdgeStyle = useCallback( ( @@ -1608,10 +1628,20 @@ export function TopologyPage() { opts?: { skipHistory?: boolean }, ) => { if (!selectedEdgeId || !mapId) return; + const display = displayEdges.find((e) => e.id === selectedEdgeId) || selectedEdge; + const targetIds = physicalIdsForDisplayEdge(display, edges); + if (!targetIds.length) return; + // Port edits only make sense on a single physical link. + if ( + (isAggregateEdgeId(selectedEdgeId) || targetIds.length > 1) && + (patch.source_port !== undefined || patch.target_port !== undefined) + ) { + return; + } if (!opts?.skipHistory) pushHistory(); setEdges((eds) => eds.map((e) => { - if (e.id !== selectedEdgeId) return e; + if (!targetIds.includes(e.id)) return e; const prev = (e.data || {}) as EdgeStyleData; const data: EdgeStyleData = { ...prev, @@ -1622,21 +1652,23 @@ export function TopologyPage() { source_port: patch.source_port !== undefined ? patch.source_port : prev.source_port || "", target_port: patch.target_port !== undefined ? patch.target_port : prev.target_port || "", }; - const portLabel = [data.source_port, data.target_port].filter(Boolean).join(SEP); + const portLabel = formatPortPairLabel(data.source_port || "", data.target_port || ""); return withEdgeVisual({ ...e, data, label: portLabel || undefined }, edgeDefaults); }), ); - const edge = edges.find((e) => e.id === selectedEdgeId); + const edge = edges.find((e) => e.id === targetIds[0]); const prev = (edge?.data || {}) as EdgeStyleData; - void patchTopologyEdgeStyle(mapId, { - fabric_edge_id: selectedEdgeId, - stroke_color: patch.stroke_color !== undefined ? patch.stroke_color : prev.stroke_color || "", - stroke_width: - patch.stroke_width !== undefined ? Number(patch.stroke_width || 0) : Number(prev.stroke_width || 0), - line_style: patch.line_style !== undefined ? patch.line_style : prev.line_style || "", - }).catch((err) => showError(String(err))); + for (const fabricEdgeId of targetIds) { + void patchTopologyEdgeStyle(mapId, { + fabric_edge_id: fabricEdgeId, + stroke_color: patch.stroke_color !== undefined ? patch.stroke_color : prev.stroke_color || "", + stroke_width: + patch.stroke_width !== undefined ? Number(patch.stroke_width || 0) : Number(prev.stroke_width || 0), + line_style: patch.line_style !== undefined ? patch.line_style : prev.line_style || "", + }).catch((err) => showError(String(err))); + } }, - [selectedEdgeId, mapId, edges, setEdges, pushHistory, edgeDefaults, showError], + [selectedEdgeId, selectedEdge, mapId, edges, displayEdges, setEdges, pushHistory, edgeDefaults, showError], ); const renameSelectedNode = useCallback(() => { @@ -1822,8 +1854,11 @@ export function TopologyPage() { const removeEdgeById = (edgeId: string) => { pushHistory(); markDirty(); - setEdges((es) => es.filter((e) => e.id !== edgeId)); - setSelectedEdgeId((cur) => (cur === edgeId ? null : cur)); + const display = displayEdges.find((e) => e.id === edgeId); + const ids = new Set(physicalIdsForDisplayEdge(display, edges)); + if (!ids.size) ids.add(edgeId); + setEdges((es) => es.filter((e) => !ids.has(e.id))); + setSelectedEdgeId((cur) => (cur && (ids.has(cur) || cur === edgeId) ? null : cur)); closeCtxMenu(); }; @@ -2215,7 +2250,8 @@ export function TopologyPage() { (edgeId: string) => { setSelectedEdgeId(edgeId); setNodes((ns) => ns.map((n) => ({ ...n, selected: false }))); - setEdges((es) => es.map((e) => ({ ...e, selected: e.id === edgeId }))); + // Selection is applied in displayEdges; keep store flags clear. + setEdges((es) => es.map((e) => ({ ...e, selected: false }))); }, [setNodes, setEdges], ); @@ -2780,14 +2816,22 @@ export function TopologyPage() { /> {t("topology.hideVendor")} - + +