Groundwork for the vim-style bindings planned next: the app previously had NO
keyboard abstraction — `event.metaKey || event.ctrlKey` copy-pasted inline in
six places, no typing-target guard anywhere, every window root matching keys
by hand.
New @core/shortcuts.service.ts owns:
- isMod() (also accepts MouseEvent — Mod+click toggle-select uses the same
rule) and isTypingTarget();
- ONE declarative keymap: resolve(event, scope) -> ShortcutId | null, scoped
main / editor / manager. Layout rules are encoded once: digits, +/- and
Alt-letter chords match on event.code (AZERTY digit rows, layouts where '+'
needs Shift, macOS Option-Z producing Ω); plain letter chords match on
event.key so they follow the user's layout.
Both app.ts, both editor-window.ts and both connection-manager.ts now resolve
through the service and switch on ids — behaviour-preserving for every
existing shortcut, including the deliberate quirks (Ctrl+Tab never meta
because Cmd-Tab is the macOS app switcher; Cmd+9 = last tab; editor Mod+W is
the Windows/Linux arm, macOS routes via the app menu). Escape and Tab stay
component-local on purpose: their meaning is per-window state (find bar open?
close prompt up? caret in the textarea?), not a global binding. The file
panels keep their contextual key state machine but delegate modifier tests.
On top of the table: Ctrl/Cmd +/-/0 steps/resets the interface scale in every
window kind, all three previously unclaimed. isTypingTarget is applied
per-shortcut, not globally in resolve(): the scale keys must not fire while
typing ('-' in a rename prompt must type '-'), but the editor WANTS Mod+S
mid-typing and the tab shortcuts keep their existing reach.
Docs: architecture.md's density prose now describes the uiScale lever;
gotchas.md gains the scale section — the disassembly evidence that native
page zoom is an empty stub on Linux (so nobody tries it again), the
everything-must-be-rem rule, the skinfix-outranks-variants trap the harness
caught (48px close button silently 29px), and the px-mirrors-in-TS class of
bug with the remPx() remedy.
Verified: tsc + build clean; live boot with the user's real legacy settings
file migrates textSize:large -> uiScale 1.2 and renders scaled. Not verified
here (needs interaction): the stepper clicks and the shortcut keys themselves.
Co-Authored-By: Claude <noreply@anthropic.com>
285 lines
8.8 KiB
TypeScript
285 lines
8.8 KiB
TypeScript
import { Component, computed, inject, signal } from '@angular/core';
|
|
import { NgIcon } from '@ng-icons/core';
|
|
import { I18nService } from '@core/i18n.service';
|
|
import type { SavedConnection, Stack } from '@shared/types';
|
|
import { ConnectionsService } from '@core/connections.service';
|
|
import { ConfirmDialog } from '../dialogs/confirm-dialog';
|
|
import { PromptDialog } from '../dialogs/prompt-dialog';
|
|
import { DialogsService } from '@core/dialogs.service';
|
|
import { blockNativeContextMenu, IS_MAC_CHROME } from '@core/platform';
|
|
import { RpcService } from '@core/rpc.service';
|
|
import { SettingsService } from '@core/settings.service';
|
|
import { ShortcutsService } from '@core/shortcuts.service';
|
|
import { Toasts } from '../toasts/toasts';
|
|
|
|
interface Draft {
|
|
id: string;
|
|
name: string;
|
|
stackId: string | null;
|
|
host: string;
|
|
port: number;
|
|
username: string;
|
|
authKind: 'password' | 'key';
|
|
password: string;
|
|
privateKeyPath: string;
|
|
passphrase: string;
|
|
initialPath: string;
|
|
localDir: string;
|
|
}
|
|
|
|
function toDraft(c: SavedConnection): Draft {
|
|
return {
|
|
id: c.id,
|
|
name: c.name,
|
|
stackId: c.stackId,
|
|
host: c.host,
|
|
port: c.port,
|
|
username: c.username,
|
|
// agent auth only exists on ~/.ssh/config hosts, which never reach this form
|
|
authKind: c.auth.kind === 'password' ? 'password' : 'key',
|
|
password: c.auth.kind === 'password' ? c.auth.password : '',
|
|
privateKeyPath: c.auth.kind === 'key' ? c.auth.privateKeyPath : '',
|
|
passphrase: c.auth.kind === 'key' ? (c.auth.passphrase ?? '') : '',
|
|
initialPath: c.initialPath ?? '',
|
|
localDir: c.localDir ?? '',
|
|
};
|
|
}
|
|
|
|
function fromDraft(d: Draft): SavedConnection {
|
|
return {
|
|
id: d.id,
|
|
name: d.name.trim() || `${d.username}@${d.host}`,
|
|
stackId: d.stackId,
|
|
host: d.host.trim(),
|
|
port: d.port || 22,
|
|
username: d.username.trim(),
|
|
auth:
|
|
d.authKind === 'password'
|
|
? { kind: 'password', password: d.password }
|
|
: {
|
|
kind: 'key',
|
|
privateKeyPath: d.privateKeyPath.trim(),
|
|
...(d.passphrase ? { passphrase: d.passphrase } : {}),
|
|
},
|
|
initialPath: d.initialPath.trim() || null,
|
|
localDir: d.localDir.trim() || null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Root component of the connection-manager window (__APP_MODE === 'manager').
|
|
* Modal to the main window (enforced bun-side); connect/open-stack requests
|
|
* are executed by the MAIN window — this window never touches panels.
|
|
*/
|
|
@Component({
|
|
selector: 'app-root',
|
|
imports: [NgIcon, ConfirmDialog, PromptDialog, Toasts],
|
|
templateUrl: './connection-manager.html',
|
|
host: {
|
|
'(window:keydown.escape)': 'onEscape()',
|
|
'(window:keydown)': 'onWindowKeydown($event)',
|
|
'(window:contextmenu)': 'blockContextMenu($event)',
|
|
},
|
|
})
|
|
export class ConnectionManager {
|
|
readonly i18n = inject(I18nService);
|
|
readonly store = inject(ConnectionsService);
|
|
readonly dialogs = inject(DialogsService);
|
|
readonly isMacChrome = IS_MAC_CHROME;
|
|
private readonly rpc = inject(RpcService);
|
|
// injected for its side effects: fetches settings and applies theme/accent/
|
|
// text-size data attributes to this window's document root
|
|
private readonly settings = inject(SettingsService);
|
|
private readonly shortcuts = inject(ShortcutsService);
|
|
|
|
readonly draft = signal<Draft | null>(null);
|
|
readonly busy = signal(false);
|
|
readonly importing = signal(false);
|
|
/** The More… section (local directory) in the edit form. */
|
|
readonly moreOpen = signal(false);
|
|
/** Connection id being dragged between stacks. */
|
|
readonly draggedId = signal<string | null>(null);
|
|
/** Highlighted drop target: a stack id, 'loose', or null. */
|
|
readonly dropTarget = signal<string | null>(null);
|
|
|
|
readonly valid = computed(() => {
|
|
const d = this.draft();
|
|
if (!d) return false;
|
|
if (!d.host.trim() || !d.username.trim()) return false;
|
|
if (d.authKind === 'key' && !d.privateKeyPath.trim()) return false;
|
|
return true;
|
|
});
|
|
|
|
patch<K extends keyof Draft>(key: K, value: Draft[K]): void {
|
|
this.draft.update((d) => (d ? { ...d, [key]: value } : d));
|
|
}
|
|
|
|
newConnection(): void {
|
|
this.moreOpen.set(false);
|
|
this.draft.set({
|
|
id: '',
|
|
name: '',
|
|
stackId: null,
|
|
host: '',
|
|
port: 22,
|
|
username: 'root',
|
|
authKind: 'password',
|
|
password: '',
|
|
privateKeyPath: '',
|
|
passphrase: '',
|
|
initialPath: '',
|
|
localDir: '',
|
|
});
|
|
}
|
|
|
|
edit(connection: SavedConnection): void {
|
|
this.draft.set(toDraft(connection));
|
|
this.moreOpen.set(!!connection.localDir);
|
|
}
|
|
|
|
close(): void {
|
|
void this.rpc.request.managerClose({});
|
|
}
|
|
|
|
onEscape(): void {
|
|
// in-window dialogs (confirm/prompt) handle their own Escape first
|
|
if (!this.dialogs.anyOpen) this.close();
|
|
}
|
|
|
|
/** Any-window shortcuts (interface scale). Escape has its own binding above. */
|
|
onWindowKeydown(event: KeyboardEvent): void {
|
|
const match = this.shortcuts.resolve(event, 'manager');
|
|
if (!match || this.shortcuts.isTypingTarget(event)) return;
|
|
event.preventDefault();
|
|
if (match.id === 'scale-up') this.settings.setUiScale(this.settings.uiScale() + 0.1);
|
|
else if (match.id === 'scale-down') this.settings.setUiScale(this.settings.uiScale() - 0.1);
|
|
else if (match.id === 'scale-reset') this.settings.setUiScale(1);
|
|
}
|
|
|
|
blockContextMenu(event: Event): void {
|
|
blockNativeContextMenu(event);
|
|
}
|
|
|
|
async save(): Promise<string | null> {
|
|
const d = this.draft();
|
|
if (!d || !this.valid()) return null;
|
|
const id = await this.store.upsert(fromDraft(d));
|
|
if (id) this.patch('id', id);
|
|
return id;
|
|
}
|
|
|
|
async saveAndConnect(): Promise<void> {
|
|
if (!this.draft()) return;
|
|
this.busy.set(true);
|
|
try {
|
|
const id = await this.save();
|
|
if (!id) return;
|
|
// the main window connects (and this window closes); a failed connect
|
|
// surfaces as an error there
|
|
await this.rpc.request.managerConnect({ id });
|
|
} finally {
|
|
this.busy.set(false);
|
|
}
|
|
}
|
|
|
|
deleteCurrent(): void {
|
|
const d = this.draft();
|
|
if (!d?.id) {
|
|
this.draft.set(null);
|
|
return;
|
|
}
|
|
this.dialogs.confirm.set({
|
|
title: this.i18n.t('connMgr.deleteConnectionTitle'),
|
|
body: this.i18n.t('connMgr.deleteConnectionBody', { name: d.name || d.host }),
|
|
confirmLabel: this.i18n.t('common.delete'),
|
|
destructive: true,
|
|
onConfirm: async () => {
|
|
await this.store.remove(d.id);
|
|
this.draft.set(null);
|
|
},
|
|
});
|
|
}
|
|
|
|
async pickKeyFile(): Promise<void> {
|
|
const res = await this.rpc.request.pickKeyFile({});
|
|
if (res.ok && res.path) this.patch('privateKeyPath', res.path);
|
|
}
|
|
|
|
async importWinScp(): Promise<void> {
|
|
this.importing.set(true);
|
|
try {
|
|
await this.store.importWinScp();
|
|
} finally {
|
|
this.importing.set(false);
|
|
}
|
|
}
|
|
|
|
// ---- stacks ----
|
|
|
|
openStack(stackId: string): void {
|
|
// opened as tabs in the main window; bun closes this window after
|
|
void this.rpc.request.managerOpenStack({ id: stackId });
|
|
}
|
|
|
|
toggleStack(stack: Stack): void {
|
|
void this.store.upsertStack({ ...stack, collapsed: !stack.collapsed });
|
|
}
|
|
|
|
newStack(): void {
|
|
this.dialogs.prompt.set({
|
|
title: this.i18n.t('connMgr.newStackTitle'),
|
|
placeholder: this.i18n.t('connMgr.newStackPrompt'),
|
|
confirmLabel: this.i18n.t('fileActions.createConfirm'),
|
|
onConfirm: (name) => void this.store.upsertStack({ id: '', name }),
|
|
});
|
|
}
|
|
|
|
renameStack(stack: Stack): void {
|
|
this.dialogs.prompt.set({
|
|
title: this.i18n.t('connMgr.renameStackTitle'),
|
|
initial: stack.name,
|
|
confirmLabel: this.i18n.t('connMgr.renameStack'),
|
|
onConfirm: (name) => void this.store.upsertStack({ ...stack, name }),
|
|
});
|
|
}
|
|
|
|
deleteStack(stack: Stack): void {
|
|
this.dialogs.confirm.set({
|
|
title: this.i18n.t('connMgr.deleteStackTitle'),
|
|
body: this.i18n.t('connMgr.deleteStackBody', { name: stack.name }),
|
|
confirmLabel: this.i18n.t('common.delete'),
|
|
destructive: true,
|
|
onConfirm: () => void this.store.removeStack(stack.id),
|
|
});
|
|
}
|
|
|
|
// ---- drag & drop (move a connection between stacks / loose) ----
|
|
|
|
onDragStart(event: DragEvent, connection: SavedConnection): void {
|
|
this.draggedId.set(connection.id);
|
|
event.dataTransfer?.setData('text/plain', connection.id);
|
|
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
|
|
}
|
|
|
|
onDragOver(event: DragEvent, target: string): void {
|
|
if (!this.draggedId()) return;
|
|
event.preventDefault();
|
|
this.dropTarget.set(target);
|
|
}
|
|
|
|
onDrop(event: DragEvent, stackId: string | null): void {
|
|
event.preventDefault();
|
|
const id = this.draggedId();
|
|
this.draggedId.set(null);
|
|
this.dropTarget.set(null);
|
|
const connection = this.store.connections().find((c) => c.id === id);
|
|
if (!connection || connection.stackId === stackId) return;
|
|
void this.store.upsert({ ...connection, stackId });
|
|
}
|
|
|
|
onDragEnd(): void {
|
|
this.draggedId.set(null);
|
|
this.dropTarget.set(null);
|
|
}
|
|
}
|