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>
This commit is contained in:
@@ -65,6 +65,63 @@ test.describe('terminal dock', () => {
|
|||||||
await expect(input).toHaveValue('navigate');
|
await expect(input).toHaveValue('navigate');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('clicking the visible desktop trigger while open collapses the panel', async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
test.skip(
|
||||||
|
testInfo.project.name === 'mobile',
|
||||||
|
'Below md the trigger is hidden while the panel is open.',
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
const trigger = page.locator('.terminal-dock-trigger');
|
||||||
|
const panel = page.locator('.terminal-dock-panel');
|
||||||
|
|
||||||
|
await trigger.click();
|
||||||
|
await expect(panel).toBeVisible();
|
||||||
|
await expect(trigger).toBeVisible();
|
||||||
|
await expect(trigger).toHaveAttribute('aria-expanded', 'true');
|
||||||
|
|
||||||
|
await trigger.click();
|
||||||
|
await expect(panel).toHaveCount(0);
|
||||||
|
await expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
||||||
|
await expect(trigger).toBeFocused();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps the newest echoed line in the log scrollport after overflow', async ({ page }) => {
|
||||||
|
const copy = SIGNATURE_COPY.de.terminal;
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
const trigger = page.locator('.terminal-dock-trigger');
|
||||||
|
const input = page.locator('#terminal-dock-input');
|
||||||
|
const log = page.locator('.terminal-dock-log');
|
||||||
|
|
||||||
|
await trigger.click();
|
||||||
|
await expect(log).toBeVisible();
|
||||||
|
|
||||||
|
for (let index = 0; index < 8; index += 1) {
|
||||||
|
await input.fill('help');
|
||||||
|
await input.press('Enter');
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(log.locator('p.echo').last()).toHaveText(`${copy.prompt} help`);
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () =>
|
||||||
|
log.evaluate((element) => {
|
||||||
|
return element.scrollTop + element.clientHeight >= element.scrollHeight - 2;
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
|
||||||
|
const last = log.locator('p').last();
|
||||||
|
const logBox = await log.boundingBox();
|
||||||
|
const lastBox = await last.boundingBox();
|
||||||
|
expect(logBox && lastBox).toBeTruthy();
|
||||||
|
expect(lastBox!.y).toBeGreaterThanOrEqual(logBox!.y - 1);
|
||||||
|
expect(lastBox!.y + lastBox!.height).toBeLessThanOrEqual(logBox!.y + logBox!.height + 1);
|
||||||
|
});
|
||||||
|
|
||||||
test('Escape from a panel control collapses the dock and returns focus to the trigger', async ({
|
test('Escape from a panel control collapses the dock and returns focus to the trigger', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ export interface SignatureCopy {
|
|||||||
readonly inputLabel: string;
|
readonly inputLabel: string;
|
||||||
readonly inputPlaceholder: string;
|
readonly inputPlaceholder: string;
|
||||||
readonly collapseLabel: string;
|
readonly collapseLabel: string;
|
||||||
readonly restoreLabel: string;
|
|
||||||
readonly maximizeLabel: string;
|
readonly maximizeLabel: string;
|
||||||
readonly outputLabel: string;
|
readonly outputLabel: string;
|
||||||
readonly unknownCommand: string;
|
readonly unknownCommand: string;
|
||||||
@@ -59,7 +58,6 @@ export const SIGNATURE_COPY: Record<AppLocale, SignatureCopy> = {
|
|||||||
inputLabel: 'Befehl',
|
inputLabel: 'Befehl',
|
||||||
inputPlaceholder: 'Befehl eingeben',
|
inputPlaceholder: 'Befehl eingeben',
|
||||||
collapseLabel: 'Terminal einklappen',
|
collapseLabel: 'Terminal einklappen',
|
||||||
restoreLabel: 'Terminal verkleinern',
|
|
||||||
maximizeLabel: 'Terminal vergrößern',
|
maximizeLabel: 'Terminal vergrößern',
|
||||||
outputLabel: 'Ausgabe',
|
outputLabel: 'Ausgabe',
|
||||||
unknownCommand: 'Unbekannter Befehl: {command}',
|
unknownCommand: 'Unbekannter Befehl: {command}',
|
||||||
@@ -113,7 +111,6 @@ export const SIGNATURE_COPY: Record<AppLocale, SignatureCopy> = {
|
|||||||
inputLabel: 'Command',
|
inputLabel: 'Command',
|
||||||
inputPlaceholder: 'Type a command',
|
inputPlaceholder: 'Type a command',
|
||||||
collapseLabel: 'Collapse the terminal',
|
collapseLabel: 'Collapse the terminal',
|
||||||
restoreLabel: 'Restore the terminal',
|
|
||||||
maximizeLabel: 'Maximise the terminal',
|
maximizeLabel: 'Maximise the terminal',
|
||||||
outputLabel: 'Output',
|
outputLabel: 'Output',
|
||||||
unknownCommand: 'Unknown command: {command}',
|
unknownCommand: 'Unknown command: {command}',
|
||||||
|
|||||||
@@ -25,11 +25,11 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="terminal-dock-control"
|
class="terminal-dock-control"
|
||||||
[attr.aria-label]="maximizeLabel()"
|
[attr.aria-label]="copy().maximizeLabel"
|
||||||
[attr.aria-pressed]="maximized()"
|
[attr.aria-pressed]="maximized()"
|
||||||
(click)="toggleMaximized()"
|
(click)="toggleMaximized()"
|
||||||
>
|
>
|
||||||
{{ maximizeLabel() }}
|
{{ copy().maximizeLabel }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -42,6 +42,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
#outputLog
|
||||||
class="terminal-dock-log"
|
class="terminal-dock-log"
|
||||||
[id]="logId"
|
[id]="logId"
|
||||||
role="log"
|
role="log"
|
||||||
|
|||||||
@@ -90,6 +90,11 @@
|
|||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.terminal-dock-control[aria-pressed='true'] {
|
||||||
|
border-color: var(--surface-glass-border-strong);
|
||||||
|
background: var(--color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
.terminal-dock-log {
|
.terminal-dock-log {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
min-height: 6rem;
|
min-height: 6rem;
|
||||||
|
|||||||
@@ -104,7 +104,12 @@ export class TerminalDockService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private readonly onDocumentKeydown = (event: KeyboardEvent): void => {
|
private readonly onDocumentKeydown = (event: KeyboardEvent): void => {
|
||||||
if (event.key.toLowerCase() !== 'k' || !(event.ctrlKey || event.metaKey) || event.altKey) {
|
if (
|
||||||
|
event.key.toLowerCase() !== 'k' ||
|
||||||
|
!(event.ctrlKey || event.metaKey) ||
|
||||||
|
event.altKey ||
|
||||||
|
event.shiftKey
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,42 @@ describe('TerminalDock', () => {
|
|||||||
expect(fixture.nativeElement.querySelector('.command-palette-scrim')).toBeNull();
|
expect(fixture.nativeElement.querySelector('.command-palette-scrim')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each(APP_LOCALES)('toggles the trigger open, closed, then open again (%s)', async (locale) => {
|
||||||
|
const fixture = await createFixture(locale);
|
||||||
|
const dock = TestBed.inject(TerminalDockService);
|
||||||
|
|
||||||
|
await openViaTrigger(fixture);
|
||||||
|
expect(panel(fixture)).toBeTruthy();
|
||||||
|
expect(dock.state()).toBe('expanded');
|
||||||
|
expect(document.activeElement).toBe(input(fixture));
|
||||||
|
|
||||||
|
trigger(fixture).click();
|
||||||
|
await flush(fixture);
|
||||||
|
expect(panel(fixture)).toBeNull();
|
||||||
|
expect(dock.state()).toBe('collapsed');
|
||||||
|
expect(document.activeElement).toBe(trigger(fixture));
|
||||||
|
|
||||||
|
await openViaTrigger(fixture);
|
||||||
|
expect(panel(fixture)).toBeTruthy();
|
||||||
|
expect(dock.state()).toBe('expanded');
|
||||||
|
expect(document.activeElement).toBe(input(fixture));
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(APP_LOCALES)(
|
||||||
|
'returns focus to the trigger after a trigger-initiated collapse (%s)',
|
||||||
|
async (locale) => {
|
||||||
|
const fixture = await createFixture(locale);
|
||||||
|
await openViaTrigger(fixture);
|
||||||
|
expect(document.activeElement).toBe(input(fixture));
|
||||||
|
|
||||||
|
trigger(fixture).click();
|
||||||
|
await flush(fixture);
|
||||||
|
|
||||||
|
expect(panel(fixture)).toBeNull();
|
||||||
|
expect(document.activeElement).toBe(trigger(fixture));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
it.each(APP_LOCALES)('moves through collapsed, expanded and maximized (%s)', async (locale) => {
|
it.each(APP_LOCALES)('moves through collapsed, expanded and maximized (%s)', async (locale) => {
|
||||||
const fixture = await createFixture(locale);
|
const fixture = await createFixture(locale);
|
||||||
const dock = TestBed.inject(TerminalDockService);
|
const dock = TestBed.inject(TerminalDockService);
|
||||||
@@ -111,6 +147,27 @@ describe('TerminalDock', () => {
|
|||||||
expect(dock.state()).toBe('expanded');
|
expect(dock.state()).toBe('expanded');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each(APP_LOCALES)(
|
||||||
|
'keeps one stable maximize name across both aria-pressed states (%s)',
|
||||||
|
async (locale) => {
|
||||||
|
const fixture = await createFixture(locale);
|
||||||
|
await openViaTrigger(fixture);
|
||||||
|
const label = SIGNATURE_COPY[locale].terminal.maximizeLabel;
|
||||||
|
const button = fixture.nativeElement.querySelector('[aria-pressed]') as HTMLButtonElement;
|
||||||
|
|
||||||
|
expect(button.getAttribute('aria-label')).toBe(label);
|
||||||
|
expect(button.textContent?.trim()).toBe(label);
|
||||||
|
expect(button.getAttribute('aria-pressed')).toBe('false');
|
||||||
|
|
||||||
|
button.click();
|
||||||
|
await flush(fixture);
|
||||||
|
|
||||||
|
expect(button.getAttribute('aria-label')).toBe(label);
|
||||||
|
expect(button.textContent?.trim()).toBe(label);
|
||||||
|
expect(button.getAttribute('aria-pressed')).toBe('true');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
it.each(APP_LOCALES)('opens from Ctrl+K and Meta+K (%s)', async (locale) => {
|
it.each(APP_LOCALES)('opens from Ctrl+K and Meta+K (%s)', async (locale) => {
|
||||||
const fixture = await createFixture(locale);
|
const fixture = await createFixture(locale);
|
||||||
await flush(fixture);
|
await flush(fixture);
|
||||||
@@ -138,6 +195,66 @@ describe('TerminalDock', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not toggle or preventDefault on Ctrl/Cmd+Shift+K', async () => {
|
||||||
|
const fixture = await createFixture();
|
||||||
|
await flush(fixture);
|
||||||
|
|
||||||
|
for (const modifier of [
|
||||||
|
{ ctrlKey: true, shiftKey: true },
|
||||||
|
{ metaKey: true, shiftKey: true },
|
||||||
|
] as const) {
|
||||||
|
const event = new KeyboardEvent('keydown', {
|
||||||
|
key: 'k',
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
...modifier,
|
||||||
|
});
|
||||||
|
const prevent = vi.spyOn(event, 'preventDefault');
|
||||||
|
document.dispatchEvent(event);
|
||||||
|
await flush(fixture);
|
||||||
|
|
||||||
|
expect(prevent).not.toHaveBeenCalled();
|
||||||
|
expect(panel(fixture)).toBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEvent = new KeyboardEvent('keydown', {
|
||||||
|
key: 'k',
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
ctrlKey: true,
|
||||||
|
});
|
||||||
|
const preventOpen = vi.spyOn(openEvent, 'preventDefault');
|
||||||
|
document.dispatchEvent(openEvent);
|
||||||
|
await flush(fixture);
|
||||||
|
expect(preventOpen).toHaveBeenCalled();
|
||||||
|
expect(panel(fixture)).toBeTruthy();
|
||||||
|
|
||||||
|
const shiftWhileOpen = new KeyboardEvent('keydown', {
|
||||||
|
key: 'k',
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
ctrlKey: true,
|
||||||
|
shiftKey: true,
|
||||||
|
});
|
||||||
|
const preventShift = vi.spyOn(shiftWhileOpen, 'preventDefault');
|
||||||
|
document.dispatchEvent(shiftWhileOpen);
|
||||||
|
await flush(fixture);
|
||||||
|
expect(preventShift).not.toHaveBeenCalled();
|
||||||
|
expect(panel(fixture)).toBeTruthy();
|
||||||
|
|
||||||
|
const metaOpen = new KeyboardEvent('keydown', {
|
||||||
|
key: 'k',
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
metaKey: true,
|
||||||
|
});
|
||||||
|
const preventMeta = vi.spyOn(metaOpen, 'preventDefault');
|
||||||
|
document.dispatchEvent(metaOpen);
|
||||||
|
await flush(fixture);
|
||||||
|
expect(preventMeta).toHaveBeenCalled();
|
||||||
|
expect(panel(fixture)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it.each(APP_LOCALES)('closes on Escape and returns focus to the trigger (%s)', async (locale) => {
|
it.each(APP_LOCALES)('closes on Escape and returns focus to the trigger (%s)', async (locale) => {
|
||||||
const fixture = await createFixture(locale);
|
const fixture = await createFixture(locale);
|
||||||
await openViaTrigger(fixture);
|
await openViaTrigger(fixture);
|
||||||
@@ -211,6 +328,71 @@ describe('TerminalDock', () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
it.each(APP_LOCALES)('scrolls the log to the newest line after output (%s)', async (locale) => {
|
||||||
|
const fixture = await createFixture(locale);
|
||||||
|
await openViaTrigger(fixture);
|
||||||
|
const logEl = fixture.nativeElement.querySelector('.terminal-dock-log') as HTMLElement;
|
||||||
|
const scrollTo = vi.fn();
|
||||||
|
Object.defineProperty(logEl, 'scrollHeight', { configurable: true, get: () => 480 });
|
||||||
|
Object.defineProperty(logEl, 'scrollTo', { configurable: true, value: scrollTo });
|
||||||
|
|
||||||
|
await submitQuery(fixture, 'help');
|
||||||
|
|
||||||
|
expect(scrollTo).toHaveBeenCalled();
|
||||||
|
expect(scrollTo.mock.calls.at(-1)?.[0]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
top: 480,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(APP_LOCALES)('walks command history like a conventional shell (%s)', async (locale) => {
|
||||||
|
const fixture = await createFixture(locale);
|
||||||
|
await openViaTrigger(fixture);
|
||||||
|
await submitQuery(fixture, 'help');
|
||||||
|
await submitQuery(fixture, 'brew');
|
||||||
|
await submitQuery(fixture, 'rev');
|
||||||
|
|
||||||
|
const field = input(fixture);
|
||||||
|
expect(field?.value).toBe('');
|
||||||
|
|
||||||
|
field?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
|
||||||
|
await flush(fixture);
|
||||||
|
expect(field?.value).toBe('');
|
||||||
|
|
||||||
|
field?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }));
|
||||||
|
await flush(fixture);
|
||||||
|
expect(field?.value).toBe('rev');
|
||||||
|
|
||||||
|
field?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }));
|
||||||
|
await flush(fixture);
|
||||||
|
expect(field?.value).toBe('brew');
|
||||||
|
|
||||||
|
field?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }));
|
||||||
|
await flush(fixture);
|
||||||
|
expect(field?.value).toBe('help');
|
||||||
|
|
||||||
|
field?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }));
|
||||||
|
await flush(fixture);
|
||||||
|
expect(field?.value).toBe('help');
|
||||||
|
|
||||||
|
field?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
|
||||||
|
await flush(fixture);
|
||||||
|
expect(field?.value).toBe('brew');
|
||||||
|
|
||||||
|
field?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
|
||||||
|
await flush(fixture);
|
||||||
|
expect(field?.value).toBe('rev');
|
||||||
|
|
||||||
|
field?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
|
||||||
|
await flush(fixture);
|
||||||
|
expect(field?.value).toBe('');
|
||||||
|
|
||||||
|
field?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
|
||||||
|
await flush(fixture);
|
||||||
|
expect(field?.value).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
it.each(APP_LOCALES)('navigates a known target and opens the CV asset (%s)', async (locale) => {
|
it.each(APP_LOCALES)('navigates a known target and opens the CV asset (%s)', async (locale) => {
|
||||||
const fixture = await createFixture(locale);
|
const fixture = await createFixture(locale);
|
||||||
const router = TestBed.inject(Router);
|
const router = TestBed.inject(Router);
|
||||||
|
|||||||
@@ -16,7 +16,11 @@ import { SIGNATURE_COPY } from '../../core/content/signature-copy';
|
|||||||
import { SITE_CONFIG } from '../../core/content/site-config';
|
import { SITE_CONFIG } from '../../core/content/site-config';
|
||||||
import { LocaleService } from '../../core/i18n/locale.service';
|
import { LocaleService } from '../../core/i18n/locale.service';
|
||||||
import { NavigationService } from '../../core/navigation/navigation.service';
|
import { NavigationService } from '../../core/navigation/navigation.service';
|
||||||
import { isApplePlatform, isBrowserPlatform } from '../../core/platform/browser';
|
import {
|
||||||
|
isApplePlatform,
|
||||||
|
isBrowserPlatform,
|
||||||
|
prefersReducedMotion,
|
||||||
|
} from '../../core/platform/browser';
|
||||||
import {
|
import {
|
||||||
applyCompletion,
|
applyCompletion,
|
||||||
COMMAND_IDS,
|
COMMAND_IDS,
|
||||||
@@ -48,9 +52,11 @@ export class TerminalDock {
|
|||||||
private readonly dock = inject(TerminalDockService);
|
private readonly dock = inject(TerminalDockService);
|
||||||
private readonly isBrowser = isBrowserPlatform();
|
private readonly isBrowser = isBrowserPlatform();
|
||||||
private readonly applePlatform = isApplePlatform();
|
private readonly applePlatform = isApplePlatform();
|
||||||
|
private readonly reducedMotion = prefersReducedMotion();
|
||||||
|
|
||||||
private readonly inputRef = viewChild<ElementRef<HTMLInputElement>>('commandInput');
|
private readonly inputRef = viewChild<ElementRef<HTMLInputElement>>('commandInput');
|
||||||
private readonly triggerRef = viewChild<ElementRef<HTMLButtonElement>>('trigger');
|
private readonly triggerRef = viewChild<ElementRef<HTMLButtonElement>>('trigger');
|
||||||
|
private readonly logRef = viewChild<ElementRef<HTMLElement>>('outputLog');
|
||||||
|
|
||||||
protected readonly panelId = this.dock.panelId;
|
protected readonly panelId = this.dock.panelId;
|
||||||
protected readonly inputId = this.dock.inputId;
|
protected readonly inputId = this.dock.inputId;
|
||||||
@@ -68,9 +74,6 @@ export class TerminalDock {
|
|||||||
);
|
);
|
||||||
protected readonly open = computed(() => this.state() !== 'collapsed');
|
protected readonly open = computed(() => this.state() !== 'collapsed');
|
||||||
protected readonly maximized = computed(() => this.state() === 'maximized');
|
protected readonly maximized = computed(() => this.state() === 'maximized');
|
||||||
protected readonly maximizeLabel = computed(() =>
|
|
||||||
this.maximized() ? this.copy().restoreLabel : this.copy().maximizeLabel,
|
|
||||||
);
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
afterNextRender(
|
afterNextRender(
|
||||||
@@ -96,12 +99,24 @@ export class TerminalDock {
|
|||||||
{ injector: this.injector },
|
{ injector: this.injector },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
effect(() => {
|
||||||
|
const lines = this.log();
|
||||||
|
if (!this.isBrowser || !this.open() || lines.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterNextRender(
|
||||||
|
() => {
|
||||||
|
this.scrollLogToLatest();
|
||||||
|
},
|
||||||
|
{ injector: this.injector },
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
protected onTriggerClick(): void {
|
protected onTriggerClick(): void {
|
||||||
const trigger = this.triggerRef()?.nativeElement;
|
this.dock.toggle(this.triggerRef()?.nativeElement ?? null);
|
||||||
trigger?.focus();
|
|
||||||
this.dock.open(trigger ?? null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected onQueryInput(event: Event): void {
|
protected onQueryInput(event: Event): void {
|
||||||
@@ -165,7 +180,7 @@ export class TerminalDock {
|
|||||||
this.echo(raw);
|
this.echo(raw);
|
||||||
this.entered.update((items) => [...items, raw]);
|
this.entered.update((items) => [...items, raw]);
|
||||||
this.historyIndex.set(null);
|
this.historyIndex.set(null);
|
||||||
this.query.set('');
|
this.writeQuery('');
|
||||||
|
|
||||||
switch (parsed.kind) {
|
switch (parsed.kind) {
|
||||||
case 'help': {
|
case 'help': {
|
||||||
@@ -237,7 +252,7 @@ export class TerminalDock {
|
|||||||
private completeCurrent(): void {
|
private completeCurrent(): void {
|
||||||
const result = applyCompletion(this.query());
|
const result = applyCompletion(this.query());
|
||||||
if (result.candidates.length === 1) {
|
if (result.candidates.length === 1) {
|
||||||
this.query.set(result.value);
|
this.writeQuery(result.value);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,15 +268,56 @@ export class TerminalDock {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const current = this.historyIndex();
|
const current = this.historyIndex();
|
||||||
const next =
|
if (current === null) {
|
||||||
current === null
|
if (direction > 0) {
|
||||||
? direction < 0
|
return;
|
||||||
? commands.length - 1
|
}
|
||||||
: 0
|
|
||||||
: Math.min(commands.length - 1, Math.max(0, current + direction));
|
const newest = commands.length - 1;
|
||||||
|
this.historyIndex.set(newest);
|
||||||
|
this.writeQuery(commands[newest] ?? '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = current + direction;
|
||||||
|
if (next < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (next >= commands.length) {
|
||||||
|
this.historyIndex.set(null);
|
||||||
|
this.writeQuery('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.historyIndex.set(next);
|
this.historyIndex.set(next);
|
||||||
this.query.set(commands[next] ?? '');
|
this.writeQuery(commands[next] ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private writeQuery(value: string): void {
|
||||||
|
this.query.set(value);
|
||||||
|
const field = this.inputRef()?.nativeElement;
|
||||||
|
if (field) {
|
||||||
|
field.value = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private scrollLogToLatest(): void {
|
||||||
|
const element = this.logRef()?.nativeElement;
|
||||||
|
if (!element) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const top = element.scrollHeight;
|
||||||
|
if (typeof element.scrollTo === 'function') {
|
||||||
|
element.scrollTo({
|
||||||
|
top,
|
||||||
|
behavior: this.reducedMotion ? 'auto' : 'smooth',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
element.scrollTop = top;
|
||||||
}
|
}
|
||||||
|
|
||||||
private echo(input: string): void {
|
private echo(input: string): void {
|
||||||
|
|||||||
Reference in New Issue
Block a user