Put every shortcut on one shared keymap and make the app keyboard-reachable
The keymap was scattered over ShortcutsService if-chains, the file panel's key switch, macOS-only menu accelerators and hand-typed hints in 16 tooltips x 7 locales, and large parts of the app had no keyboard path at all. src/shared/keymap.ts is now the one table: scopes, per-platform defaults, exact matching, formatting with localized key names, conflicts, reserved combos and native-menu accelerators, proven by keymap.spec.ts. - ShortcutsService resolves against the effective keymap: claim() takes the key (preventDefault, held keys repeat only movement, never mid-IME), and from a text field only chords and F-keys fire. The file panel, App, editor, manager and Settings windows all dispatch through it. - FileActionsService.run is the one file-command dispatcher for the list keys, the row menu, the macOS menu bar and the toolbar. - New keys: the row menu (Shift+F10 / Menu / Ctrl+Return), the source picker (Mod+K), the path bar (Mod+L), transfers (Mod+J), sorting (Alt+1-7), Shift+movement selection, close other tabs, move and disconnect tabs, refresh and edit on Windows/Linux, permissions, copy path, reveal, trash, open in default app, editor find next/previous. - Right-click menus: soft shortcut hints, icons on the everyday verbs (the toolbar's glyphs), section headers, scrolling, item keys while open, a highlighted first item when opened from the keyboard. The panel source picker now renders through the same menu. - Dialogs: role=dialog, a Tab trap via cycleFocus (WKWebView skips buttons on Tab without Keyboard navigation), initial focus, Esc that stops propagating; the chmod dialog takes focus and applies on Enter. - Fixed: Ctrl+Tab also flipped the active panel; Esc in a manager dialog closed the manager; Ctrl-F/T fired Cmd shortcuts on macOS; shortcuts fired under dialogs; keys died after any button click on macOS; the editor's save prompt and find box never got focus; WebView2 reload keys; a window could save default settings before its first load. - Settings closes on its own close-window key (new settingsClose request). Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -467,6 +467,8 @@ export interface SettingsRpcHooks {
|
||||
/** Every edit here is a settings save — rebroadcast it to the other windows. */
|
||||
onSettingsSaved: (next: AppSettings) => void;
|
||||
showAbout: () => void;
|
||||
/** The window's own close-window key. */
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
/** RPC for the single Settings window (per-window instance). */
|
||||
@@ -483,6 +485,11 @@ export function createSettingsRPC(deps: { settings: SettingsStore }, hooks: Sett
|
||||
hooks.showAbout();
|
||||
return {};
|
||||
}),
|
||||
settingsClose: () =>
|
||||
wrap(async () => {
|
||||
hooks.close();
|
||||
return {};
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -23,9 +23,9 @@ export interface SettingsWindowDeps {
|
||||
/**
|
||||
* The single Settings window. Unlike the connection manager it is NOT modal:
|
||||
* every edit is an ordinary settings save, so the other windows follow live
|
||||
* while it stays open. On macOS ⌘W reaches bun as the main window's close-tab
|
||||
* menu action, so index.ts asks isFocused() first and closes this window
|
||||
* instead of a main-window tab.
|
||||
* while it stays open. Its page claims its own close-window key (settingsClose).
|
||||
* A ⌘W it doesn't claim (remapped away) falls through to the app menu's Close
|
||||
* Tab, which index.ts routes here while this window is focused.
|
||||
*/
|
||||
export class SettingsWindow {
|
||||
private win: BrowserWindow | null = null;
|
||||
@@ -49,6 +49,7 @@ export class SettingsWindow {
|
||||
{
|
||||
onSettingsSaved: (next) => this.deps.onSettingsSaved(next),
|
||||
showAbout: () => this.deps.showAbout(),
|
||||
close: () => this.close(),
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { sanitizeKeybindings } from "../../shared/keymap";
|
||||
import { clampUiScale, DEFAULT_SETTINGS, type AppSettings } from "../../shared/types";
|
||||
import { appDataDir } from "./paths";
|
||||
|
||||
@@ -24,6 +25,9 @@ export class SettingsStore {
|
||||
// into every window's root font-size, so a hand-edited settings.json
|
||||
// (uiScale: 100, "2", NaN) must not be able to render the UI unusable
|
||||
settings.uiScale = clampUiScale(Number(settings.uiScale));
|
||||
// same reasoning: every window resolves keys through this map
|
||||
settings.keybindings = sanitizeKeybindings(data.keybindings);
|
||||
settings.openFilesOnEnter = data.openFilesOnEnter === true;
|
||||
return settings;
|
||||
} catch {
|
||||
return { ...DEFAULT_SETTINGS };
|
||||
|
||||
+34
-14
@@ -21,8 +21,8 @@ export const de = {
|
||||
tabBar: {
|
||||
connections: "Verbindungen",
|
||||
manageConnections: "Verbindungen verwalten",
|
||||
newTab: "Neuer Tab ({mod}T)",
|
||||
closeTab: "Tab schließen ({mod}W)",
|
||||
newTab: "Neuer Tab",
|
||||
closeTab: "Tab schließen",
|
||||
options: "Einstellungen",
|
||||
update: "Update verfügbar",
|
||||
updating: "Aktualisiere",
|
||||
@@ -75,17 +75,16 @@ export const de = {
|
||||
panel: {
|
||||
choosePanel: "Wählen, was dieses Panel anzeigt",
|
||||
copy: "Senden",
|
||||
copyToRight: "An rechtes Panel senden (F5)",
|
||||
copyToLeft: "An linkes Panel senden (F5)",
|
||||
copyToRight: "An rechtes Panel senden",
|
||||
copyToLeft: "An linkes Panel senden",
|
||||
edit: "Bearbeiten",
|
||||
editFile: "Datei bearbeiten (F4)",
|
||||
rename: "Umbenennen (F2)",
|
||||
newFolder: "Neuer Ordner (F7)",
|
||||
editFile: "Datei bearbeiten",
|
||||
rename: "Umbenennen",
|
||||
newFolder: "Neuer Ordner",
|
||||
newFile: "Neue Datei",
|
||||
permissions: "Berechtigungen",
|
||||
delete: "Löschen",
|
||||
deleteHint: "Löschen (F8 / Entf)",
|
||||
parentDir: "Übergeordneter Ordner (Rücktaste)",
|
||||
parentDir: "Übergeordneter Ordner",
|
||||
refresh: "Aktualisieren",
|
||||
clearFilter: "Filter löschen (Esc)",
|
||||
sshConfig: "~/.ssh/config",
|
||||
@@ -144,16 +143,16 @@ export const de = {
|
||||
noFileTitle: "Keine Datei zum Bearbeiten",
|
||||
noFileBody: "Dieses Fenster wurde ohne Editor-Kontext geöffnet. Öffne Dateien aus der App.",
|
||||
wrap: "Umbruch",
|
||||
wrapHint: "Lange Zeilen umbrechen — blendet Zeilennummern aus ({alt}Z)",
|
||||
findHint: "Suchen ({mod}F)",
|
||||
wrapHint: "Lange Zeilen umbrechen — blendet Zeilennummern aus",
|
||||
findHint: "Suchen",
|
||||
findPlaceholder: "In Datei suchen",
|
||||
prevMatch: "Vorheriger Treffer (⇧↵)",
|
||||
nextMatch: "Nächster Treffer (↵)",
|
||||
prevMatch: "Vorheriger Treffer",
|
||||
nextMatch: "Nächster Treffer",
|
||||
closeHint: "Schließen (Esc)",
|
||||
disconnected: "Verbindung zu {name} getrennt — Speichern schlägt fehl, aber dein Text bleibt in diesem Fenster erhalten.",
|
||||
cantOpen: "{name} kann nicht geöffnet werden",
|
||||
closeWindow: "Fenster schließen",
|
||||
shortcuts: "{mod}S speichern · {mod}F suchen · {mod}W schließen",
|
||||
verbs: { save: "speichern", find: "suchen", close: "schließen" },
|
||||
lines: { one: "{count} Zeile", other: "{count} Zeilen" },
|
||||
close: "Schließen",
|
||||
save: "Speichern",
|
||||
@@ -209,6 +208,7 @@ export const de = {
|
||||
},
|
||||
toasts: {
|
||||
movedToTrash: '„{name}" in den Papierkorb verschoben',
|
||||
movedManyToTrash: { one: "{count} Objekt in den Papierkorb verschoben", other: "{count} Objekte in den Papierkorb verschoben" },
|
||||
editingExternal: "Bearbeite {name} — Speichern lädt automatisch hoch",
|
||||
nothingToCopy: "Nichts zu kopieren (symbolische Links werden übersprungen)",
|
||||
nothingToCopyAllSkipped: "Nichts zu kopieren — alle Konflikte wurden übersprungen",
|
||||
@@ -292,6 +292,26 @@ export const de = {
|
||||
tabDisconnect: "Trennen",
|
||||
trashTooltip: "Finders Wiederherstellen ist für von hier gelöschte Elemente nicht verfügbar",
|
||||
},
|
||||
keys: {
|
||||
names: {
|
||||
ctrl: "Strg",
|
||||
alt: "Alt",
|
||||
shift: "Umschalt",
|
||||
enter: "Eingabe",
|
||||
backspace: "Rücktaste",
|
||||
del: "Entf",
|
||||
esc: "Esc",
|
||||
space: "Leertaste",
|
||||
tab: "Tab",
|
||||
pgup: "Bild↑",
|
||||
pgdn: "Bild↓",
|
||||
home: "Pos1",
|
||||
end: "Ende",
|
||||
ins: "Einfg",
|
||||
menu: "Menü",
|
||||
num: "Num",
|
||||
},
|
||||
},
|
||||
errors: {
|
||||
sshAgentUnavailable: "ssh-agent nicht verfügbar (SSH_AUTH_SOCK ist nicht gesetzt)",
|
||||
identityFileNotFound: "Identitätsdatei nicht gefunden: {path}",
|
||||
|
||||
+110
-14
@@ -25,8 +25,8 @@ export const en = {
|
||||
tabBar: {
|
||||
connections: "Connections",
|
||||
manageConnections: "Manage connections",
|
||||
newTab: "New tab ({mod}T)",
|
||||
closeTab: "Close tab ({mod}W)",
|
||||
newTab: "New tab",
|
||||
closeTab: "Close tab",
|
||||
options: "Settings",
|
||||
update: "Update available",
|
||||
updating: "Updating",
|
||||
@@ -79,17 +79,16 @@ export const en = {
|
||||
panel: {
|
||||
choosePanel: "Choose what this panel shows",
|
||||
copy: "Send",
|
||||
copyToRight: "Send to right panel (F5)",
|
||||
copyToLeft: "Send to left panel (F5)",
|
||||
copyToRight: "Send to right panel",
|
||||
copyToLeft: "Send to left panel",
|
||||
edit: "Edit",
|
||||
editFile: "Edit file (F4)",
|
||||
rename: "Rename (F2)",
|
||||
newFolder: "New folder (F7)",
|
||||
editFile: "Edit file",
|
||||
rename: "Rename",
|
||||
newFolder: "New folder",
|
||||
newFile: "New file",
|
||||
permissions: "Permissions",
|
||||
delete: "Delete",
|
||||
deleteHint: "Delete (F8 / Del)",
|
||||
parentDir: "Parent directory (Backspace)",
|
||||
parentDir: "Parent directory",
|
||||
refresh: "Refresh",
|
||||
clearFilter: "Clear filter (Esc)",
|
||||
sshConfig: "~/.ssh/config",
|
||||
@@ -148,16 +147,17 @@ export const en = {
|
||||
noFileTitle: "No file to edit",
|
||||
noFileBody: "This window was opened without an editor context. Launch files from the app.",
|
||||
wrap: "Wrap",
|
||||
wrapHint: "Wrap long lines — hides line numbers ({alt}Z)",
|
||||
findHint: "Find ({mod}F)",
|
||||
wrapHint: "Wrap long lines — hides line numbers",
|
||||
findHint: "Find",
|
||||
findPlaceholder: "Find in file",
|
||||
prevMatch: "Previous match (⇧↵)",
|
||||
nextMatch: "Next match (↵)",
|
||||
prevMatch: "Previous match",
|
||||
nextMatch: "Next match",
|
||||
closeHint: "Close (Esc)",
|
||||
disconnected: "Disconnected from {name} — saving will fail, but your text is preserved in this window.",
|
||||
cantOpen: "Can't open {name}",
|
||||
closeWindow: "Close Window",
|
||||
shortcuts: "{mod}S save · {mod}F find · {mod}W close",
|
||||
// footer hint: "⌘S save · ⌘F find · ⌘W close", keys from the keymap
|
||||
verbs: { save: "save", find: "find", close: "close" },
|
||||
lines: { one: "{count} line", other: "{count} lines" },
|
||||
close: "Close",
|
||||
save: "Save",
|
||||
@@ -213,6 +213,7 @@ export const en = {
|
||||
},
|
||||
toasts: {
|
||||
movedToTrash: 'Moved "{name}" to Trash',
|
||||
movedManyToTrash: { one: "Moved {count} item to Trash", other: "Moved {count} items to Trash" },
|
||||
editingExternal: "Editing {name} — saves upload automatically",
|
||||
nothingToCopy: "Nothing to copy (symlinks are skipped)",
|
||||
nothingToCopyAllSkipped: "Nothing to copy — all conflicting items were skipped",
|
||||
@@ -296,6 +297,101 @@ export const en = {
|
||||
tabDisconnect: "Disconnect",
|
||||
trashTooltip: "Finder's Restore is not available for items trashed from here",
|
||||
},
|
||||
// Keyboard commands — one label per src/shared/keymap.ts BINDINGS id
|
||||
keys: {
|
||||
"cursor-up": "Move up",
|
||||
"cursor-down": "Move down",
|
||||
"page-up": "Page up",
|
||||
"page-down": "Page down",
|
||||
"cursor-first": "First item",
|
||||
"cursor-last": "Last item",
|
||||
"toggle-select": "Select / deselect",
|
||||
"select-all": "Select all",
|
||||
open: "Open",
|
||||
parent: "Parent folder",
|
||||
"switch-panel": "Switch panel",
|
||||
"row-menu": "Show the item's menu",
|
||||
send: "Send to the other panel",
|
||||
edit: "Edit",
|
||||
rename: "Rename",
|
||||
"new-folder": "New folder",
|
||||
"new-file": "New file",
|
||||
chmod: "Permissions",
|
||||
delete: "Delete",
|
||||
trash: "Move to Trash",
|
||||
"copy-path": "Copy path",
|
||||
reveal: "Reveal in file manager",
|
||||
"open-default": "Open in default app",
|
||||
refresh: "Refresh",
|
||||
"focus-path": "Go to path",
|
||||
"source-picker": "Choose what the panel shows",
|
||||
"sort-name": "Sort by name",
|
||||
"sort-size": "Sort by size",
|
||||
"sort-mtime": "Sort by modified",
|
||||
"sort-kind": "Sort by kind",
|
||||
"sort-rights": "Sort by rights",
|
||||
"sort-owner": "Sort by owner",
|
||||
"sort-group": "Sort by group",
|
||||
"new-tab": "New tab",
|
||||
"close-tab": "Close tab",
|
||||
"close-other-tabs": "Close other tabs",
|
||||
"close-tabs-right": "Close tabs to the right",
|
||||
"next-tab": "Next tab",
|
||||
"prev-tab": "Previous tab",
|
||||
"go-to-tab": "Go to tab {n}",
|
||||
"go-to-last-tab": "Go to last tab",
|
||||
"move-tab-left": "Move tab left",
|
||||
"move-tab-right": "Move tab right",
|
||||
"disconnect-tab": "Disconnect tab",
|
||||
"open-settings": "Settings",
|
||||
"connection-manager": "Connection manager",
|
||||
"toggle-transfers": "Show / hide transfers",
|
||||
"install-update": "Install update",
|
||||
"scale-up": "Bigger interface",
|
||||
"scale-down": "Smaller interface",
|
||||
"scale-reset": "Interface at 100%",
|
||||
"close-window": "Close window",
|
||||
save: "Save",
|
||||
find: "Find",
|
||||
"find-next": "Find next",
|
||||
"find-prev": "Find previous",
|
||||
"toggle-wrap": "Wrap lines",
|
||||
"conn-new": "New connection",
|
||||
"conn-new-stack": "New stack",
|
||||
"conn-save": "Save connection",
|
||||
"conn-connect": "Connect / open stack",
|
||||
"conn-delete": "Delete connection or stack",
|
||||
"conn-rename-stack": "Rename stack",
|
||||
"conn-import": "Import from WinSCP",
|
||||
groups: {
|
||||
panels: "File list",
|
||||
files: "Files",
|
||||
sort: "Sorting",
|
||||
tabs: "Tabs",
|
||||
window: "Window",
|
||||
editor: "Editor",
|
||||
connections: "Connection manager",
|
||||
},
|
||||
// key names for "Ctrl+Shift+Del"-style hints (not used on macOS, which shows ⌘⇧⌫)
|
||||
names: {
|
||||
ctrl: "Ctrl",
|
||||
alt: "Alt",
|
||||
shift: "Shift",
|
||||
enter: "Enter",
|
||||
backspace: "Backspace",
|
||||
del: "Del",
|
||||
esc: "Esc",
|
||||
space: "Space",
|
||||
tab: "Tab",
|
||||
pgup: "PgUp",
|
||||
pgdn: "PgDn",
|
||||
home: "Home",
|
||||
end: "End",
|
||||
ins: "Ins",
|
||||
menu: "Menu",
|
||||
num: "Num",
|
||||
},
|
||||
},
|
||||
errors: {
|
||||
sshAgentUnavailable: "ssh-agent not available (SSH_AUTH_SOCK is not set)",
|
||||
identityFileNotFound: "identity file not found: {path}",
|
||||
|
||||
+34
-14
@@ -21,8 +21,8 @@ export const es = {
|
||||
tabBar: {
|
||||
connections: "Conexiones",
|
||||
manageConnections: "Gestionar conexiones",
|
||||
newTab: "Nueva pestaña ({mod}T)",
|
||||
closeTab: "Cerrar pestaña ({mod}W)",
|
||||
newTab: "Nueva pestaña",
|
||||
closeTab: "Cerrar pestaña",
|
||||
options: "Ajustes",
|
||||
update: "Actualización disponible",
|
||||
updating: "Actualizando",
|
||||
@@ -75,17 +75,16 @@ export const es = {
|
||||
panel: {
|
||||
choosePanel: "Elige qué muestra este panel",
|
||||
copy: "Enviar",
|
||||
copyToRight: "Enviar al panel derecho (F5)",
|
||||
copyToLeft: "Enviar al panel izquierdo (F5)",
|
||||
copyToRight: "Enviar al panel derecho",
|
||||
copyToLeft: "Enviar al panel izquierdo",
|
||||
edit: "Editar",
|
||||
editFile: "Editar archivo (F4)",
|
||||
rename: "Renombrar (F2)",
|
||||
newFolder: "Nueva carpeta (F7)",
|
||||
editFile: "Editar archivo",
|
||||
rename: "Renombrar",
|
||||
newFolder: "Nueva carpeta",
|
||||
newFile: "Nuevo archivo",
|
||||
permissions: "Permisos",
|
||||
delete: "Eliminar",
|
||||
deleteHint: "Eliminar (F8 / Supr)",
|
||||
parentDir: "Carpeta superior (Retroceso)",
|
||||
parentDir: "Carpeta superior",
|
||||
refresh: "Actualizar",
|
||||
clearFilter: "Borrar filtro (Esc)",
|
||||
sshConfig: "~/.ssh/config",
|
||||
@@ -144,16 +143,16 @@ export const es = {
|
||||
noFileTitle: "Ningún archivo que editar",
|
||||
noFileBody: "Esta ventana se abrió sin contexto de editor. Abre archivos desde la aplicación.",
|
||||
wrap: "Ajustar",
|
||||
wrapHint: "Ajustar líneas largas — oculta los números de línea ({alt}Z)",
|
||||
findHint: "Buscar ({mod}F)",
|
||||
wrapHint: "Ajustar líneas largas — oculta los números de línea",
|
||||
findHint: "Buscar",
|
||||
findPlaceholder: "Buscar en el archivo",
|
||||
prevMatch: "Coincidencia anterior (⇧↵)",
|
||||
nextMatch: "Coincidencia siguiente (↵)",
|
||||
prevMatch: "Coincidencia anterior",
|
||||
nextMatch: "Coincidencia siguiente",
|
||||
closeHint: "Cerrar (Esc)",
|
||||
disconnected: "Desconectado de {name} — no se podrá guardar, pero tu texto se conserva en esta ventana.",
|
||||
cantOpen: "No se puede abrir {name}",
|
||||
closeWindow: "Cerrar ventana",
|
||||
shortcuts: "{mod}S guardar · {mod}F buscar · {mod}W cerrar",
|
||||
verbs: { save: "guardar", find: "buscar", close: "cerrar" },
|
||||
lines: { one: "{count} línea", other: "{count} líneas" },
|
||||
close: "Cerrar",
|
||||
save: "Guardar",
|
||||
@@ -209,6 +208,7 @@ export const es = {
|
||||
},
|
||||
toasts: {
|
||||
movedToTrash: 'Se movió "{name}" a la papelera',
|
||||
movedManyToTrash: { one: "Se movió {count} elemento a la papelera", other: "Se movieron {count} elementos a la papelera" },
|
||||
editingExternal: "Editando {name} — los cambios se suben automáticamente",
|
||||
nothingToCopy: "Nada que copiar (los enlaces simbólicos se omiten)",
|
||||
nothingToCopyAllSkipped: "Nada que copiar — se omitieron todos los elementos en conflicto",
|
||||
@@ -292,6 +292,26 @@ export const es = {
|
||||
tabDisconnect: "Desconectar",
|
||||
trashTooltip: "La opción Restaurar de Finder no está disponible para elementos enviados a la papelera desde aquí",
|
||||
},
|
||||
keys: {
|
||||
names: {
|
||||
ctrl: "Ctrl",
|
||||
alt: "Alt",
|
||||
shift: "Mayús",
|
||||
enter: "Intro",
|
||||
backspace: "Retroceso",
|
||||
del: "Supr",
|
||||
esc: "Esc",
|
||||
space: "Espacio",
|
||||
tab: "Tab",
|
||||
pgup: "RePág",
|
||||
pgdn: "AvPág",
|
||||
home: "Inicio",
|
||||
end: "Fin",
|
||||
ins: "Insert",
|
||||
menu: "Menú",
|
||||
num: "Num",
|
||||
},
|
||||
},
|
||||
errors: {
|
||||
sshAgentUnavailable: "ssh-agent no disponible (SSH_AUTH_SOCK no está definido)",
|
||||
identityFileNotFound: "archivo de identidad no encontrado: {path}",
|
||||
|
||||
+34
-14
@@ -23,8 +23,8 @@ export const eu = {
|
||||
tabBar: {
|
||||
connections: "Konexioak",
|
||||
manageConnections: "Kudeatu konexioak",
|
||||
newTab: "Fitxa berria ({mod}T)",
|
||||
closeTab: "Itxi fitxa ({mod}W)",
|
||||
newTab: "Fitxa berria",
|
||||
closeTab: "Itxi fitxa",
|
||||
options: "Ezarpenak",
|
||||
update: "Eguneratzea eskuragarri",
|
||||
updating: "Eguneratzen",
|
||||
@@ -77,17 +77,16 @@ export const eu = {
|
||||
panel: {
|
||||
choosePanel: "Aukeratu panel honek zer erakusten duen",
|
||||
copy: "Bidali",
|
||||
copyToRight: "Bidali eskuineko panelera (F5)",
|
||||
copyToLeft: "Bidali ezkerreko panelera (F5)",
|
||||
copyToRight: "Bidali eskuineko panelera",
|
||||
copyToLeft: "Bidali ezkerreko panelera",
|
||||
edit: "Editatu",
|
||||
editFile: "Editatu fitxategia (F4)",
|
||||
rename: "Berrizendatu (F2)",
|
||||
newFolder: "Karpeta berria (F7)",
|
||||
editFile: "Editatu fitxategia",
|
||||
rename: "Berrizendatu",
|
||||
newFolder: "Karpeta berria",
|
||||
newFile: "Fitxategi berria",
|
||||
permissions: "Baimenak",
|
||||
delete: "Ezabatu",
|
||||
deleteHint: "Ezabatu (F8 / Del)",
|
||||
parentDir: "Karpeta gurasoa (Atzera-tekla)",
|
||||
parentDir: "Karpeta gurasoa",
|
||||
refresh: "Freskatu",
|
||||
clearFilter: "Garbitu iragazkia (Esc)",
|
||||
sshConfig: "~/.ssh/config",
|
||||
@@ -146,16 +145,16 @@ export const eu = {
|
||||
noFileTitle: "Ez dago editatzeko fitxategirik",
|
||||
noFileBody: "Leiho hau editore-testuingururik gabe ireki da. Ireki fitxategiak aplikaziotik.",
|
||||
wrap: "Doitu",
|
||||
wrapHint: "Doitu lerro luzeak — lerro-zenbakiak ezkutatzen ditu ({alt}Z)",
|
||||
findHint: "Bilatu ({mod}F)",
|
||||
wrapHint: "Doitu lerro luzeak — lerro-zenbakiak ezkutatzen ditu",
|
||||
findHint: "Bilatu",
|
||||
findPlaceholder: "Bilatu fitxategian",
|
||||
prevMatch: "Aurreko bat-etortzea (⇧↵)",
|
||||
nextMatch: "Hurrengo bat-etortzea (↵)",
|
||||
prevMatch: "Aurreko bat-etortzea",
|
||||
nextMatch: "Hurrengo bat-etortzea",
|
||||
closeHint: "Itxi (Esc)",
|
||||
disconnected: "{name}(e)tik deskonektatuta — gordetzeak huts egingo du, baina zure testua leiho honetan gordeta dago.",
|
||||
cantOpen: "Ezin da {name} ireki",
|
||||
closeWindow: "Itxi leihoa",
|
||||
shortcuts: "{mod}S gorde · {mod}F bilatu · {mod}W itxi",
|
||||
verbs: { save: "gorde", find: "bilatu", close: "itxi" },
|
||||
lines: { one: "lerro {count}", other: "{count} lerro" },
|
||||
close: "Itxi",
|
||||
save: "Gorde",
|
||||
@@ -211,6 +210,7 @@ export const eu = {
|
||||
},
|
||||
toasts: {
|
||||
movedToTrash: '"{name}" zakarrontzira eraman da',
|
||||
movedManyToTrash: { one: "{count} elementu zakarrontzira eraman da", other: "{count} elementu zakarrontzira eraman dira" },
|
||||
editingExternal: "{name} editatzen — gordetzean automatikoki igotzen da",
|
||||
nothingToCopy: "Ez dago kopiatzeko ezer (esteka sinbolikoak saltatu egiten dira)",
|
||||
nothingToCopyAllSkipped: "Ez dago kopiatzeko ezer — gatazkan zeuden elementu guztiak saltatu dira",
|
||||
@@ -294,6 +294,26 @@ export const eu = {
|
||||
tabDisconnect: "Deskonektatu",
|
||||
trashTooltip: "Finder-en «Jarri berriro» ez dago erabilgarri hemendik zakarrontzira botatako elementuentzat",
|
||||
},
|
||||
keys: {
|
||||
names: {
|
||||
ctrl: "Ctrl",
|
||||
alt: "Alt",
|
||||
shift: "Maius",
|
||||
enter: "Intro",
|
||||
backspace: "Atzera",
|
||||
del: "Supr",
|
||||
esc: "Esc",
|
||||
space: "Zuriunea",
|
||||
tab: "Tab",
|
||||
pgup: "RePág",
|
||||
pgdn: "AvPág",
|
||||
home: "Inicio",
|
||||
end: "Fin",
|
||||
ins: "Insert",
|
||||
menu: "Menua",
|
||||
num: "Num",
|
||||
},
|
||||
},
|
||||
errors: {
|
||||
sshAgentUnavailable: "ssh-agent ez dago erabilgarri (SSH_AUTH_SOCK ez dago ezarrita)",
|
||||
identityFileNotFound: "identitate-fitxategia ez da aurkitu: {path}",
|
||||
|
||||
+34
-14
@@ -21,8 +21,8 @@ export const fr = {
|
||||
tabBar: {
|
||||
connections: "Connexions",
|
||||
manageConnections: "Gérer les connexions",
|
||||
newTab: "Nouvel onglet ({mod}T)",
|
||||
closeTab: "Fermer l'onglet ({mod}W)",
|
||||
newTab: "Nouvel onglet",
|
||||
closeTab: "Fermer l'onglet",
|
||||
options: "Réglages",
|
||||
update: "Mise à jour disponible",
|
||||
updating: "Mise à jour",
|
||||
@@ -75,17 +75,16 @@ export const fr = {
|
||||
panel: {
|
||||
choosePanel: "Choisir ce qu'affiche ce panneau",
|
||||
copy: "Envoyer",
|
||||
copyToRight: "Envoyer vers le panneau droit (F5)",
|
||||
copyToLeft: "Envoyer vers le panneau gauche (F5)",
|
||||
copyToRight: "Envoyer vers le panneau droit",
|
||||
copyToLeft: "Envoyer vers le panneau gauche",
|
||||
edit: "Éditer",
|
||||
editFile: "Éditer le fichier (F4)",
|
||||
rename: "Renommer (F2)",
|
||||
newFolder: "Nouveau dossier (F7)",
|
||||
editFile: "Éditer le fichier",
|
||||
rename: "Renommer",
|
||||
newFolder: "Nouveau dossier",
|
||||
newFile: "Nouveau fichier",
|
||||
permissions: "Autorisations",
|
||||
delete: "Supprimer",
|
||||
deleteHint: "Supprimer (F8 / Suppr)",
|
||||
parentDir: "Dossier parent (Retour arrière)",
|
||||
parentDir: "Dossier parent",
|
||||
refresh: "Actualiser",
|
||||
clearFilter: "Effacer le filtre (Échap)",
|
||||
sshConfig: "~/.ssh/config",
|
||||
@@ -144,16 +143,16 @@ export const fr = {
|
||||
noFileTitle: "Aucun fichier à éditer",
|
||||
noFileBody: "Cette fenêtre a été ouverte sans contexte d'éditeur. Ouvrez des fichiers depuis l'application.",
|
||||
wrap: "Retour",
|
||||
wrapHint: "Retour à la ligne — masque les numéros de ligne ({alt}Z)",
|
||||
findHint: "Rechercher ({mod}F)",
|
||||
wrapHint: "Retour à la ligne — masque les numéros de ligne",
|
||||
findHint: "Rechercher",
|
||||
findPlaceholder: "Rechercher dans le fichier",
|
||||
prevMatch: "Résultat précédent (⇧↵)",
|
||||
nextMatch: "Résultat suivant (↵)",
|
||||
prevMatch: "Résultat précédent",
|
||||
nextMatch: "Résultat suivant",
|
||||
closeHint: "Fermer (Échap)",
|
||||
disconnected: "Déconnecté de {name} — l'enregistrement échouera, mais votre texte est conservé dans cette fenêtre.",
|
||||
cantOpen: "Impossible d'ouvrir {name}",
|
||||
closeWindow: "Fermer la fenêtre",
|
||||
shortcuts: "{mod}S enregistrer · {mod}F rechercher · {mod}W fermer",
|
||||
verbs: { save: "enregistrer", find: "rechercher", close: "fermer" },
|
||||
lines: { one: "{count} ligne", other: "{count} lignes" },
|
||||
close: "Fermer",
|
||||
save: "Enregistrer",
|
||||
@@ -209,6 +208,7 @@ export const fr = {
|
||||
},
|
||||
toasts: {
|
||||
movedToTrash: '« {name} » mis à la corbeille',
|
||||
movedManyToTrash: { one: "{count} élément mis à la corbeille", other: "{count} éléments mis à la corbeille" },
|
||||
editingExternal: "Édition de {name} — les enregistrements sont envoyés automatiquement",
|
||||
nothingToCopy: "Rien à copier (les liens symboliques sont ignorés)",
|
||||
nothingToCopyAllSkipped: "Rien à copier — tous les éléments en conflit ont été ignorés",
|
||||
@@ -292,6 +292,26 @@ export const fr = {
|
||||
tabDisconnect: "Déconnecter",
|
||||
trashTooltip: "La restauration du Finder n'est pas disponible pour les éléments mis à la corbeille d'ici",
|
||||
},
|
||||
keys: {
|
||||
names: {
|
||||
ctrl: "Ctrl",
|
||||
alt: "Alt",
|
||||
shift: "Maj",
|
||||
enter: "Entrée",
|
||||
backspace: "Retour arrière",
|
||||
del: "Suppr",
|
||||
esc: "Échap",
|
||||
space: "Espace",
|
||||
tab: "Tab",
|
||||
pgup: "Pg préc",
|
||||
pgdn: "Pg suiv",
|
||||
home: "Début",
|
||||
end: "Fin",
|
||||
ins: "Inser",
|
||||
menu: "Menu",
|
||||
num: "Num",
|
||||
},
|
||||
},
|
||||
errors: {
|
||||
sshAgentUnavailable: "ssh-agent indisponible (SSH_AUTH_SOCK non défini)",
|
||||
identityFileNotFound: "fichier d'identité introuvable : {path}",
|
||||
|
||||
+34
-14
@@ -22,8 +22,8 @@ export const ja = {
|
||||
tabBar: {
|
||||
connections: "接続",
|
||||
manageConnections: "接続を管理",
|
||||
newTab: "新しいタブ ({mod}T)",
|
||||
closeTab: "タブを閉じる ({mod}W)",
|
||||
newTab: "新しいタブ",
|
||||
closeTab: "タブを閉じる",
|
||||
options: "設定",
|
||||
update: "アップデートあり",
|
||||
updating: "更新中",
|
||||
@@ -76,17 +76,16 @@ export const ja = {
|
||||
panel: {
|
||||
choosePanel: "このパネルに表示する内容を選択",
|
||||
copy: "送信",
|
||||
copyToRight: "右のパネルへ送信 (F5)",
|
||||
copyToLeft: "左のパネルへ送信 (F5)",
|
||||
copyToRight: "右のパネルへ送信",
|
||||
copyToLeft: "左のパネルへ送信",
|
||||
edit: "編集",
|
||||
editFile: "ファイルを編集 (F4)",
|
||||
rename: "名前を変更 (F2)",
|
||||
newFolder: "新規フォルダ (F7)",
|
||||
editFile: "ファイルを編集",
|
||||
rename: "名前を変更",
|
||||
newFolder: "新規フォルダ",
|
||||
newFile: "新規ファイル",
|
||||
permissions: "アクセス権",
|
||||
delete: "削除",
|
||||
deleteHint: "削除 (F8 / Del)",
|
||||
parentDir: "親フォルダ (Backspace)",
|
||||
parentDir: "親フォルダ",
|
||||
refresh: "更新",
|
||||
clearFilter: "フィルターをクリア (Esc)",
|
||||
sshConfig: "~/.ssh/config",
|
||||
@@ -145,16 +144,16 @@ export const ja = {
|
||||
noFileTitle: "編集するファイルがありません",
|
||||
noFileBody: "このウィンドウはエディタコンテキストなしで開かれました。アプリからファイルを開いてください。",
|
||||
wrap: "折り返し",
|
||||
wrapHint: "長い行を折り返す — 行番号を非表示 ({alt}Z)",
|
||||
findHint: "検索 ({mod}F)",
|
||||
wrapHint: "長い行を折り返す — 行番号を非表示",
|
||||
findHint: "検索",
|
||||
findPlaceholder: "ファイル内を検索",
|
||||
prevMatch: "前の一致 (⇧↵)",
|
||||
nextMatch: "次の一致 (↵)",
|
||||
prevMatch: "前の一致",
|
||||
nextMatch: "次の一致",
|
||||
closeHint: "閉じる (Esc)",
|
||||
disconnected: "{name} から切断されました — 保存は失敗しますが、テキストはこのウィンドウに保持されます。",
|
||||
cantOpen: "{name} を開けません",
|
||||
closeWindow: "ウィンドウを閉じる",
|
||||
shortcuts: "{mod}S 保存 · {mod}F 検索 · {mod}W 閉じる",
|
||||
verbs: { save: "保存", find: "検索", close: "閉じる" },
|
||||
lines: { other: "{count} 行" },
|
||||
close: "閉じる",
|
||||
save: "保存",
|
||||
@@ -210,6 +209,7 @@ export const ja = {
|
||||
},
|
||||
toasts: {
|
||||
movedToTrash: '"{name}" をゴミ箱に移動しました',
|
||||
movedManyToTrash: { other: "{count} 個の項目をゴミ箱に移動しました" },
|
||||
editingExternal: "{name} を編集中 — 保存すると自動でアップロードされます",
|
||||
nothingToCopy: "コピーする項目がありません(シンボリックリンクはスキップされます)",
|
||||
nothingToCopyAllSkipped: "コピーする項目がありません — 競合する項目はすべてスキップされました",
|
||||
@@ -292,6 +292,26 @@ export const ja = {
|
||||
tabDisconnect: "切断",
|
||||
trashTooltip: "ここからゴミ箱に入れた項目は Finder の「戻す」を利用できません",
|
||||
},
|
||||
keys: {
|
||||
names: {
|
||||
ctrl: "Ctrl",
|
||||
alt: "Alt",
|
||||
shift: "Shift",
|
||||
enter: "Enter",
|
||||
backspace: "Backspace",
|
||||
del: "Del",
|
||||
esc: "Esc",
|
||||
space: "スペース",
|
||||
tab: "Tab",
|
||||
pgup: "PgUp",
|
||||
pgdn: "PgDn",
|
||||
home: "Home",
|
||||
end: "End",
|
||||
ins: "Ins",
|
||||
menu: "メニュー",
|
||||
num: "Num",
|
||||
},
|
||||
},
|
||||
errors: {
|
||||
sshAgentUnavailable: "ssh-agent が利用できません(SSH_AUTH_SOCK が未設定)",
|
||||
identityFileNotFound: "認証鍵ファイルが見つかりません: {path}",
|
||||
|
||||
+34
-14
@@ -22,8 +22,8 @@ export const zh = {
|
||||
tabBar: {
|
||||
connections: "连接",
|
||||
manageConnections: "管理连接",
|
||||
newTab: "新建标签页 ({mod}T)",
|
||||
closeTab: "关闭标签页 ({mod}W)",
|
||||
newTab: "新建标签页",
|
||||
closeTab: "关闭标签页",
|
||||
options: "设置",
|
||||
update: "有可用更新",
|
||||
updating: "正在更新",
|
||||
@@ -76,17 +76,16 @@ export const zh = {
|
||||
panel: {
|
||||
choosePanel: "选择此面板显示的内容",
|
||||
copy: "发送",
|
||||
copyToRight: "发送到右侧面板 (F5)",
|
||||
copyToLeft: "发送到左侧面板 (F5)",
|
||||
copyToRight: "发送到右侧面板",
|
||||
copyToLeft: "发送到左侧面板",
|
||||
edit: "编辑",
|
||||
editFile: "编辑文件 (F4)",
|
||||
rename: "重命名 (F2)",
|
||||
newFolder: "新建文件夹 (F7)",
|
||||
editFile: "编辑文件",
|
||||
rename: "重命名",
|
||||
newFolder: "新建文件夹",
|
||||
newFile: "新建文件",
|
||||
permissions: "权限",
|
||||
delete: "删除",
|
||||
deleteHint: "删除 (F8 / Del)",
|
||||
parentDir: "上级目录 (退格)",
|
||||
parentDir: "上级目录",
|
||||
refresh: "刷新",
|
||||
clearFilter: "清除筛选 (Esc)",
|
||||
sshConfig: "~/.ssh/config",
|
||||
@@ -145,16 +144,16 @@ export const zh = {
|
||||
noFileTitle: "没有要编辑的文件",
|
||||
noFileBody: "此窗口在没有编辑器上下文的情况下打开。请从应用中打开文件。",
|
||||
wrap: "换行",
|
||||
wrapHint: "长行自动换行 — 隐藏行号 ({alt}Z)",
|
||||
findHint: "查找 ({mod}F)",
|
||||
wrapHint: "长行自动换行 — 隐藏行号",
|
||||
findHint: "查找",
|
||||
findPlaceholder: "在文件中查找",
|
||||
prevMatch: "上一个匹配 (⇧↵)",
|
||||
nextMatch: "下一个匹配 (↵)",
|
||||
prevMatch: "上一个匹配",
|
||||
nextMatch: "下一个匹配",
|
||||
closeHint: "关闭 (Esc)",
|
||||
disconnected: "已从 {name} 断开 — 保存将失败,但你的文本会保留在此窗口中。",
|
||||
cantOpen: "无法打开 {name}",
|
||||
closeWindow: "关闭窗口",
|
||||
shortcuts: "{mod}S 保存 · {mod}F 查找 · {mod}W 关闭",
|
||||
verbs: { save: "保存", find: "查找", close: "关闭" },
|
||||
lines: { other: "{count} 行" },
|
||||
close: "关闭",
|
||||
save: "保存",
|
||||
@@ -210,6 +209,7 @@ export const zh = {
|
||||
},
|
||||
toasts: {
|
||||
movedToTrash: '已将 "{name}" 移到废纸篓',
|
||||
movedManyToTrash: { other: "已将 {count} 个项目移到废纸篓" },
|
||||
editingExternal: "正在编辑 {name} — 保存后会自动上传",
|
||||
nothingToCopy: "没有可复制的内容(符号链接将被跳过)",
|
||||
nothingToCopyAllSkipped: "没有可复制的内容 — 所有冲突项都被跳过",
|
||||
@@ -292,6 +292,26 @@ export const zh = {
|
||||
tabDisconnect: "断开连接",
|
||||
trashTooltip: "从此处移入废纸篓的项目无法使用访达的放回原处功能",
|
||||
},
|
||||
keys: {
|
||||
names: {
|
||||
ctrl: "Ctrl",
|
||||
alt: "Alt",
|
||||
shift: "Shift",
|
||||
enter: "Enter",
|
||||
backspace: "退格",
|
||||
del: "Del",
|
||||
esc: "Esc",
|
||||
space: "空格",
|
||||
tab: "Tab",
|
||||
pgup: "PgUp",
|
||||
pgdn: "PgDn",
|
||||
home: "Home",
|
||||
end: "End",
|
||||
ins: "Ins",
|
||||
menu: "菜单",
|
||||
num: "Num",
|
||||
},
|
||||
},
|
||||
errors: {
|
||||
sshAgentUnavailable: "ssh-agent 不可用(未设置 SSH_AUTH_SOCK)",
|
||||
identityFileNotFound: "找不到身份文件:{path}",
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { en } from "./i18n/en";
|
||||
import {
|
||||
BINDINGS,
|
||||
type Combo,
|
||||
comboFromEvent,
|
||||
conflicts,
|
||||
EN_KEY_NAMES,
|
||||
effectiveKeymap,
|
||||
firesInTextField,
|
||||
formatCombo,
|
||||
GROUPS,
|
||||
isReserved,
|
||||
type KeyEventLike,
|
||||
matchesEvent,
|
||||
NATIVE_MENU_IDS,
|
||||
parseCombo,
|
||||
serializeCombo,
|
||||
scopesOverlap,
|
||||
toAccelerator,
|
||||
} from "./keymap";
|
||||
|
||||
const US_CHARS: Record<string, string> = {
|
||||
Comma: ",",
|
||||
Period: ".",
|
||||
Slash: "/",
|
||||
Semicolon: ";",
|
||||
Quote: "'",
|
||||
BracketLeft: "[",
|
||||
BracketRight: "]",
|
||||
Backslash: "\\",
|
||||
Backquote: "`",
|
||||
Equal: "=",
|
||||
Minus: "-",
|
||||
NumpadAdd: "+",
|
||||
};
|
||||
|
||||
/** The keydown a US keyboard produces for `c`. */
|
||||
function eventFor(c: Combo, isMac: boolean): KeyEventLike {
|
||||
let key = c.key;
|
||||
let code = c.key;
|
||||
if (/^[A-Z]$/.test(c.key)) {
|
||||
key = c.shift ? c.key : c.key.toLowerCase();
|
||||
code = `Key${c.key}`;
|
||||
} else if (/^[0-9]$/.test(c.key)) {
|
||||
code = `Digit${c.key}`;
|
||||
} else if (c.key in US_CHARS) {
|
||||
key = US_CHARS[c.key];
|
||||
} else if (c.key === "Space") {
|
||||
key = " ";
|
||||
}
|
||||
return {
|
||||
key,
|
||||
code,
|
||||
metaKey: isMac && c.mod,
|
||||
ctrlKey: isMac ? c.ctrl : c.mod,
|
||||
altKey: c.alt,
|
||||
shiftKey: c.shift,
|
||||
};
|
||||
}
|
||||
|
||||
const ev = (over: Partial<KeyEventLike>): KeyEventLike => ({
|
||||
key: "",
|
||||
code: "",
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
const combo = (text: string, isMac: boolean) => {
|
||||
const c = parseCombo(text, isMac);
|
||||
if (!c) throw new Error(`unparsable ${text}`);
|
||||
return c;
|
||||
};
|
||||
|
||||
for (const isMac of [true, false]) {
|
||||
const os = isMac ? "macOS" : "Windows/Linux";
|
||||
const keymap = effectiveKeymap({}, isMac);
|
||||
|
||||
test(`${os}: the defaults have no conflicts`, () => {
|
||||
expect([...conflicts(keymap).entries()]).toEqual([]);
|
||||
});
|
||||
|
||||
test(`${os}: every default round-trips and resolves to its own command`, () => {
|
||||
for (const binding of keymap) {
|
||||
for (const c of binding.combos) {
|
||||
expect(parseCombo(serializeCombo(c), isMac)).toEqual(c);
|
||||
const event = eventFor(c, isMac);
|
||||
// table order is resolution order: nothing earlier may shadow it
|
||||
const hit = keymap.find(
|
||||
(other) => scopesOverlap(other.scopes, binding.scopes) && other.combos.some((oc) => matchesEvent(oc, event, isMac)),
|
||||
);
|
||||
expect(`${serializeCombo(c)} → ${hit?.id}`).toBe(`${serializeCombo(c)} → ${binding.id}`);
|
||||
expect(comboFromEvent(event, isMac)).toEqual(c);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test(`${os}: no default is a reserved combo`, () => {
|
||||
for (const binding of keymap) for (const c of binding.combos) expect(isReserved(c, isMac)).toBe(false);
|
||||
});
|
||||
|
||||
test(`${os}: every macOS menu command has an expressible accelerator`, () => {
|
||||
if (!isMac) return;
|
||||
for (const id of NATIVE_MENU_IDS) {
|
||||
const binding = keymap.find((b) => b.id === id)!;
|
||||
expect(binding.combos.some((c) => toAccelerator(c) !== null)).toBe(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("every command and group has an English label", () => {
|
||||
const keys = en.keys as Record<string, unknown>;
|
||||
for (const b of BINDINGS) expect(typeof keys["n" in b ? "go-to-tab" : b.id]).toBe("string");
|
||||
for (const g of GROUPS) expect(typeof en.keys.groups[g]).toBe("string");
|
||||
});
|
||||
|
||||
test("matching is exact about modifiers", () => {
|
||||
// ⌃F is Cocoa's forward-one-character, not ⌘F
|
||||
expect(matchesEvent(combo("Mod+F", true), ev({ key: "f", code: "KeyF", ctrlKey: true }), true)).toBe(false);
|
||||
expect(matchesEvent(combo("Mod+F", true), ev({ key: "f", code: "KeyF", metaKey: true }), true)).toBe(true);
|
||||
// Ctrl+Tab is not Tab (the old panel switch caught both)
|
||||
expect(matchesEvent(combo("Tab", false), ev({ key: "Tab", code: "Tab", ctrlKey: true }), false)).toBe(false);
|
||||
// the Windows key is never part of a binding
|
||||
expect(matchesEvent(combo("Mod+T", false), ev({ key: "t", code: "KeyT", ctrlKey: true, metaKey: true }), false)).toBe(false);
|
||||
});
|
||||
|
||||
test("letters follow the layout, Alt chords and other scripts use the physical key", () => {
|
||||
// AZERTY: the comma sits on the M key, the A on the Q key
|
||||
expect(comboFromEvent(ev({ key: ",", code: "KeyM", metaKey: true }), true)).toEqual(combo("Mod+Comma", true));
|
||||
expect(comboFromEvent(ev({ key: "a", code: "KeyQ", ctrlKey: true }), false)).toEqual(combo("Mod+A", false));
|
||||
// macOS Option turns ⌥Z into Ω
|
||||
expect(comboFromEvent(ev({ key: "Ω", code: "KeyZ", altKey: true }), true)).toEqual(combo("Alt+Z", true));
|
||||
// Cyrillic ⌘T arrives as "е" on the T key
|
||||
expect(matchesEvent(combo("Mod+T", true), ev({ key: "е", code: "KeyT", metaKey: true }), true)).toBe(true);
|
||||
// US ⌘+ is ⇧⌘= — matched by the physical key
|
||||
expect(matchesEvent(combo("Mod+Shift+Equal", true), ev({ key: "+", code: "Equal", metaKey: true, shiftKey: true }), true)).toBe(true);
|
||||
// AZERTY ⌘1 types "&" on the 1 key
|
||||
expect(comboFromEvent(ev({ key: "&", code: "Digit1", metaKey: true }), true)).toEqual(combo("Mod+1", true));
|
||||
// a bare modifier press is not a combo
|
||||
expect(comboFromEvent(ev({ key: "Shift", code: "ShiftLeft", shiftKey: true }), true)).toBeNull();
|
||||
});
|
||||
|
||||
test("formatting per platform, with localized key names off macOS", () => {
|
||||
expect(formatCombo(combo("Mod+Shift+N", true), true)).toBe("⇧⌘N");
|
||||
expect(formatCombo(combo("Ctrl+Tab", true), true)).toBe("⌃⇥");
|
||||
expect(formatCombo(combo("Mod+Alt+C", true), true)).toBe("⌥⌘C");
|
||||
expect(formatCombo(combo("Mod+Comma", true), true)).toBe("⌘,");
|
||||
expect(formatCombo(combo("Shift+F10", true), true)).toBe("⇧F10");
|
||||
expect(formatCombo(combo("Mod+Backspace", true), true)).toBe("⌘⌫");
|
||||
expect(formatCombo(combo("Mod+Shift+N", false), false)).toBe("Ctrl+Shift+N");
|
||||
expect(formatCombo(combo("Alt+Shift+R", false), false)).toBe("Alt+Shift+R");
|
||||
expect(formatCombo(combo("Delete", false), false)).toBe("Del");
|
||||
const de = { ...EN_KEY_NAMES, ctrl: "Strg", del: "Entf" };
|
||||
expect(formatCombo(combo("Mod+Delete", false), false, de)).toBe("Strg+Entf");
|
||||
});
|
||||
|
||||
test("overrides replace a row; garbage is dropped", () => {
|
||||
const keymap = effectiveKeymap(
|
||||
{ rename: ["Mod+Shift+R", "nonsense", 5, "Mod+Shift+R"], save: "F9", bogus: ["F1"], delete: [] },
|
||||
true,
|
||||
);
|
||||
const by = (id: string) => keymap.find((b) => b.id === id)!;
|
||||
expect(by("rename").combos.map(serializeCombo)).toEqual(["Mod+Shift+R"]);
|
||||
expect(by("rename").custom).toBe(true);
|
||||
// not an array: ignored, the defaults stand
|
||||
expect(by("save").custom).toBe(false);
|
||||
expect(by("save").combos.map(serializeCombo)).toEqual(["Mod+S"]);
|
||||
// [] unbinds
|
||||
expect(by("delete").combos).toEqual([]);
|
||||
expect(keymap.some((b) => (b.id as string) === "bogus")).toBe(false);
|
||||
// not an object at all
|
||||
expect(effectiveKeymap("junk", true).every((b) => !b.custom)).toBe(true);
|
||||
});
|
||||
|
||||
test("off macOS a stored literal Ctrl folds into Mod", () => {
|
||||
expect(parseCombo("Ctrl+Tab", false)).toEqual(parseCombo("Mod+Tab", false));
|
||||
expect(parseCombo("Ctrl+Tab", true)).not.toEqual(parseCombo("Mod+Tab", true));
|
||||
});
|
||||
|
||||
test("native menu accelerators", () => {
|
||||
expect(toAccelerator(combo("Mod+Shift+N", true))).toBe("Command+Shift+n");
|
||||
expect(toAccelerator(combo("Mod+Comma", true))).toBe("Command+,");
|
||||
expect(toAccelerator(combo("Mod+Minus", true))).toBe("Command+minus");
|
||||
expect(toAccelerator(combo("Ctrl+Shift+PageUp", true))).toBe("Control+Shift+pageup");
|
||||
// a bare key would mean ⌘+key to the native parser
|
||||
expect(toAccelerator(combo("F5", true))).toBeNull();
|
||||
// ⌫ / ⌦, Shift+punctuation and modifier-less combos have no faithful form
|
||||
expect(toAccelerator(combo("Mod+Delete", true))).toBeNull();
|
||||
expect(toAccelerator(combo("Mod+Backspace", true))).toBeNull();
|
||||
expect(toAccelerator(combo("Mod+Shift+BracketRight", true))).toBeNull();
|
||||
expect(toAccelerator(combo("Shift+F4", true))).toBeNull();
|
||||
});
|
||||
|
||||
test("text fields keep their own keys", () => {
|
||||
// plain keys and text-editing chords type / edit
|
||||
expect(firesInTextField(combo("Delete", true), true)).toBe(false);
|
||||
expect(firesInTextField(combo("Mod+Backspace", true), true)).toBe(false);
|
||||
expect(firesInTextField(combo("Mod+A", true), true)).toBe(false);
|
||||
expect(firesInTextField(combo("Alt+ArrowLeft", true), true)).toBe(false);
|
||||
// app chords and F-keys still fire (⌘S in the editor, ⌘↩ in the manager form)
|
||||
expect(firesInTextField(combo("Mod+S", true), true)).toBe(true);
|
||||
expect(firesInTextField(combo("Mod+Enter", false), false)).toBe(true);
|
||||
expect(firesInTextField(combo("F3", false), false)).toBe(true);
|
||||
// Ctrl+Alt off macOS is AltGr typing a character
|
||||
expect(firesInTextField(combo("Mod+Alt+Q", false), false)).toBe(false);
|
||||
});
|
||||
|
||||
test("AltGr is never a shortcut off macOS", () => {
|
||||
const altGrQ = ev({ key: "@", code: "KeyQ", ctrlKey: true, altKey: true, getModifierState: (k) => k === "AltGraph" });
|
||||
expect(matchesEvent(combo("Mod+Alt+Q", false), altGrQ, false)).toBe(false);
|
||||
expect(comboFromEvent(altGrQ, false)).toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,547 @@
|
||||
// The app's one keymap: every keyboard command, where it fires and its default
|
||||
// keys per platform, plus the pure helpers that parse, match, format and check
|
||||
// key combos. Shared on purpose: the webviews resolve keys, show hints and edit
|
||||
// remaps against this table, and bun builds the macOS menu-bar accelerators
|
||||
// from it, so the two can never disagree. User remaps are stored as overrides
|
||||
// in AppSettings.keybindings and applied by effectiveKeymap().
|
||||
|
||||
/** Where a binding fires. `panel` = the focused file list, `main` = anywhere
|
||||
* in the main window, `any` = every window. `panel` sits inside `main`, so
|
||||
* the two overlap for conflict purposes. */
|
||||
export type Scope = "panel" | "main" | "editor" | "manager" | "settings" | "any";
|
||||
|
||||
/** Settings → Keyboard sections, in display order. */
|
||||
export const GROUPS = ["panels", "files", "sort", "tabs", "window", "editor", "connections"] as const;
|
||||
export type Group = (typeof GROUPS)[number];
|
||||
|
||||
interface BindingDef {
|
||||
readonly id: string;
|
||||
readonly group: Group;
|
||||
readonly scopes: readonly Scope[];
|
||||
/** Default combos, best first: the first is the one menus and tooltips show. */
|
||||
readonly mac: readonly string[];
|
||||
readonly other: readonly string[];
|
||||
/** go-to-tab-N: the N its label interpolates. */
|
||||
readonly n?: number;
|
||||
}
|
||||
|
||||
const both = (...combos: string[]) => ({ mac: combos, other: combos });
|
||||
|
||||
/**
|
||||
* Every command, in Settings display order within its group. Combo strings are
|
||||
* platform-neutral (see parseCombo): `Mod` is ⌘ on macOS and Ctrl elsewhere,
|
||||
* `Ctrl` a literal ⌃ that only macOS tells apart.
|
||||
*/
|
||||
export const BINDINGS = [
|
||||
// ---- panels: the focused file list ----
|
||||
{ id: "cursor-up", group: "panels", scopes: ["panel"], ...both("ArrowUp") },
|
||||
{ id: "cursor-down", group: "panels", scopes: ["panel"], ...both("ArrowDown") },
|
||||
{ id: "page-up", group: "panels", scopes: ["panel"], ...both("PageUp") },
|
||||
{ id: "page-down", group: "panels", scopes: ["panel"], ...both("PageDown") },
|
||||
{ id: "cursor-first", group: "panels", scopes: ["panel"], ...both("Home") },
|
||||
{ id: "cursor-last", group: "panels", scopes: ["panel"], ...both("End") },
|
||||
{ id: "toggle-select", group: "panels", scopes: ["panel"], mac: ["Space"], other: ["Space", "Insert"] },
|
||||
{ id: "select-all", group: "panels", scopes: ["panel"], ...both("Mod+A") },
|
||||
{ id: "open", group: "panels", scopes: ["panel"], mac: ["Enter", "Mod+ArrowDown"], other: ["Enter"] },
|
||||
{ id: "parent", group: "panels", scopes: ["panel"], mac: ["Backspace", "Mod+ArrowUp"], other: ["Backspace", "Alt+ArrowUp"] },
|
||||
{ id: "switch-panel", group: "panels", scopes: ["panel"], ...both("Tab", "Shift+Tab") },
|
||||
{ id: "row-menu", group: "panels", scopes: ["panel"], mac: ["Shift+F10", "Ctrl+Enter"], other: ["Shift+F10", "ContextMenu"] },
|
||||
// ---- files: act on the focused panel's selection, else its cursor row ----
|
||||
{ id: "send", group: "files", scopes: ["panel"], ...both("F5") },
|
||||
{ id: "edit", group: "files", scopes: ["panel"], mac: ["Mod+E", "F4"], other: ["F4", "Mod+E"] },
|
||||
{ id: "rename", group: "files", scopes: ["panel"], ...both("F2") },
|
||||
{ id: "new-folder", group: "files", scopes: ["panel"], mac: ["Mod+Shift+N", "F7"], other: ["F7", "Mod+Shift+N"] },
|
||||
{ id: "new-file", group: "files", scopes: ["panel"], mac: ["Mod+N", "Shift+F4"], other: ["Shift+F4", "Mod+N"] },
|
||||
{ id: "chmod", group: "files", scopes: ["panel"], ...both("Mod+I") },
|
||||
{ id: "delete", group: "files", scopes: ["panel"], ...both("F8", "Delete") },
|
||||
{ id: "trash", group: "files", scopes: ["panel"], mac: ["Mod+Backspace"], other: ["Mod+Delete"] },
|
||||
{ id: "copy-path", group: "files", scopes: ["panel"], mac: ["Mod+Alt+C"], other: ["Mod+Shift+C"] },
|
||||
{ id: "reveal", group: "files", scopes: ["panel"], mac: ["Mod+Alt+R"], other: ["Alt+Shift+R"] },
|
||||
{ id: "open-default", group: "files", scopes: ["panel"], ...both("Mod+O") },
|
||||
{ id: "refresh", group: "files", scopes: ["panel"], ...both("Mod+R") },
|
||||
{ id: "focus-path", group: "files", scopes: ["panel"], ...both("Mod+L") },
|
||||
{ id: "source-picker", group: "files", scopes: ["panel"], ...both("Mod+K") },
|
||||
// ---- sort the focused panel (again = reverse) ----
|
||||
{ id: "sort-name", group: "sort", scopes: ["panel"], ...both("Alt+1") },
|
||||
{ id: "sort-size", group: "sort", scopes: ["panel"], ...both("Alt+2") },
|
||||
{ id: "sort-mtime", group: "sort", scopes: ["panel"], ...both("Alt+3") },
|
||||
{ id: "sort-kind", group: "sort", scopes: ["panel"], ...both("Alt+4") },
|
||||
{ id: "sort-rights", group: "sort", scopes: ["panel"], ...both("Alt+5") },
|
||||
{ id: "sort-owner", group: "sort", scopes: ["panel"], ...both("Alt+6") },
|
||||
{ id: "sort-group", group: "sort", scopes: ["panel"], ...both("Alt+7") },
|
||||
// ---- tabs (Ctrl+Tab is a literal ⌃ on macOS: ⌘Tab is the app switcher) ----
|
||||
{ id: "new-tab", group: "tabs", scopes: ["main"], ...both("Mod+T") },
|
||||
{ id: "close-tab", group: "tabs", scopes: ["main"], ...both("Mod+W") },
|
||||
// Ctrl+Alt+letter is AltGr typing on many non-mac layouts — never a default there
|
||||
{ id: "close-other-tabs", group: "tabs", scopes: ["main"], mac: ["Mod+Alt+W"], other: ["Mod+Shift+W"] },
|
||||
{ id: "close-tabs-right", group: "tabs", scopes: ["main"], ...both() },
|
||||
{ id: "next-tab", group: "tabs", scopes: ["main"], mac: ["Ctrl+Tab", "Mod+Shift+BracketRight"], other: ["Ctrl+Tab", "Mod+PageDown"] },
|
||||
{ id: "prev-tab", group: "tabs", scopes: ["main"], mac: ["Ctrl+Shift+Tab", "Mod+Shift+BracketLeft"], other: ["Ctrl+Shift+Tab", "Mod+PageUp"] },
|
||||
{ id: "go-to-tab-1", group: "tabs", scopes: ["main"], n: 1, ...both("Mod+1") },
|
||||
{ id: "go-to-tab-2", group: "tabs", scopes: ["main"], n: 2, ...both("Mod+2") },
|
||||
{ id: "go-to-tab-3", group: "tabs", scopes: ["main"], n: 3, ...both("Mod+3") },
|
||||
{ id: "go-to-tab-4", group: "tabs", scopes: ["main"], n: 4, ...both("Mod+4") },
|
||||
{ id: "go-to-tab-5", group: "tabs", scopes: ["main"], n: 5, ...both("Mod+5") },
|
||||
{ id: "go-to-tab-6", group: "tabs", scopes: ["main"], n: 6, ...both("Mod+6") },
|
||||
{ id: "go-to-tab-7", group: "tabs", scopes: ["main"], n: 7, ...both("Mod+7") },
|
||||
{ id: "go-to-tab-8", group: "tabs", scopes: ["main"], n: 8, ...both("Mod+8") },
|
||||
{ id: "go-to-last-tab", group: "tabs", scopes: ["main"], ...both("Mod+9") },
|
||||
{ id: "move-tab-left", group: "tabs", scopes: ["main"], mac: ["Ctrl+Shift+PageUp"], other: ["Mod+Shift+PageUp"] },
|
||||
{ id: "move-tab-right", group: "tabs", scopes: ["main"], mac: ["Ctrl+Shift+PageDown"], other: ["Mod+Shift+PageDown"] },
|
||||
{ id: "disconnect-tab", group: "tabs", scopes: ["main"], ...both("Mod+D") },
|
||||
// ---- window ----
|
||||
{ id: "open-settings", group: "window", scopes: ["main"], ...both("Mod+Comma") },
|
||||
{ id: "connection-manager", group: "window", scopes: ["main"], ...both("Mod+Shift+K") },
|
||||
{ id: "toggle-transfers", group: "window", scopes: ["main"], ...both("Mod+J") },
|
||||
{ id: "install-update", group: "window", scopes: ["main"], ...both() },
|
||||
// ⌘+ is ⇧⌘= on US layouts — both spellings, plus the keypad
|
||||
{ id: "scale-up", group: "window", scopes: ["any"], ...both("Mod+Equal", "Mod+Shift+Equal", "Mod+NumpadAdd") },
|
||||
{ id: "scale-down", group: "window", scopes: ["any"], ...both("Mod+Minus") },
|
||||
{ id: "scale-reset", group: "window", scopes: ["any"], ...both("Mod+0") },
|
||||
{ id: "close-window", group: "window", scopes: ["editor", "manager", "settings"], ...both("Mod+W") },
|
||||
// ---- editor window ----
|
||||
{ id: "save", group: "editor", scopes: ["editor"], ...both("Mod+S") },
|
||||
{ id: "find", group: "editor", scopes: ["editor"], ...both("Mod+F") },
|
||||
{ id: "find-next", group: "editor", scopes: ["editor"], mac: ["Mod+G"], other: ["F3"] },
|
||||
{ id: "find-prev", group: "editor", scopes: ["editor"], mac: ["Mod+Shift+G"], other: ["Shift+F3"] },
|
||||
{ id: "toggle-wrap", group: "editor", scopes: ["editor"], ...both("Alt+Z") },
|
||||
// ---- connection manager ----
|
||||
{ id: "conn-new", group: "connections", scopes: ["manager"], ...both("Mod+N") },
|
||||
{ id: "conn-new-stack", group: "connections", scopes: ["manager"], ...both("Mod+Shift+N") },
|
||||
{ id: "conn-save", group: "connections", scopes: ["manager"], ...both("Mod+S") },
|
||||
{ id: "conn-connect", group: "connections", scopes: ["manager"], ...both("Mod+Enter") },
|
||||
{ id: "conn-delete", group: "connections", scopes: ["manager"], mac: ["Mod+Backspace"], other: ["Delete"] },
|
||||
{ id: "conn-rename-stack", group: "connections", scopes: ["manager"], ...both("F2") },
|
||||
{ id: "conn-import", group: "connections", scopes: ["manager"], ...both("Mod+O") },
|
||||
] as const satisfies readonly BindingDef[];
|
||||
|
||||
export type ShortcutId = (typeof BINDINGS)[number]["id"];
|
||||
|
||||
/** Commands the macOS menu bar carries; their accelerators follow the keymap. */
|
||||
export const NATIVE_MENU_IDS: readonly ShortcutId[] = [
|
||||
"open-settings",
|
||||
"new-tab",
|
||||
"close-tab",
|
||||
"new-folder",
|
||||
"new-file",
|
||||
"edit",
|
||||
"connection-manager",
|
||||
"refresh",
|
||||
];
|
||||
|
||||
export interface Combo {
|
||||
/** A–Z, 0–9, a punctuation event.code (Comma, Equal…) or a key name (Enter, F2…). */
|
||||
key: string;
|
||||
/** ⌘ on macOS, Ctrl elsewhere. */
|
||||
mod: boolean;
|
||||
/** A literal ⌃, macOS only — elsewhere Ctrl IS Mod. */
|
||||
ctrl: boolean;
|
||||
alt: boolean;
|
||||
shift: boolean;
|
||||
}
|
||||
|
||||
/** The KeyboardEvent fields matching needs (tests build these by hand). */
|
||||
export interface KeyEventLike {
|
||||
key: string;
|
||||
code: string;
|
||||
metaKey: boolean;
|
||||
ctrlKey: boolean;
|
||||
altKey: boolean;
|
||||
shiftKey: boolean;
|
||||
getModifierState?(key: string): boolean;
|
||||
}
|
||||
|
||||
export interface ResolvedBinding {
|
||||
id: ShortcutId;
|
||||
group: Group;
|
||||
scopes: readonly Scope[];
|
||||
n?: number;
|
||||
/** What fires: the user's override when there is one, else the defaults. */
|
||||
combos: Combo[];
|
||||
defaults: Combo[];
|
||||
/** The user replaced this row's defaults (possibly with nothing). */
|
||||
custom: boolean;
|
||||
}
|
||||
|
||||
const LETTER = /^[A-Z]$/;
|
||||
const DIGIT = /^[0-9]$/;
|
||||
const FKEY = /^F([1-9]|1[0-2])$/;
|
||||
/** Punctuation tokens and the character each stands for. Matched by character
|
||||
* first (AZERTY's comma is on the M key) and by physical key second (⇧] makes
|
||||
* "}", the US ⌘+ is ⇧⌘=). */
|
||||
const CHAR_OF: Record<string, string> = {
|
||||
Comma: ",",
|
||||
Period: ".",
|
||||
Slash: "/",
|
||||
Semicolon: ";",
|
||||
Quote: "'",
|
||||
BracketLeft: "[",
|
||||
BracketRight: "]",
|
||||
Backslash: "\\",
|
||||
Backquote: "`",
|
||||
Equal: "=",
|
||||
Minus: "-",
|
||||
};
|
||||
const TOKEN_OF_CHAR: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(CHAR_OF).map(([token, char]) => [char, token]),
|
||||
);
|
||||
/** Physical-only keys: the keypad's + (its − already types "-", which is Minus). */
|
||||
const KEYPAD = new Set(["NumpadAdd"]);
|
||||
/** KeyboardEvent.key names, the same on every layout. */
|
||||
const NAMED = new Set([
|
||||
"Enter",
|
||||
"Escape",
|
||||
"Backspace",
|
||||
"Delete",
|
||||
"Tab",
|
||||
"Space",
|
||||
"ArrowUp",
|
||||
"ArrowDown",
|
||||
"ArrowLeft",
|
||||
"ArrowRight",
|
||||
"Home",
|
||||
"End",
|
||||
"PageUp",
|
||||
"PageDown",
|
||||
"Insert",
|
||||
"ContextMenu",
|
||||
]);
|
||||
|
||||
function isToken(key: string): boolean {
|
||||
return (
|
||||
LETTER.test(key) || DIGIT.test(key) || FKEY.test(key) || Object.hasOwn(CHAR_OF, key) || KEYPAD.has(key) || NAMED.has(key)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* An event's key token, or null for modifier-only presses and keys the keymap
|
||||
* doesn't cover. Letters follow the layout (AZERTY's A is A); under Alt
|
||||
* (macOS Option turns ⌥Z into Ω) and on non-Latin layouts they fall back to the
|
||||
* physical key. Digits are physical too: AZERTY types "&" on the 1 key.
|
||||
*/
|
||||
export function keyToken(e: Pick<KeyEventLike, "key" | "code" | "altKey">): string | null {
|
||||
const { key, code } = e;
|
||||
const upper = key.length === 1 ? key.toUpperCase() : "";
|
||||
if (!e.altKey && LETTER.test(upper)) return upper;
|
||||
if (/^Key[A-Z]$/.test(code) && (e.altKey || /\p{L}/u.test(key))) return code.slice(3);
|
||||
if (/^Digit[0-9]$/.test(code)) return code.slice(5);
|
||||
if (/^Numpad[0-9]$/.test(code)) return DIGIT.test(key) ? key : NAMED.has(key) ? key : null;
|
||||
if (Object.hasOwn(TOKEN_OF_CHAR, key)) return TOKEN_OF_CHAR[key];
|
||||
if (Object.hasOwn(CHAR_OF, code) || KEYPAD.has(code)) return code;
|
||||
if (key === " " || code === "Space") return "Space";
|
||||
if (FKEY.test(key) || NAMED.has(key)) return key;
|
||||
return null;
|
||||
}
|
||||
|
||||
function eventMods(e: KeyEventLike, isMac: boolean) {
|
||||
return isMac
|
||||
? { mod: e.metaKey, ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey, other: false }
|
||||
: // never part of a binding: the Windows / Super key, and AltGr — which
|
||||
// arrives as Ctrl+Alt while it types "@", "€", "{"…
|
||||
{
|
||||
mod: e.ctrlKey,
|
||||
ctrl: false,
|
||||
alt: e.altKey,
|
||||
shift: e.shiftKey,
|
||||
other: e.metaKey || !!e.getModifierState?.("AltGraph"),
|
||||
};
|
||||
}
|
||||
|
||||
/** Exact match: the same key AND exactly the same modifiers. ⌃ is not ⌘ on
|
||||
* macOS (⌃F is Cocoa's forward-one-character), and Ctrl+Tab is not Tab. */
|
||||
export function matchesEvent(combo: Combo, e: KeyEventLike, isMac: boolean): boolean {
|
||||
const m = eventMods(e, isMac);
|
||||
if (m.other || m.mod !== combo.mod || m.ctrl !== combo.ctrl || m.alt !== combo.alt || m.shift !== combo.shift) {
|
||||
return false;
|
||||
}
|
||||
return keyToken(e) === combo.key || ((Object.hasOwn(CHAR_OF, combo.key) || KEYPAD.has(combo.key)) && e.code === combo.key);
|
||||
}
|
||||
|
||||
/** The combo a keydown spells (Settings → Keyboard recording), or null. */
|
||||
export function comboFromEvent(e: KeyEventLike, isMac: boolean): Combo | null {
|
||||
const key = keyToken(e);
|
||||
const m = eventMods(e, isMac);
|
||||
if (!key || m.other) return null;
|
||||
return { key, mod: m.mod, ctrl: m.ctrl, alt: m.alt, shift: m.shift };
|
||||
}
|
||||
|
||||
/** "Mod+Shift+N" → Combo; null for anything malformed. Off macOS a stored
|
||||
* literal Ctrl folds into Mod — on those keyboards they are the same key. */
|
||||
export function parseCombo(text: unknown, isMac: boolean): Combo | null {
|
||||
if (typeof text !== "string" || !text) return null;
|
||||
const parts = text.split("+");
|
||||
let key = parts.pop() ?? "";
|
||||
if (key.length === 1) key = key.toUpperCase();
|
||||
if (!isToken(key)) return null;
|
||||
const combo: Combo = { key, mod: false, ctrl: false, alt: false, shift: false };
|
||||
for (const part of parts) {
|
||||
const name = part.toLowerCase();
|
||||
if (name === "mod") combo.mod = true;
|
||||
else if (name === "ctrl") combo.ctrl = true;
|
||||
else if (name === "alt") combo.alt = true;
|
||||
else if (name === "shift") combo.shift = true;
|
||||
else return null;
|
||||
}
|
||||
if (!isMac && combo.ctrl) {
|
||||
combo.ctrl = false;
|
||||
combo.mod = true;
|
||||
}
|
||||
return combo;
|
||||
}
|
||||
|
||||
export function serializeCombo(c: Combo): string {
|
||||
return [c.mod && "Mod", c.ctrl && "Ctrl", c.alt && "Alt", c.shift && "Shift", c.key].filter(Boolean).join("+");
|
||||
}
|
||||
|
||||
export function sameCombo(a: Combo, b: Combo): boolean {
|
||||
return serializeCombo(a) === serializeCombo(b);
|
||||
}
|
||||
|
||||
function parseList(list: readonly unknown[], isMac: boolean): Combo[] {
|
||||
const out: Combo[] = [];
|
||||
for (const text of list) {
|
||||
const combo = parseCombo(text, isMac);
|
||||
if (combo && !out.some((c) => sameCombo(c, combo))) out.push(combo);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The table with the user's overrides applied — the single place a hand-edited
|
||||
* settings.json is sanitized: unknown ids, non-arrays and malformed combos are
|
||||
* dropped. An override replaces a row's whole list; [] unbinds it.
|
||||
*/
|
||||
export function effectiveKeymap(overrides: unknown, isMac: boolean): ResolvedBinding[] {
|
||||
const table: Record<string, unknown> =
|
||||
overrides && typeof overrides === "object" && !Array.isArray(overrides)
|
||||
? (overrides as Record<string, unknown>)
|
||||
: {};
|
||||
return BINDINGS.map((b): ResolvedBinding => {
|
||||
const defaults = parseList(isMac ? b.mac : b.other, isMac);
|
||||
const raw = Object.hasOwn(table, b.id) ? table[b.id] : undefined;
|
||||
const custom = Array.isArray(raw);
|
||||
return {
|
||||
id: b.id,
|
||||
group: b.group,
|
||||
scopes: b.scopes,
|
||||
n: "n" in b ? b.n : undefined,
|
||||
defaults,
|
||||
custom,
|
||||
combos: custom ? parseList(raw, isMac) : defaults,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** The stored shape, coerced to arrays of combo strings, for
|
||||
* SettingsStore.load: a hand-edited settings.json can't break boot. Unknown
|
||||
* ids are kept (a newer build's commands survive a downgrade round trip);
|
||||
* effectiveKeymap ignores them. */
|
||||
export function sanitizeKeybindings(raw: unknown): Record<string, string[]> {
|
||||
const out: Record<string, string[]> = {};
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return out;
|
||||
for (const [id, list] of Object.entries(raw)) {
|
||||
if (Array.isArray(list)) out[id] = list.filter((t): t is string => parseCombo(t, true) !== null);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** True when a binding in `a` and one in `b` can fire on the same keystroke. */
|
||||
export function scopesOverlap(a: readonly Scope[], b: readonly Scope[]): boolean {
|
||||
return a.some((x) =>
|
||||
b.some(
|
||||
(y) =>
|
||||
x === y ||
|
||||
x === "any" ||
|
||||
y === "any" ||
|
||||
(x === "panel" && y === "main") ||
|
||||
(x === "main" && y === "panel"),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** The binding that already owns `combo` where `scopes` fire, if any. */
|
||||
export function ownerOf(
|
||||
keymap: readonly ResolvedBinding[],
|
||||
combo: Combo,
|
||||
scopes: readonly Scope[],
|
||||
except?: ShortcutId,
|
||||
): ResolvedBinding | undefined {
|
||||
return keymap.find(
|
||||
(b) => b.id !== except && scopesOverlap(b.scopes, scopes) && b.combos.some((c) => sameCombo(c, combo)),
|
||||
);
|
||||
}
|
||||
|
||||
/** Every binding that shares a combo with another one it can collide with. */
|
||||
export function conflicts(keymap: readonly ResolvedBinding[]): Map<ShortcutId, ShortcutId[]> {
|
||||
const out = new Map<ShortcutId, ShortcutId[]>();
|
||||
const link = (from: ShortcutId, to: ShortcutId) => out.set(from, [...(out.get(from) ?? []), to]);
|
||||
for (let i = 0; i < keymap.length; i++) {
|
||||
for (let j = i + 1; j < keymap.length; j++) {
|
||||
const a = keymap[i];
|
||||
const b = keymap[j];
|
||||
if (!scopesOverlap(a.scopes, b.scopes)) continue;
|
||||
if (a.combos.some((ca) => b.combos.some((cb) => sameCombo(ca, cb)))) {
|
||||
link(a.id, b.id);
|
||||
link(b.id, a.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Keys whose modified forms edit text: ⌘⌫ deletes to line start, ⌥← jumps a word… */
|
||||
const TEXT_EDITING = new Set(["Backspace", "Delete", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Home", "End"]);
|
||||
|
||||
/** Whether a combo may fire while focus is in a text field. Plain and
|
||||
* Shift-only keys type there, and so do the text-editing chords (⌘⌫, ⌥←, ⌘A);
|
||||
* other ⌘/Ctrl and Alt chords and F-keys fire — except Ctrl+Alt off macOS,
|
||||
* which is AltGr. */
|
||||
export function firesInTextField(c: Combo, isMac: boolean): boolean {
|
||||
if (FKEY.test(c.key)) return true;
|
||||
if (TEXT_EDITING.has(c.key) || (c.mod && c.key === "A")) return false;
|
||||
if (!isMac && c.mod && c.alt) return false;
|
||||
return c.mod || c.alt;
|
||||
}
|
||||
|
||||
/** Commands a held key may repeat; any other held shortcut fires once
|
||||
* (holding ⌘W must not close every tab). */
|
||||
const REPEATABLE = new Set<ShortcutId>([
|
||||
"cursor-up",
|
||||
"cursor-down",
|
||||
"page-up",
|
||||
"page-down",
|
||||
"toggle-select",
|
||||
"next-tab",
|
||||
"prev-tab",
|
||||
"move-tab-left",
|
||||
"move-tab-right",
|
||||
"scale-up",
|
||||
"scale-down",
|
||||
"find-next",
|
||||
"find-prev",
|
||||
]);
|
||||
|
||||
export function isRepeatable(id: ShortcutId): boolean {
|
||||
return REPEATABLE.has(id);
|
||||
}
|
||||
|
||||
const RESERVED = {
|
||||
mac: ["Mod+Q", "Mod+X", "Mod+C", "Mod+V", "Mod+Z", "Mod+Shift+Z", "Mod+H", "Mod+Alt+H", "Mod+M", "Mod+Backquote"],
|
||||
other: ["Mod+X", "Mod+C", "Mod+V", "Mod+Z", "Mod+Y"],
|
||||
};
|
||||
|
||||
/** Quit, the clipboard / undo keys and macOS's window keys (⌘H ⌘M ⌘`) can't be rebound. */
|
||||
export function isReserved(c: Combo, isMac: boolean): boolean {
|
||||
return parseList(isMac ? RESERVED.mac : RESERVED.other, isMac).some((r) => sameCombo(r, c));
|
||||
}
|
||||
|
||||
/** Key names for the non-macOS spelling ("Ctrl+Shift+Del"); i18n supplies
|
||||
* localized ones (German keyboards say Strg and Entf). */
|
||||
export interface KeyNames {
|
||||
ctrl: string;
|
||||
alt: string;
|
||||
shift: string;
|
||||
enter: string;
|
||||
backspace: string;
|
||||
del: string;
|
||||
esc: string;
|
||||
space: string;
|
||||
tab: string;
|
||||
pgup: string;
|
||||
pgdn: string;
|
||||
home: string;
|
||||
end: string;
|
||||
ins: string;
|
||||
menu: string;
|
||||
num: string;
|
||||
}
|
||||
|
||||
export const EN_KEY_NAMES: KeyNames = {
|
||||
ctrl: "Ctrl",
|
||||
alt: "Alt",
|
||||
shift: "Shift",
|
||||
enter: "Enter",
|
||||
backspace: "Backspace",
|
||||
del: "Del",
|
||||
esc: "Esc",
|
||||
space: "Space",
|
||||
tab: "Tab",
|
||||
pgup: "PgUp",
|
||||
pgdn: "PgDn",
|
||||
home: "Home",
|
||||
end: "End",
|
||||
ins: "Ins",
|
||||
menu: "Menu",
|
||||
num: "Num",
|
||||
};
|
||||
|
||||
const ARROWS: Record<string, string> = { ArrowUp: "↑", ArrowDown: "↓", ArrowLeft: "←", ArrowRight: "→" };
|
||||
const MAC_GLYPHS: Record<string, string> = { Enter: "↩", Escape: "⎋", Backspace: "⌫", Delete: "⌦", Tab: "⇥" };
|
||||
|
||||
function keyLabel(key: string, isMac: boolean, names: KeyNames): string {
|
||||
if (Object.hasOwn(CHAR_OF, key)) return CHAR_OF[key];
|
||||
if (Object.hasOwn(ARROWS, key)) return ARROWS[key];
|
||||
if (isMac && Object.hasOwn(MAC_GLYPHS, key)) return MAC_GLYPHS[key];
|
||||
const named: Record<string, string> = {
|
||||
Enter: names.enter,
|
||||
Escape: names.esc,
|
||||
Backspace: names.backspace,
|
||||
Delete: names.del,
|
||||
Tab: names.tab,
|
||||
Space: names.space,
|
||||
Home: names.home,
|
||||
End: names.end,
|
||||
PageUp: names.pgup,
|
||||
PageDown: names.pgdn,
|
||||
Insert: names.ins,
|
||||
ContextMenu: names.menu,
|
||||
NumpadAdd: `${names.num} +`,
|
||||
};
|
||||
return Object.hasOwn(named, key) ? named[key] : key;
|
||||
}
|
||||
|
||||
/** "⇧⌘N" on macOS (Apple's ⌃⌥⇧⌘ order), "Ctrl+Shift+N" elsewhere. */
|
||||
export function formatCombo(c: Combo, isMac: boolean, names: KeyNames = EN_KEY_NAMES): string {
|
||||
const key = keyLabel(c.key, isMac, names);
|
||||
if (isMac) return `${c.ctrl ? "⌃" : ""}${c.alt ? "⌥" : ""}${c.shift ? "⇧" : ""}${c.mod ? "⌘" : ""}${key}`;
|
||||
return [c.mod && names.ctrl, c.alt && names.alt, c.shift && names.shift, key].filter(Boolean).join("+");
|
||||
}
|
||||
|
||||
const ACCELERATOR_KEYS: Record<string, string> = {
|
||||
Enter: "return",
|
||||
Escape: "escape",
|
||||
Tab: "tab",
|
||||
Space: "space",
|
||||
ArrowUp: "up",
|
||||
ArrowDown: "down",
|
||||
ArrowLeft: "left",
|
||||
ArrowRight: "right",
|
||||
Home: "home",
|
||||
End: "end",
|
||||
PageUp: "pageup",
|
||||
PageDown: "pagedown",
|
||||
};
|
||||
|
||||
/**
|
||||
* Electrobun's native menu accelerator ("Command+Shift+n"), or null when the
|
||||
* menu can't express the combo faithfully:
|
||||
* - only ⌘ / ⌃ chords — the native parser turns any combo without a modifier
|
||||
* token into ⌘+key;
|
||||
* - no ⌫ / ⌦ (its "backspace" and "delete" tokens map to characters AppKit
|
||||
* matches unreliably), Insert, the Menu key or the keypad;
|
||||
* - no Shift+punctuation — AppKit matches the shifted character ("}"), not "]".
|
||||
*/
|
||||
export function toAccelerator(c: Combo): string | null {
|
||||
if (!c.mod && !c.ctrl) return null;
|
||||
if (c.shift && Object.hasOwn(CHAR_OF, c.key)) return null;
|
||||
let key: string | undefined;
|
||||
if (LETTER.test(c.key)) key = c.key.toLowerCase();
|
||||
else if (DIGIT.test(c.key)) key = c.key;
|
||||
else if (FKEY.test(c.key)) key = c.key.toLowerCase();
|
||||
else if (Object.hasOwn(CHAR_OF, c.key)) key = c.key === "Minus" ? "minus" : CHAR_OF[c.key];
|
||||
else if (Object.hasOwn(ACCELERATOR_KEYS, c.key)) key = ACCELERATOR_KEYS[c.key];
|
||||
if (!key) return null;
|
||||
return [c.mod && "Command", c.ctrl && "Control", c.alt && "Alt", c.shift && "Shift", key]
|
||||
.filter(Boolean)
|
||||
.join("+");
|
||||
}
|
||||
@@ -157,6 +157,8 @@ export type AppRPC = {
|
||||
// settings window
|
||||
/** Main window: open (or focus) the single Settings window. */
|
||||
openSettings: { params: Record<string, never>; response: Result };
|
||||
/** Settings window only: its close-window key (Ctrl+W off macOS, ⌘W). */
|
||||
settingsClose: { params: Record<string, never>; response: Result };
|
||||
// auto-updater (electrobun Updater)
|
||||
/** Main window: re-run the update check on demand (menu item). */
|
||||
checkForUpdate: {
|
||||
|
||||
@@ -91,6 +91,14 @@ export interface AppSettings {
|
||||
editor: "builtin" | "custom";
|
||||
/** Full path to the external editor .app; empty = OS default application. */
|
||||
editorApp: string;
|
||||
/**
|
||||
* Keyboard remaps from Settings → Keyboard: command id → combo strings, only
|
||||
* for rows that differ from the defaults ([] unbinds). Format and sanitizing
|
||||
* live in keymap.ts — a hand-edited entry it can't parse is simply ignored.
|
||||
*/
|
||||
keybindings: Record<string, string[]>;
|
||||
/** Enter / double-click on a LOCAL file opens it in its default app. */
|
||||
openFilesOnEnter: boolean;
|
||||
// Auto-refresh the active tab's panels (local watched, remote polled). Always
|
||||
// on for now; uncomment (+ the settings.service and tab-bar breadcrumbs, and
|
||||
// gate the poll/watch in panels.service) to expose a toggle later.
|
||||
@@ -109,6 +117,8 @@ export const DEFAULT_SETTINGS: AppSettings = {
|
||||
autoClearCompleted: true,
|
||||
editor: "builtin",
|
||||
editorApp: "",
|
||||
keybindings: {},
|
||||
openFilesOnEnter: false,
|
||||
// autoRefresh: true,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user