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>
264 lines
10 KiB
TypeScript
264 lines
10 KiB
TypeScript
import { Component, ElementRef, effect, inject, signal, viewChild } from '@angular/core';
|
|
import { NgIcon } from '@ng-icons/core';
|
|
import { ContextMenuService } from '@core/context-menu.service';
|
|
import { I18nService } from '@core/i18n.service';
|
|
import { PanelsService, type Workspace } from '@core/panels.service';
|
|
import { IS_MAC_CHROME } from '@core/platform';
|
|
import { RpcService } from '@core/rpc.service';
|
|
import { ShortcutsService } from '@core/shortcuts.service';
|
|
import { UpdaterService } from '@core/updater.service';
|
|
|
|
@Component({
|
|
selector: 'app-tab-bar',
|
|
imports: [NgIcon],
|
|
template: `
|
|
<!-- On macOS this row IS the titlebar (hiddenInset): taller, padded past the
|
|
traffic lights, and draggable. Elsewhere it's a normal compact row. -->
|
|
<div
|
|
class="relative flex items-center gap-0.5 border-b border-border bg-chrome pr-1.5 electrobun-webkit-app-region-drag"
|
|
[class.h-10]="isMacChrome"
|
|
[class.pl-24]="isMacChrome"
|
|
[class.pl-1.5]="!isMacChrome"
|
|
[class.pt-1]="!isMacChrome"
|
|
>
|
|
<div
|
|
#tabScroll
|
|
role="tablist"
|
|
class="flex min-w-0 items-center gap-0.5 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
|
>
|
|
@for (ws of panels.workspaces(); track ws.id; let i = $index) {
|
|
<div
|
|
[attr.data-tab-id]="ws.id"
|
|
class="group relative flex max-w-52 shrink-0 items-center electrobun-webkit-app-region-no-drag"
|
|
[class.bg-accent]="ws.id === panels.activeWorkspaceId()"
|
|
[class.opacity-50]="draggedId() === ws.id"
|
|
[style.box-shadow]="dropIndicatorShadow(i)"
|
|
(contextmenu)="onTabContextMenu($event, ws, i)"
|
|
(mousedown)="onTabMouseDown($event)"
|
|
(auxclick)="onAuxClick($event, ws.id)"
|
|
draggable="true"
|
|
(dragstart)="onDragStart($event, ws.id)"
|
|
(dragover)="onDragOver($event, i)"
|
|
(drop)="onDrop($event, i)"
|
|
(dragend)="onDragEnd()"
|
|
>
|
|
<button
|
|
role="tab"
|
|
[attr.aria-selected]="ws.id === panels.activeWorkspaceId()"
|
|
(click)="panels.setActiveWorkspace(ws.id)"
|
|
class="flex min-w-0 items-center gap-1.5 py-1.5 pr-1 pl-2.5 text-xs"
|
|
[class.text-foreground]="ws.id === panels.activeWorkspaceId()"
|
|
[class.text-muted-foreground]="ws.id !== panels.activeWorkspaceId()"
|
|
[title]="panels.workspaceName(ws)"
|
|
>
|
|
<ng-icon
|
|
[name]="panels.workspaceIsRemote(ws) ? 'tdesignServer' : 'tdesignHardDrive'"
|
|
size="0.85rem"
|
|
class="shrink-0"
|
|
[class.text-primary]="panels.workspaceIsRemote(ws)"
|
|
/>
|
|
<span class="truncate">{{ panels.workspaceName(ws) }}</span>
|
|
</button>
|
|
@if (panels.workspaces().length > 1) {
|
|
<button
|
|
(click)="close($event, ws.id)"
|
|
tabindex="-1"
|
|
aria-hidden="true"
|
|
[title]="i18n.t('tabBar.closeTab')"
|
|
class="mr-1 grid size-4 shrink-0 place-items-center text-muted-foreground opacity-0 group-hover:opacity-100 hover:bg-border hover:text-foreground"
|
|
>
|
|
<ng-icon name="tdesignClose" size="0.7rem" />
|
|
</button>
|
|
} @else {
|
|
<span class="mr-1 size-4 shrink-0"></span>
|
|
}
|
|
@if (ws.id === panels.activeWorkspaceId()) {
|
|
<div class="absolute inset-x-0 -bottom-px h-0.5 bg-primary"></div>
|
|
}
|
|
</div>
|
|
}
|
|
</div>
|
|
<button
|
|
(click)="panels.newWorkspace()"
|
|
[title]="shortcuts.tip(i18n.t('tabBar.newTab'), 'new-tab')"
|
|
class="ml-0.5 grid size-6 place-items-center text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
|
|
>
|
|
<ng-icon name="tdesignAdd" size=".8rem" />
|
|
</button>
|
|
|
|
<div class="flex-1"></div>
|
|
|
|
<!-- auto-update tool: one accent pill that announces the update, becomes its
|
|
own progress bar while downloading, then turns into "Restart to apply" -->
|
|
@if (updater.state() !== 'idle' && updater.state() !== 'error') {
|
|
<button
|
|
(click)="onUpdateClick()"
|
|
[disabled]="updater.state() === 'downloading'"
|
|
[title]="i18n.t('tabBar.updateTooltip', { version: updater.version() })"
|
|
class="relative mr-1 grid h-6 place-items-center overflow-hidden px-2.5 text-[0.6875rem] font-medium bg-primary text-primary-foreground electrobun-webkit-app-region-no-drag hover:opacity-90 disabled:cursor-default"
|
|
>
|
|
@if (updater.state() === 'downloading') {
|
|
<span
|
|
class="absolute inset-y-0 left-0 bg-primary-foreground/25 transition-[width] duration-200"
|
|
[style.width.%]="updater.progress()"
|
|
></span>
|
|
}
|
|
<span class="relative flex items-center gap-1.5">
|
|
@switch (updater.state()) {
|
|
@case ('available') {
|
|
<ng-icon name="tdesignDownload" size=".8rem" />{{ i18n.t('tabBar.update') }}
|
|
}
|
|
@case ('downloading') {
|
|
<ng-icon name="tdesignLoading" size=".8rem" class="animate-spin" />{{ i18n.t('tabBar.updating') }} {{ updater.progress() }}%
|
|
}
|
|
@case ('ready') {
|
|
<ng-icon name="tdesignRefresh" size=".8rem" />{{ i18n.t('tabBar.restartToUpdate') }}
|
|
}
|
|
}
|
|
</span>
|
|
</button>
|
|
}
|
|
|
|
<!-- connection manager (own native window) -->
|
|
<button
|
|
(click)="openManager()"
|
|
[title]="shortcuts.tip(i18n.t('tabBar.manageConnections'), 'connection-manager')"
|
|
class="grid h-6 px-2 py-1 place-items-center text-[0.6875rem] text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
|
|
>
|
|
<!-- <ng-icon name="tdesignSetting" size="0.9rem" /> -->
|
|
{{ i18n.t('tabBar.connections') }}
|
|
</button>
|
|
|
|
<!-- settings (own native window) -->
|
|
<button
|
|
(click)="openSettings()"
|
|
[title]="shortcuts.tip(i18n.t('tabBar.options'), 'open-settings')"
|
|
class="grid size-6 place-items-center text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
|
|
>
|
|
<ng-icon name="tdesignSetting" size="0.9rem" />
|
|
</button>
|
|
</div>
|
|
`,
|
|
})
|
|
export class TabBar {
|
|
readonly panels = inject(PanelsService);
|
|
readonly i18n = inject(I18nService);
|
|
readonly updater = inject(UpdaterService);
|
|
private readonly contextMenu = inject(ContextMenuService);
|
|
private readonly rpc = inject(RpcService);
|
|
readonly shortcuts = inject(ShortcutsService);
|
|
readonly isMacChrome = IS_MAC_CHROME;
|
|
|
|
// drag & drop reordering
|
|
readonly draggedId = signal<number | null>(null);
|
|
readonly dropTarget = signal<{ index: number; before: boolean } | null>(null);
|
|
|
|
private readonly tabScroll = viewChild.required<ElementRef<HTMLDivElement>>('tabScroll');
|
|
|
|
constructor() {
|
|
// keep the active tab visible when the strip overflows
|
|
effect(() => {
|
|
const id = this.panels.activeWorkspaceId();
|
|
this.tabScroll()
|
|
.nativeElement.querySelector(`[data-tab-id="${id}"]`)
|
|
?.scrollIntoView({ inline: 'nearest', block: 'nearest' });
|
|
});
|
|
}
|
|
|
|
close(event: MouseEvent, id: number): void {
|
|
event.stopPropagation();
|
|
this.panels.closeWorkspace(id);
|
|
}
|
|
|
|
/**
|
|
* Middle-click closes the tab, as in browsers. `auxclick` also fires for the
|
|
* right button, hence the button check; the last tab is refused by
|
|
* closeWorkspace itself.
|
|
*/
|
|
onAuxClick(event: MouseEvent, id: number): void {
|
|
if (event.button !== 1) return;
|
|
event.preventDefault();
|
|
this.close(event, id);
|
|
}
|
|
|
|
/** A middle press over the scrollable strip would start WebView2's autoscroll. */
|
|
onTabMouseDown(event: MouseEvent): void {
|
|
if (event.button === 1) event.preventDefault();
|
|
}
|
|
|
|
/**
|
|
* Right-click a tab. The menu targets THIS tab, not the active one — its
|
|
* actions all carry the id — so right-clicking a background tab does not
|
|
* yank focus to it first, the way Explorer and every browser behave.
|
|
*/
|
|
onTabContextMenu(event: MouseEvent, workspace: Workspace, index: number): void {
|
|
event.preventDefault();
|
|
this.contextMenu.open(
|
|
event,
|
|
this.contextMenu.tabItems({
|
|
id: workspace.id,
|
|
index,
|
|
tabCount: this.panels.workspaces().length,
|
|
isRemote: this.panels.workspaceIsRemote(workspace),
|
|
}),
|
|
);
|
|
}
|
|
|
|
openManager(): void {
|
|
void this.rpc.request.openConnectionManager({});
|
|
}
|
|
|
|
openSettings(): void {
|
|
void this.rpc.request.openSettings({});
|
|
}
|
|
|
|
/** Update pill: first click downloads, second click (when ready) applies + relaunches. */
|
|
onUpdateClick(): void {
|
|
this.updater.advance();
|
|
}
|
|
|
|
/** Insertion line at the drop position (inset shadow: no layout shift). */
|
|
dropIndicatorShadow(index: number): string | null {
|
|
const target = this.dropTarget();
|
|
if (!target || target.index !== index || this.draggedId() === null) return null;
|
|
return target.before
|
|
? 'inset 2px 0 0 var(--primary)'
|
|
: 'inset -2px 0 0 var(--primary)';
|
|
}
|
|
|
|
onDragStart(event: DragEvent, id: number): void {
|
|
this.draggedId.set(id);
|
|
// WebKit refuses to start a drag without data
|
|
event.dataTransfer?.setData('text/plain', String(id));
|
|
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
|
|
}
|
|
|
|
onDragOver(event: DragEvent, index: number): void {
|
|
if (this.draggedId() === null) return;
|
|
event.preventDefault();
|
|
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
|
|
const el = event.currentTarget as HTMLElement;
|
|
const before = event.clientX < el.getBoundingClientRect().left + el.offsetWidth / 2;
|
|
this.dropTarget.set({ index, before });
|
|
}
|
|
|
|
onDrop(event: DragEvent, index: number): void {
|
|
event.preventDefault();
|
|
const id = this.draggedId();
|
|
const target = this.dropTarget();
|
|
this.onDragEnd();
|
|
if (id === null || !target) return;
|
|
const list = this.panels.workspaces();
|
|
const fromIndex = list.findIndex((w) => w.id === id);
|
|
if (fromIndex < 0) return;
|
|
let toIndex = target.before ? index : index + 1;
|
|
if (fromIndex < toIndex) toIndex--; // account for removal before insert
|
|
if (toIndex !== fromIndex) this.panels.moveWorkspace(id, toIndex);
|
|
}
|
|
|
|
onDragEnd(): void {
|
|
this.draggedId.set(null);
|
|
this.dropTarget.set(null);
|
|
}
|
|
}
|