Files
Portfolio/src/app/shared/terminal/terminal-dock.service.ts
Antonio Ledebuhr b8b9e57d06 Make the terminal dock toggle, scroll, and remember commands honestly.
The desktop trigger now collapses through the shared toggle, the log stays on the newest line, Shift+K is left to the browser, history walks like a shell, and maximize keeps one accessible name.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 13:51:55 +02:00

120 lines
2.6 KiB
TypeScript

import { DOCUMENT, isPlatformBrowser } from '@angular/common';
import {
afterNextRender,
DestroyRef,
inject,
Injectable,
Injector,
PLATFORM_ID,
signal,
} from '@angular/core';
export type TerminalDockState = 'collapsed' | 'expanded' | 'maximized';
@Injectable({ providedIn: 'root' })
export class TerminalDockService {
private readonly document = inject(DOCUMENT);
private readonly destroyRef = inject(DestroyRef);
private readonly injector = inject(Injector);
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
readonly state = signal<TerminalDockState>('collapsed');
readonly panelId = 'terminal-dock-panel';
readonly inputId = 'terminal-dock-input';
readonly logId = 'terminal-dock-log';
private trigger: HTMLElement | null = null;
constructor() {
afterNextRender(
() => {
if (!this.isBrowser) {
return;
}
this.document.addEventListener('keydown', this.onDocumentKeydown);
},
{ injector: this.injector },
);
this.destroyRef.onDestroy(() => this.teardown());
}
registerTrigger(element: HTMLElement | null): void {
this.trigger = element;
}
open(opener?: HTMLElement | null): void {
if (this.state() !== 'collapsed') {
return;
}
if (opener instanceof HTMLElement) {
this.trigger = opener;
}
this.state.set('expanded');
}
collapse(): void {
if (this.state() === 'collapsed') {
return;
}
this.state.set('collapsed');
this.restoreFocus();
}
toggle(opener?: HTMLElement | null): void {
if (this.state() === 'collapsed') {
this.open(opener);
return;
}
this.collapse();
}
toggleMaximized(): void {
if (this.state() === 'collapsed') {
this.open();
this.state.set('maximized');
return;
}
this.state.update((current) => (current === 'maximized' ? 'expanded' : 'maximized'));
}
private restoreFocus(): void {
const trigger = this.trigger;
afterNextRender(
() => {
if (trigger instanceof HTMLElement && trigger.isConnected) {
trigger.focus();
}
},
{ injector: this.injector },
);
}
private teardown(): void {
this.document.removeEventListener('keydown', this.onDocumentKeydown);
this.state.set('collapsed');
this.trigger = null;
}
private readonly onDocumentKeydown = (event: KeyboardEvent): void => {
if (
event.key.toLowerCase() !== 'k' ||
!(event.ctrlKey || event.metaKey) ||
event.altKey ||
event.shiftKey
) {
return;
}
event.preventDefault();
this.toggle();
};
}