Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev

This commit is contained in:
2026-05-24 11:00:18 +00:00
5 changed files with 98 additions and 51 deletions
@@ -6,6 +6,7 @@ import { SpecialKeysPanel } from "./special-keys-panel";
import { useMobileViewport } from "../hooks/use-mobile-viewport";
import { useVirtualKeyboard } from "../hooks/use-virtual-keyboard";
import { useAutoHide } from "../hooks/use-auto-hide";
import type { ModifierKey } from "../hooks/use-special-keys";
interface MobileTerminalWrapperProps {
instanceId: string;
@@ -26,6 +27,7 @@ export const MobileTerminalWrapper: React.FC<MobileTerminalWrapperProps> = ({
const { isOpen: isKeyboardOpen, height: keyboardHeight } =
useVirtualKeyboard();
const [showPanel, setShowPanel] = useState(false);
const [activeModifier, setActiveModifier] = useState<ModifierKey | null>(null);
const [terminalRef, setTerminalRef] = useState<{
sendData: (data: string) => void;
connectionStatus: "connecting" | "connected" | "disconnected" | "error";
@@ -84,6 +86,8 @@ export const MobileTerminalWrapper: React.FC<MobileTerminalWrapperProps> = ({
instanceId={instanceId}
onClose={onClose}
isMobile={true}
activeModifier={activeModifier}
onModifierChange={setActiveModifier}
onTerminalReady={handleTerminalReady}
/>
</div>
@@ -93,6 +97,8 @@ export const MobileTerminalWrapper: React.FC<MobileTerminalWrapperProps> = ({
isVisible={!showPanel}
onMoreClick={() => setShowPanel(true)}
onKeepFocus={() => terminalRef?.focusInput()}
activeModifier={activeModifier}
onModifierChange={setActiveModifier}
/>
<SpecialKeysPanel
@@ -100,6 +106,8 @@ export const MobileTerminalWrapper: React.FC<MobileTerminalWrapperProps> = ({
isOpen={showPanel}
onClose={() => setShowPanel(false)}
onKeepFocus={() => terminalRef?.focusInput()}
activeModifier={activeModifier}
onModifierChange={setActiveModifier}
/>
</div>
);
+15 -5
View File
@@ -1,11 +1,13 @@
import React from "react";
import { useSpecialKeys, type SpecialKey } from "../hooks/use-special-keys";
import { getSequenceWithModifier, type SpecialKey, type ModifierKey } from "../hooks/use-special-keys";
interface SpecialKeysPanelProps {
onSend: (data: string) => void;
isOpen: boolean;
onClose: () => void;
onKeepFocus?: () => void;
activeModifier: ModifierKey | null;
onModifierChange: (modifier: ModifierKey | null) => void;
}
const EXPANDED_KEYS: { key: SpecialKey; label: string }[] = [
@@ -38,14 +40,22 @@ export const SpecialKeysPanel: React.FC<SpecialKeysPanelProps> = ({
isOpen,
onClose,
onKeepFocus,
activeModifier,
onModifierChange,
}) => {
const { sendKey, clearModifier } = useSpecialKeys({ onSend });
if (!isOpen) return null;
const handlePointerDown = (e: React.PointerEvent, key: SpecialKey) => {
e.preventDefault();
sendKey(key);
const result = getSequenceWithModifier(key, activeModifier);
if (result) {
onSend(result.sequence);
if (result.clearModifier) {
onModifierChange(null);
}
}
onClose();
// Always refocus terminal after sending
requestAnimationFrame(() => {
@@ -55,7 +65,7 @@ export const SpecialKeysPanel: React.FC<SpecialKeysPanelProps> = ({
const handleOverlayPointerDown = (e: React.PointerEvent) => {
e.preventDefault();
clearModifier();
onModifierChange(null);
onClose();
requestAnimationFrame(() => {
onKeepFocus?.();
+23 -4
View File
@@ -1,11 +1,13 @@
import React from "react";
import { useSpecialKeys, type SpecialKey, type ModifierKey } from "../hooks/use-special-keys";
import { getSequenceWithModifier, type SpecialKey, type ModifierKey, KEY_SEQUENCES } from "../hooks/use-special-keys";
interface SpecialKeysStripProps {
onSend: (data: string) => void;
isVisible: boolean;
onMoreClick?: () => void;
onKeepFocus?: () => void;
activeModifier: ModifierKey | null;
onModifierChange: (modifier: ModifierKey | null) => void;
}
const PRIMARY_KEYS: { key: SpecialKey; label: string; isModifier?: boolean }[] = [
@@ -24,12 +26,29 @@ export const SpecialKeysStrip: React.FC<SpecialKeysStripProps> = ({
isVisible,
onMoreClick,
onKeepFocus,
activeModifier,
onModifierChange,
}) => {
const { sendKey, activeModifier } = useSpecialKeys({ onSend });
const handlePointerDown = (e: React.PointerEvent, key: SpecialKey) => {
e.preventDefault();
sendKey(key);
// Handle modifier keys (one-shot)
if (key === "ctrl" || key === "alt") {
onModifierChange(activeModifier === key ? null : key);
requestAnimationFrame(() => {
onKeepFocus?.();
});
return;
}
const result = getSequenceWithModifier(key, activeModifier);
if (result) {
onSend(result.sequence);
if (result.clearModifier) {
onModifierChange(null);
}
}
// Always refocus terminal after sending
requestAnimationFrame(() => {
onKeepFocus?.();
+21 -2
View File
@@ -4,10 +4,14 @@ import { FitAddon } from "xterm-addon-fit";
import { WebLinksAddon } from "xterm-addon-web-links";
import "xterm/css/xterm.css";
import { applyModifierToChar, type ModifierKey } from "../hooks/use-special-keys";
interface TerminalProps {
instanceId: string;
onClose?: () => void;
isMobile?: boolean;
activeModifier?: ModifierKey | null;
onModifierChange?: (modifier: ModifierKey | null) => void;
onTerminalReady?: (
sendData: (data: string) => void,
connectionStatus: "connecting" | "connected" | "disconnected" | "error",
@@ -25,6 +29,8 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
instanceId,
onClose,
isMobile = false,
activeModifier,
onModifierChange,
onTerminalReady,
}) => {
const terminalRef = useRef<HTMLDivElement>(null);
@@ -39,6 +45,8 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
"connecting" | "connected" | "disconnected" | "error"
>("connecting");
const [error, setError] = useState<string | null>(null);
const activeModifierRef = useRef(activeModifier);
activeModifierRef.current = activeModifier;
const [fontSize, setFontSize] = useState(() => {
if (typeof window === "undefined") return isMobile ? 16 : 14;
const stored = localStorage.getItem(FONT_SIZE_KEY);
@@ -161,9 +169,20 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
// Handle terminal input
term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(data);
if (ws.readyState !== WebSocket.OPEN) return;
// Apply active modifier to single-character input
const modifier = activeModifierRef.current;
if (modifier && data.length === 1) {
const modified = applyModifierToChar(data, modifier);
if (modified) {
ws.send(modified);
onModifierChange?.(null);
return;
}
}
ws.send(data);
});
// Handle resize with debounce
+31 -40
View File
@@ -1,4 +1,4 @@
import { useState, useCallback } from "react";
export type SpecialKey =
| "escape"
@@ -103,47 +103,38 @@ const MODIFIER_PREFIXES: Record<string, { ctrl: string; alt: string; ctrlAlt: st
"9": { ctrl: "9", alt: "\x1B9", ctrlAlt: "\x1B9" },
};
interface UseSpecialKeysOptions {
onSend: (data: string) => void;
export function getSequenceWithModifier(
key: SpecialKey,
activeModifier: ModifierKey | null
): { sequence: string; clearModifier: boolean } | null {
// Handle modifier keys (one-shot)
if (key === "ctrl" || key === "alt") {
return null; // Modifiers don't send anything themselves
}
const sequence = KEY_SEQUENCES[key];
if (!sequence) return null;
// Check if we have an active modifier and the key is a single character
if (activeModifier && sequence.length === 1) {
const char = sequence;
const mapping = MODIFIER_PREFIXES[char.toLowerCase()];
if (mapping) {
return { sequence: mapping[activeModifier], clearModifier: true };
}
}
return { sequence, clearModifier: !!activeModifier };
}
export function useSpecialKeys({ onSend }: UseSpecialKeysOptions) {
const [activeModifier, setActiveModifier] = useState<ModifierKey | null>(null);
const sendKey = useCallback(
(key: SpecialKey) => {
// Handle modifier keys (one-shot)
if (key === "ctrl" || key === "alt") {
// Toggle modifier
setActiveModifier((current) => (current === key ? null : key));
return;
}
const sequence = KEY_SEQUENCES[key];
if (!sequence) return;
// Check if we have an active modifier and the key is a single character
if (activeModifier && sequence.length === 1) {
const char = sequence;
const mapping = MODIFIER_PREFIXES[char.toLowerCase()];
if (mapping) {
onSend(mapping[activeModifier]);
setActiveModifier(null);
return;
}
}
onSend(sequence);
setActiveModifier(null);
},
[onSend, activeModifier]
);
const clearModifier = useCallback(() => {
setActiveModifier(null);
}, []);
return { sendKey, activeModifier, clearModifier };
export function applyModifierToChar(
char: string,
modifier: ModifierKey
): string | null {
if (char.length !== 1) return null;
const mapping = MODIFIER_PREFIXES[char.toLowerCase()];
if (!mapping) return null;
return mapping[modifier];
}
export { KEY_SEQUENCES };