import { Component, ElementRef, effect, inject, signal, viewChild } from '@angular/core'; import { NgIcon } from '@ng-icons/core'; import type { AccentTone } from '@shared/types'; import { PanelsService } from '../panels.service'; import { IS_MAC_CHROME } from '../platform'; import { RpcService } from '../rpc.service'; import { SettingsService } from '../settings.service'; const THEMES = ['system', 'light', 'dark'] as const; // Swatch colors are fixed mid-tones so every dot reads on both light and dark // cards (the live --primary values would vanish against one of them). const ACCENTS: readonly { value: AccentTone; swatch: string; label: string }[] = [ { value: 'graphite', swatch: 'oklch(0.552 0.016 285.9)', label: 'Graphite' }, { value: 'blue', swatch: 'oklch(0.65 0.17 255)', label: 'Blue' }, { value: 'indigo', swatch: 'oklch(0.65 0.19 277)', label: 'Indigo' }, { value: 'purple', swatch: 'oklch(0.65 0.17 305)', label: 'Purple' }, { value: 'rose', swatch: 'oklch(0.65 0.17 8)', label: 'Rose' }, { value: 'orange', swatch: 'oklch(0.68 0.17 50)', label: 'Orange' }, { value: 'green', swatch: 'oklch(0.65 0.15 150)', label: 'Green' }, { value: 'teal', swatch: 'oklch(0.65 0.12 185)', label: 'Teal' }, ]; const SPEED_UNITS = [ { value: 'MBps', label: 'MB/s' }, { value: 'Mbps', label: 'Mbps' }, ] as const; const TEXT_SIZES = ['compact', 'default', 'large'] as const; const ICON_COLOR_MODES = ['plain', 'multi'] as const; const TRANSFER_MODES = ['single', 'multi'] as const; const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const; @Component({ selector: 'app-tab-bar', imports: [NgIcon], template: `
@for (ws of panels.workspaces(); track ws.id; let i = $index) {
@if (panels.workspaces().length > 1) { } @else { } @if (ws.id === panels.activeWorkspaceId()) {
}
}
@if (settingsOpen()) {

Options

Theme
@for (theme of themes; track theme) { }
Accent
@for (tone of accents; track tone.value) { }
File colors
@for (mode of iconColorModes; track mode) { }
Text size
@for (size of textSizes; track size) { }
Speed unit
@for (unit of speedUnits; track unit.value) { }
Transfers
@for (mode of transferModes; track mode) { }
@if (settings.transferMode() === 'multi') {
At once
@for (count of concurrencyChoices; track count) { }
}
Editor
@if (settings.editor() === 'custom') {
{{ editorAppName() }}
@if (settings.editorApp()) { }
}
Finished transfers
}
`, }) export class TabBar { readonly panels = inject(PanelsService); readonly settings = inject(SettingsService); private readonly rpc = inject(RpcService); readonly settingsOpen = signal(false); readonly isMacChrome = IS_MAC_CHROME; readonly themes = THEMES; readonly accents = ACCENTS; readonly iconColorModes = ICON_COLOR_MODES; readonly speedUnits = SPEED_UNITS; readonly textSizes = TEXT_SIZES; readonly transferModes = TRANSFER_MODES; readonly concurrencyChoices = CONCURRENCY_CHOICES; // drag & drop reordering readonly draggedId = signal(null); readonly dropTarget = signal<{ index: number; before: boolean } | null>(null); private readonly tabScroll = viewChild.required>('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); } /** "Visual Studio Code.app" -> "Visual Studio Code"; empty -> system default. */ editorAppName(): string { const path = this.settings.editorApp(); if (!path) return 'System default'; const base = path.split('/').filter(Boolean).pop() ?? path; return base.replace(/\.app$/i, ''); } async pickEditorApp(): Promise { const res = await this.rpc.request.pickEditorApp({}); if (res.ok && res.path) this.settings.setEditorApp(res.path); } /** 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); } }