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
311 lines
13 KiB
TypeScript
311 lines
13 KiB
TypeScript
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { Ban, RotateCcw } from "lucide-react";
|
|
import type { HotkeyScheme, KeyBinding } from "../../../domain/models";
|
|
import { keyEventToString } from "../../../domain/models";
|
|
import { useI18n } from "../../../application/i18n/I18nProvider";
|
|
import { cn } from "../../../lib/utils";
|
|
import { Button } from "../../ui/button";
|
|
import { SectionHeader, Select, SettingsAnchor, SettingsTabContent, SettingRow, Toggle } from "../settings-ui";
|
|
import { isAppLockOverlayActive } from '../../../infrastructure/appLockOverlayDom';
|
|
|
|
export default function SettingsShortcutsTab(props: {
|
|
hotkeyScheme: HotkeyScheme;
|
|
setHotkeyScheme: (scheme: HotkeyScheme) => void;
|
|
shellOnlyTabNumberShortcuts: boolean;
|
|
setShellOnlyTabNumberShortcuts: (enabled: boolean) => void;
|
|
showTabNumberBadges: boolean;
|
|
setShowTabNumberBadges: (enabled: boolean) => void;
|
|
disableTerminalFontZoom: boolean;
|
|
setDisableTerminalFontZoom: (enabled: boolean) => void;
|
|
keyBindings: KeyBinding[];
|
|
updateKeyBinding?: (bindingId: string, scheme: "mac" | "pc", newKey: string) => void;
|
|
resetKeyBinding?: (bindingId: string, scheme?: "mac" | "pc") => void;
|
|
resetAllKeyBindings: () => void;
|
|
setIsHotkeyRecording?: (isRecording: boolean) => void;
|
|
}) {
|
|
const {
|
|
hotkeyScheme,
|
|
setHotkeyScheme,
|
|
shellOnlyTabNumberShortcuts,
|
|
setShellOnlyTabNumberShortcuts,
|
|
showTabNumberBadges,
|
|
setShowTabNumberBadges,
|
|
disableTerminalFontZoom,
|
|
setDisableTerminalFontZoom,
|
|
keyBindings,
|
|
updateKeyBinding,
|
|
resetKeyBinding,
|
|
resetAllKeyBindings,
|
|
setIsHotkeyRecording,
|
|
} = props;
|
|
const { t } = useI18n();
|
|
|
|
const [recordingBindingId, setRecordingBindingId] = useState<string | null>(null);
|
|
const [recordingScheme, setRecordingScheme] = useState<"mac" | "pc" | null>(null);
|
|
|
|
const cancelRecording = useCallback(() => {
|
|
setRecordingBindingId(null);
|
|
setRecordingScheme(null);
|
|
}, []);
|
|
|
|
const getSpecialSuffix = useCallback(
|
|
(bindingId: string): string | null => {
|
|
const binding = keyBindings.find((b) => b.id === bindingId);
|
|
if (!binding) return null;
|
|
const currentKey = hotkeyScheme === "mac" ? binding.mac : binding.pc;
|
|
if (currentKey.includes("[1...9]")) return "[1...9]";
|
|
if (currentKey.includes("arrows")) return "arrows";
|
|
return null;
|
|
},
|
|
[keyBindings, hotkeyScheme],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!recordingBindingId || !recordingScheme) return;
|
|
|
|
const specialSuffix = getSpecialSuffix(recordingBindingId);
|
|
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
// Skip while app lock overlay is up so password keys never bind (Codex P2).
|
|
if (isAppLockOverlayActive()) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
if (e.key === "Escape") {
|
|
cancelRecording();
|
|
return;
|
|
}
|
|
|
|
if (specialSuffix) {
|
|
if (["Meta", "Control", "Alt", "Shift"].includes(e.key)) return;
|
|
|
|
const parts: string[] = [];
|
|
if (recordingScheme === "mac") {
|
|
if (e.metaKey) parts.push("⌘");
|
|
if (e.ctrlKey) parts.push("⌃");
|
|
if (e.altKey) parts.push("⌥");
|
|
if (e.shiftKey) parts.push("Shift");
|
|
} else {
|
|
if (e.ctrlKey) parts.push("Ctrl");
|
|
if (e.altKey) parts.push("Alt");
|
|
if (e.shiftKey) parts.push("Shift");
|
|
if (e.metaKey) parts.push("Win");
|
|
}
|
|
|
|
const modifierString = parts.length > 0 ? `${parts.join(" + ")} + ` : "";
|
|
const fullKeyString = modifierString + specialSuffix;
|
|
|
|
updateKeyBinding?.(recordingBindingId, recordingScheme, fullKeyString);
|
|
cancelRecording();
|
|
return;
|
|
}
|
|
|
|
if (["Meta", "Control", "Alt", "Shift"].includes(e.key)) return;
|
|
const keyString = keyEventToString(e, recordingScheme === "mac");
|
|
updateKeyBinding?.(recordingBindingId, recordingScheme, keyString);
|
|
cancelRecording();
|
|
};
|
|
|
|
const handleClick = () => {
|
|
cancelRecording();
|
|
};
|
|
|
|
const timer = setTimeout(() => {
|
|
window.addEventListener("click", handleClick, true);
|
|
}, 100);
|
|
|
|
window.addEventListener("keydown", handleKeyDown, true);
|
|
return () => {
|
|
clearTimeout(timer);
|
|
window.removeEventListener("keydown", handleKeyDown, true);
|
|
window.removeEventListener("click", handleClick, true);
|
|
};
|
|
}, [recordingBindingId, recordingScheme, updateKeyBinding, cancelRecording, getSpecialSuffix]);
|
|
|
|
useEffect(() => {
|
|
const isRecording = Boolean(recordingBindingId && recordingScheme);
|
|
setIsHotkeyRecording?.(isRecording);
|
|
return () => {
|
|
setIsHotkeyRecording?.(false);
|
|
};
|
|
}, [recordingBindingId, recordingScheme, setIsHotkeyRecording]);
|
|
|
|
const categories = useMemo(() => ["tabs", "terminal", "navigation", "app", "sftp"] as const, []);
|
|
|
|
return (
|
|
<SettingsTabContent value="shortcuts">
|
|
<SectionHeader title={t("settings.shortcuts.section.scheme")} />
|
|
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
|
|
<SettingRow
|
|
anchorId="shortcuts-scheme"
|
|
label={t("settings.shortcuts.scheme.label")}
|
|
description={t("settings.shortcuts.scheme.desc")}
|
|
>
|
|
<Select
|
|
value={hotkeyScheme}
|
|
options={[
|
|
{ value: "disabled", label: t("settings.shortcuts.scheme.disabled") },
|
|
{ value: "mac", label: t("settings.shortcuts.scheme.mac") },
|
|
{ value: "pc", label: t("settings.shortcuts.scheme.pc") },
|
|
]}
|
|
onChange={(v) => setHotkeyScheme(v as HotkeyScheme)}
|
|
className="w-32"
|
|
/>
|
|
</SettingRow>
|
|
<SettingRow
|
|
anchorId="shortcuts-disable-terminal-font-zoom"
|
|
label={t("settings.shortcuts.disableTerminalFontZoom.label")}
|
|
description={t("settings.shortcuts.disableTerminalFontZoom.desc")}
|
|
>
|
|
<Toggle
|
|
checked={disableTerminalFontZoom}
|
|
onChange={setDisableTerminalFontZoom}
|
|
/>
|
|
</SettingRow>
|
|
<SettingRow
|
|
anchorId="shortcuts-shell-only-tab-numbers"
|
|
label={t("settings.shortcuts.shellOnlyTabNumberShortcuts.label")}
|
|
description={t("settings.shortcuts.shellOnlyTabNumberShortcuts.desc")}
|
|
>
|
|
<Toggle
|
|
checked={shellOnlyTabNumberShortcuts}
|
|
onChange={setShellOnlyTabNumberShortcuts}
|
|
/>
|
|
</SettingRow>
|
|
<SettingRow
|
|
anchorId="shortcuts-show-tab-number-badges"
|
|
label={t("settings.shortcuts.showTabNumberBadges.label")}
|
|
description={t("settings.shortcuts.showTabNumberBadges.desc")}
|
|
>
|
|
<Toggle
|
|
checked={showTabNumberBadges}
|
|
onChange={setShowTabNumberBadges}
|
|
/>
|
|
</SettingRow>
|
|
</div>
|
|
|
|
{hotkeyScheme !== "disabled" && (
|
|
<>
|
|
<div className="flex items-center justify-between">
|
|
<SectionHeader
|
|
title={t("settings.shortcuts.section.custom")}
|
|
className="mb-0"
|
|
anchorId="shortcuts-section-custom"
|
|
/>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={resetAllKeyBindings}
|
|
className="text-xs gap-1"
|
|
>
|
|
<RotateCcw size={12} /> {t("settings.shortcuts.resetAll")}
|
|
</Button>
|
|
</div>
|
|
|
|
{categories.map((category) => {
|
|
const categoryBindings = keyBindings.filter((kb) => kb.category === category);
|
|
if (categoryBindings.length === 0) return null;
|
|
return (
|
|
<div key={category}>
|
|
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">
|
|
{t(`settings.shortcuts.category.${category}`)}
|
|
</h4>
|
|
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card">
|
|
{categoryBindings.map((binding) => {
|
|
const currentKey = hotkeyScheme === "mac" ? binding.mac : binding.pc;
|
|
const specialSuffix = currentKey.includes("[1...9]")
|
|
? "[1...9]"
|
|
: currentKey.includes("arrows")
|
|
? "arrows"
|
|
: null;
|
|
const isSpecialBinding = !!specialSuffix;
|
|
|
|
const modifierPrefix = isSpecialBinding
|
|
? currentKey.replace(specialSuffix!, "").trim().replace(/\+\s*$/, "").trim()
|
|
: null;
|
|
|
|
const isRecordingThis = recordingBindingId === binding.id;
|
|
const scheme = hotkeyScheme === "mac" ? "mac" : "pc";
|
|
|
|
return (
|
|
<div key={binding.id} className="flex items-center justify-between px-4 py-2">
|
|
<span className="text-sm">{t(`settings.shortcuts.binding.${binding.id}`) !== `settings.shortcuts.binding.${binding.id}` ? t(`settings.shortcuts.binding.${binding.id}`) : binding.label}</span>
|
|
<div className="flex items-center gap-2">
|
|
{isSpecialBinding ? (
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setRecordingBindingId(binding.id);
|
|
setRecordingScheme(scheme);
|
|
}}
|
|
className={cn(
|
|
"px-2 py-1 text-xs font-mono rounded border transition-colors min-w-[60px] text-center",
|
|
isRecordingThis
|
|
? "border-primary bg-primary/10 animate-pulse"
|
|
: "border-border hover:border-primary/50",
|
|
)}
|
|
>
|
|
{isRecordingThis
|
|
? t("settings.shortcuts.recording")
|
|
: modifierPrefix || t("settings.shortcuts.none")}
|
|
</button>
|
|
<span className="text-xs text-muted-foreground">+</span>
|
|
<span className="px-2 py-1 text-xs font-mono rounded border border-border bg-muted/30 text-muted-foreground">
|
|
{specialSuffix}
|
|
</span>
|
|
</div>
|
|
) : (
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setRecordingBindingId(binding.id);
|
|
setRecordingScheme(scheme);
|
|
}}
|
|
className={cn(
|
|
"px-2 py-1 text-xs font-mono rounded border transition-colors min-w-[80px] text-center",
|
|
isRecordingThis
|
|
? "border-primary bg-primary/10 animate-pulse"
|
|
: "border-border hover:border-primary/50",
|
|
)}
|
|
>
|
|
{isRecordingThis
|
|
? t("settings.shortcuts.recording")
|
|
: currentKey === "Disabled"
|
|
? t("settings.shortcuts.scheme.disabled")
|
|
: currentKey || t("settings.shortcuts.scheme.disabled")}
|
|
</button>
|
|
)}
|
|
{!isSpecialBinding && (
|
|
<button
|
|
onClick={() => updateKeyBinding?.(binding.id, scheme, "Disabled")}
|
|
className="p-1 hover:bg-muted rounded"
|
|
aria-label={t("settings.shortcuts.setDisabled")}
|
|
>
|
|
<Ban size={12} />
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={() => resetKeyBinding?.(binding.id, scheme)}
|
|
className="p-1 hover:bg-muted rounded"
|
|
aria-label={t("settings.shortcuts.resetToDefault")}
|
|
>
|
|
<RotateCcw size={12} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</>
|
|
)}
|
|
{hotkeyScheme === "disabled" && (
|
|
<SettingsAnchor anchorId="shortcuts-section-custom" />
|
|
)}
|
|
</SettingsTabContent>
|
|
);
|
|
}
|