import { Component, ElementRef, effect, inject, signal, viewChild } from '@angular/core'; import { NgIcon } from '@ng-icons/core'; import { BRANDING } from '@shared/branding'; import { LOCALE_LABELS, SUPPORTED } from '@shared/i18n'; import type { AccentTone } from '@shared/types'; import { I18nService } from '../i18n.service'; import { PanelsService } from '../panels.service'; import { MOD_LABEL } from '../platform'; import { RpcService } from '../rpc.service'; import { SettingsService } from '../settings.service'; import { UpdaterService } from '../updater.service'; const THEMES = ['system', 'light', 'dark'] as const; const DARK_TONES = ['dark', 'darker'] 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). Names are // translated via options.accentNames.. const ACCENTS: readonly { value: AccentTone; swatch: string }[] = [ { value: 'graphite', swatch: 'oklch(0.552 0.016 285.9)' }, { value: 'blue', swatch: 'oklch(0.65 0.17 255)' }, { value: 'indigo', swatch: 'oklch(0.65 0.19 277)' }, { value: 'purple', swatch: 'oklch(0.65 0.17 305)' }, { value: 'rose', swatch: 'oklch(0.65 0.17 8)' }, { value: 'orange', swatch: 'oklch(0.68 0.17 50)' }, { value: 'green', swatch: 'oklch(0.65 0.15 150)' }, { value: 'teal', swatch: 'oklch(0.65 0.12 185)' }, ]; 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 (updater.state() !== 'idle' && updater.state() !== 'error') { } @if (settingsOpen()) {

{{ i18n.t('options.title') }}

{{ i18n.t('options.language') }}
{{ i18n.t('options.theme.label') }}
@for (theme of themes; track theme) { }
@if (settings.effectiveDark()) {
{{ i18n.t('options.tone.label') }}
@for (tone of darkTones; track tone) { }
}
{{ i18n.t('options.accent.label') }}
@for (tone of accents; track tone.value) { }
{{ i18n.t('options.fileIcons.label') }}
@for (mode of iconColorModes; track mode) { }
{{ i18n.t('options.interface.label') }}
@for (size of textSizes; track size) { }
{{ i18n.t('options.speedUnit.label') }}
@for (unit of speedUnits; track unit.value) { }
{{ i18n.t('options.transfers.label') }}
@for (mode of transferModes; track mode) { }
@if (settings.transferMode() === 'multi') {
{{ i18n.t('options.atOnce') }}
@for (count of concurrencyChoices; track count) { }
}
{{ i18n.t('options.editor.label') }}
@if (settings.editor() === 'custom') {
{{ editorAppName() }}
@if (settings.editorApp()) { }
}
{{ i18n.t('options.finishedTransfers.label') }}
}
`, }) export class TabBar { readonly panels = inject(PanelsService); readonly settings = inject(SettingsService); readonly i18n = inject(I18nService); readonly updater = inject(UpdaterService); private readonly rpc = inject(RpcService); readonly settingsOpen = signal(false); readonly localeOptions = SUPPORTED; readonly localeLabels = LOCALE_LABELS; readonly modLabel = MOD_LABEL; readonly appName = BRANDING.appName; readonly buildLabel = BRANDING.buildLabel; /** What the window was BUILT with, not the current setting — the frameless * chrome only changes on reopen, so the hint compares against this. */ readonly themes = THEMES; readonly darkTones = DARK_TONES; 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 this.i18n.t('options.systemDefault'); const base = path.split('/').filter(Boolean).pop() ?? path; return base.replace(/\.app$/i, ''); } openManager(): void { void this.rpc.request.openConnectionManager({}); } /** Update pill: first click downloads, second click (when ready) applies + relaunches. */ onUpdateClick(): void { const state = this.updater.state(); if (state === 'available') void this.updater.download(); else if (state === 'ready') this.updater.apply(); } showAbout(): void { void this.rpc.request.showAbout({}); } 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); } }