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>
120 lines
2.6 KiB
TypeScript
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();
|
|
};
|
|
}
|