Wires electrobun's Updater to the /dl download host. The packaged app checks https://miniscp.com/dl on launch + every 6h; when a newer build exists, an accent-colored "update tool" appears next to Connections in the tab bar. It announces the update, becomes its own inline progress bar while downloading, then flips to "Restart to apply" (two-step: download, then user-triggered relaunch). - electrobun.config.ts: release.baseUrl = https://miniscp.com/dl (baked into version.json at build; miniscp.com fronted by an external proxy for TLS). - rpc.ts: checkForUpdate/downloadUpdate/applyUpdate requests + updateStatus push. - rpc-handlers.ts + index.ts: call Updater; poll on launch+6h; stream onStatusChange progress. checkForUpdate self-guards the dev channel. - rpc.service.ts + new updater.service.ts: signal-based state (available -> downloading -> ready, ready derived from downloadUpdate ok). - tab-bar.ts: single bg-primary pill; progress fills the pill itself. - icons.ts: register mynaDownload. i18n: tabBar + menu keys across all locales. - menu.ts + app.ts: "Check for Updates…" app-menu item -> UpdaterService.check(). - docs: build-and-release + architecture updated. Unsigned builds still update (applyUpdate strips the macOS quarantine xattr). Verified: tsc --noEmit (exit 0) + build:ui AOT (exit 0). Live pill + full download/apply flow require a two-build test on the host. Co-Authored-By: Claude <noreply@anthropic.com>
541 lines
24 KiB
TypeScript
541 lines
24 KiB
TypeScript
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 { IS_MAC_CHROME, 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.<value>.
|
|
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: `
|
|
<!-- 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 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
|
|
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 rounded-md electrobun-webkit-app-region-no-drag"
|
|
[class.bg-accent]="ws.id === panels.activeWorkspaceId()"
|
|
[class.opacity-50]="draggedId() === ws.id"
|
|
[style.box-shadow]="dropIndicatorShadow(i)"
|
|
draggable="true"
|
|
(dragstart)="onDragStart($event, ws.id)"
|
|
(dragover)="onDragOver($event, i)"
|
|
(drop)="onDrop($event, i)"
|
|
(dragend)="onDragEnd()"
|
|
>
|
|
<button
|
|
(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) ? 'mynaServers' : 'mynaHardDrive'"
|
|
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)"
|
|
[title]="i18n.t('tabBar.closeTab', { mod: modLabel })"
|
|
class="mr-1 grid size-4 shrink-0 place-items-center rounded text-muted-foreground opacity-0 group-hover:opacity-100 hover:bg-border hover:text-foreground"
|
|
>
|
|
<ng-icon name="mynaX" 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 rounded-full bg-primary"></div>
|
|
}
|
|
</div>
|
|
}
|
|
</div>
|
|
<button
|
|
(click)="panels.newWorkspace()"
|
|
[title]="i18n.t('tabBar.newTab', { mod: modLabel })"
|
|
class="ml-0.5 grid size-6 place-items-center rounded-md text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
|
|
>
|
|
<ng-icon name="mynaPlus" 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 rounded-md px-2.5 text-[11px] 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="mynaDownload" size=".8rem" />{{ i18n.t('tabBar.update') }}
|
|
}
|
|
@case ('downloading') {
|
|
<ng-icon name="mynaSpinner" size=".8rem" class="animate-spin" />{{ i18n.t('tabBar.updating') }} {{ updater.progress() }}%
|
|
}
|
|
@case ('ready') {
|
|
<ng-icon name="mynaRefresh" size=".8rem" />{{ i18n.t('tabBar.restartToUpdate') }}
|
|
}
|
|
}
|
|
</span>
|
|
</button>
|
|
}
|
|
|
|
<!-- connection manager (own native window) -->
|
|
<button
|
|
(click)="openManager()"
|
|
[title]="i18n.t('tabBar.manageConnections')"
|
|
class="grid h-6 px-2 py-1 place-items-center rounded-md text-[11px] text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
|
|
>
|
|
<!-- <ng-icon name="mynaCog" size="0.9rem" /> -->
|
|
{{ i18n.t('tabBar.connections') }}
|
|
</button>
|
|
|
|
<!-- global options -->
|
|
<button
|
|
(click)="settingsOpen.set(!settingsOpen())"
|
|
[title]="i18n.t('tabBar.options')"
|
|
class="grid size-6 place-items-center rounded-md text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
|
|
>
|
|
<ng-icon name="mynaDots" size="1.5rem" />
|
|
</button>
|
|
|
|
@if (settingsOpen()) {
|
|
<div class="fixed inset-0 z-20" (click)="settingsOpen.set(false)"></div>
|
|
<div
|
|
class="absolute top-full right-1.5 z-30 mt-1 w-70 rounded-lg border border-border bg-card p-3 text-card-foreground shadow-xl electrobun-webkit-app-region-no-drag"
|
|
>
|
|
<h3 class="text-xs font-semibold">{{ i18n.t('options.title') }}</h3>
|
|
|
|
<div class="mt-3 flex items-center justify-between gap-2">
|
|
<span class="text-xs text-muted-foreground">{{ i18n.t('options.language') }}</span>
|
|
<select
|
|
(change)="settings.setLocale($any($event.target).value)"
|
|
class="h-7 rounded-md border border-input bg-background px-1.5 text-[11px] outline-none focus:ring-2 focus:ring-ring"
|
|
>
|
|
<option value="system" [selected]="settings.locale() === 'system'">
|
|
{{ i18n.t('options.system') }}
|
|
</option>
|
|
@for (loc of localeOptions; track loc) {
|
|
<option [value]="loc" [selected]="settings.locale() === loc">
|
|
{{ localeLabels[loc] }}
|
|
</option>
|
|
}
|
|
</select>
|
|
</div>
|
|
|
|
<div class="mt-2.5 flex items-center justify-between gap-2">
|
|
<span class="text-xs text-muted-foreground">{{ i18n.t('options.theme.label') }}</span>
|
|
<div class="flex rounded-md border border-border p-0.5">
|
|
@for (theme of themes; track theme) {
|
|
<button
|
|
(click)="settings.setTheme(theme)"
|
|
class="rounded px-2 py-0.5 text-[11px]"
|
|
[class.bg-accent]="settings.theme() === theme"
|
|
[class.text-foreground]="settings.theme() === theme"
|
|
[class.text-muted-foreground]="settings.theme() !== theme"
|
|
>
|
|
{{ i18n.t('options.theme.' + theme) }}
|
|
</button>
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
@if (settings.effectiveDark()) {
|
|
<div class="mt-2.5 flex items-center justify-between gap-2">
|
|
<span class="text-xs text-muted-foreground">{{ i18n.t('options.tone.label') }}</span>
|
|
<div class="flex rounded-md border border-border p-0.5">
|
|
@for (tone of darkTones; track tone) {
|
|
<button
|
|
(click)="settings.setDarkTone(tone)"
|
|
class="rounded px-2 py-0.5 text-[11px]"
|
|
[class.bg-accent]="settings.darkTone() === tone"
|
|
[class.text-foreground]="settings.darkTone() === tone"
|
|
[class.text-muted-foreground]="settings.darkTone() !== tone"
|
|
>
|
|
{{ i18n.t('options.tone.' + tone) }}
|
|
</button>
|
|
}
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
<div class="mt-2.5 flex items-center justify-between gap-2">
|
|
<span class="text-xs text-muted-foreground">{{ i18n.t('options.accent.label') }}</span>
|
|
<div class="flex items-center gap-1.5">
|
|
@for (tone of accents; track tone.value) {
|
|
<button
|
|
(click)="settings.setAccent(tone.value)"
|
|
[title]="i18n.t('options.accentNames.' + tone.value)"
|
|
class="size-3 rounded-full border border-foreground/15"
|
|
[style.background-color]="tone.swatch"
|
|
[class.ring-2]="settings.accent() === tone.value"
|
|
[class.ring-foreground]="settings.accent() === tone.value"
|
|
[class.ring-offset-1]="settings.accent() === tone.value"
|
|
[class.ring-offset-card]="settings.accent() === tone.value"
|
|
></button>
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-2.5 flex items-center justify-between gap-2">
|
|
<span class="text-xs text-muted-foreground">{{ i18n.t('options.fileIcons.label') }}</span>
|
|
<div class="flex rounded-md border border-border p-0.5">
|
|
@for (mode of iconColorModes; track mode) {
|
|
<button
|
|
(click)="settings.setIconColors(mode)"
|
|
class="rounded px-2 py-0.5 text-[11px]"
|
|
[class.bg-accent]="settings.iconColors() === mode"
|
|
[class.text-foreground]="settings.iconColors() === mode"
|
|
[class.text-muted-foreground]="settings.iconColors() !== mode"
|
|
>
|
|
{{ i18n.t('options.fileIcons.' + mode) }}
|
|
</button>
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-2.5 flex items-center justify-between gap-2">
|
|
<span class="text-xs text-muted-foreground">{{ i18n.t('options.interface.label') }}</span>
|
|
<div class="flex rounded-md border border-border p-0.5">
|
|
@for (size of textSizes; track size) {
|
|
<button
|
|
(click)="settings.setTextSize(size)"
|
|
class="rounded px-2 py-0.5 text-[11px]"
|
|
[class.bg-accent]="settings.textSize() === size"
|
|
[class.text-foreground]="settings.textSize() === size"
|
|
[class.text-muted-foreground]="settings.textSize() !== size"
|
|
>
|
|
{{ i18n.t('options.interface.' + size) }}
|
|
</button>
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-2.5 flex items-center justify-between gap-2">
|
|
<span class="text-xs text-muted-foreground">{{ i18n.t('options.speedUnit.label') }}</span>
|
|
<div class="flex rounded-md border border-border p-0.5">
|
|
@for (unit of speedUnits; track unit.value) {
|
|
<button
|
|
(click)="settings.setSpeedUnit(unit.value)"
|
|
class="rounded px-2 py-0.5 text-[11px]"
|
|
[class.bg-accent]="settings.speedUnit() === unit.value"
|
|
[class.text-foreground]="settings.speedUnit() === unit.value"
|
|
[class.text-muted-foreground]="settings.speedUnit() !== unit.value"
|
|
>
|
|
{{ unit.label }}
|
|
</button>
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-2.5 flex items-center justify-between gap-2">
|
|
<span class="text-xs text-muted-foreground">{{ i18n.t('options.transfers.label') }}</span>
|
|
<div class="flex rounded-md border border-border p-0.5">
|
|
@for (mode of transferModes; track mode) {
|
|
<button
|
|
(click)="settings.setTransferMode(mode)"
|
|
class="rounded px-2 py-0.5 text-[11px]"
|
|
[class.bg-accent]="settings.transferMode() === mode"
|
|
[class.text-foreground]="settings.transferMode() === mode"
|
|
[class.text-muted-foreground]="settings.transferMode() !== mode"
|
|
>
|
|
{{ i18n.t('options.transfers.' + mode) }}
|
|
</button>
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
@if (settings.transferMode() === 'multi') {
|
|
<div class="mt-2.5 flex items-center justify-between gap-2 pl-3">
|
|
<span class="text-xs text-muted-foreground">{{ i18n.t('options.atOnce') }}</span>
|
|
<div class="flex rounded-md border border-border p-0.5">
|
|
@for (count of concurrencyChoices; track count) {
|
|
<button
|
|
(click)="settings.setTransferConcurrency(count)"
|
|
class="rounded px-2 py-0.5 text-[11px] tabular-nums"
|
|
[class.bg-accent]="settings.transferConcurrency() === count"
|
|
[class.text-foreground]="settings.transferConcurrency() === count"
|
|
[class.text-muted-foreground]="settings.transferConcurrency() !== count"
|
|
>
|
|
{{ count }}
|
|
</button>
|
|
}
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
<div class="mt-2.5 flex items-center justify-between gap-2">
|
|
<span class="text-xs text-muted-foreground">{{ i18n.t('options.editor.label') }}</span>
|
|
<div class="flex rounded-md border border-border p-0.5">
|
|
<button
|
|
(click)="settings.setEditor('builtin')"
|
|
class="rounded px-2 py-0.5 text-[11px]"
|
|
[class.bg-accent]="settings.editor() === 'builtin'"
|
|
[class.text-foreground]="settings.editor() === 'builtin'"
|
|
[class.text-muted-foreground]="settings.editor() !== 'builtin'"
|
|
>
|
|
{{ i18n.t('options.editor.builtin') }}
|
|
</button>
|
|
<button
|
|
(click)="settings.setEditor('custom')"
|
|
class="rounded px-2 py-0.5 text-[11px]"
|
|
[class.bg-accent]="settings.editor() === 'custom'"
|
|
[class.text-foreground]="settings.editor() === 'custom'"
|
|
[class.text-muted-foreground]="settings.editor() !== 'custom'"
|
|
>
|
|
{{ i18n.t('options.editor.custom') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
@if (settings.editor() === 'custom') {
|
|
<div class="mt-2.5 flex items-center justify-between gap-2 pl-3">
|
|
<span class="min-w-0 truncate text-xs text-muted-foreground" [title]="settings.editorApp()">
|
|
{{ editorAppName() }}
|
|
</span>
|
|
<div class="flex shrink-0 items-center gap-1">
|
|
<button
|
|
(click)="pickEditorApp()"
|
|
class="rounded-md border border-border px-2 py-0.5 text-[11px] hover:bg-accent"
|
|
>
|
|
{{ i18n.t('common.choose') }}
|
|
</button>
|
|
@if (settings.editorApp()) {
|
|
<button
|
|
(click)="settings.setEditorApp('')"
|
|
[title]="i18n.t('options.resetDefault')"
|
|
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
>
|
|
<ng-icon name="mynaX" size="0.7rem" />
|
|
</button>
|
|
}
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
<div class="mt-2.5 flex items-center justify-between gap-2">
|
|
<span class="text-xs text-muted-foreground">{{ i18n.t('options.finishedTransfers.label') }}</span>
|
|
<div class="flex rounded-md border border-border p-0.5">
|
|
<button
|
|
(click)="settings.setAutoClearCompleted(true)"
|
|
class="rounded px-2 py-0.5 text-[11px]"
|
|
[class.bg-accent]="settings.autoClearCompleted()"
|
|
[class.text-foreground]="settings.autoClearCompleted()"
|
|
[class.text-muted-foreground]="!settings.autoClearCompleted()"
|
|
>
|
|
{{ i18n.t('options.finishedTransfers.autoClear') }}
|
|
</button>
|
|
<button
|
|
(click)="settings.setAutoClearCompleted(false)"
|
|
class="rounded px-2 py-0.5 text-[11px]"
|
|
[class.bg-accent]="!settings.autoClearCompleted()"
|
|
[class.text-foreground]="!settings.autoClearCompleted()"
|
|
[class.text-muted-foreground]="settings.autoClearCompleted()"
|
|
>
|
|
{{ i18n.t('options.finishedTransfers.keep') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Auto-refresh is always on for now. To expose a toggle, uncomment
|
|
the breadcrumbs in types.ts + settings.service, then this row:
|
|
<div class="mt-2.5 flex items-center justify-between gap-2">
|
|
<span class="text-xs text-muted-foreground">Auto-refresh</span>
|
|
<div class="flex rounded-md border border-border p-0.5">
|
|
<button (click)="settings.setAutoRefresh(true)" class="rounded px-2 py-0.5 text-[11px]"
|
|
[class.bg-accent]="settings.autoRefresh()"
|
|
[class.text-foreground]="settings.autoRefresh()"
|
|
[class.text-muted-foreground]="!settings.autoRefresh()">On</button>
|
|
<button (click)="settings.setAutoRefresh(false)" class="rounded px-2 py-0.5 text-[11px]"
|
|
[class.bg-accent]="!settings.autoRefresh()"
|
|
[class.text-foreground]="!settings.autoRefresh()"
|
|
[class.text-muted-foreground]="settings.autoRefresh()">Off</button>
|
|
</div>
|
|
</div>
|
|
-->
|
|
|
|
<!-- about footer -->
|
|
<button
|
|
(click)="showAbout()"
|
|
class="mt-3 w-full border-t border-border pt-2 text-center text-[11px] text-muted-foreground hover:text-foreground"
|
|
>
|
|
{{ appName }} · {{ buildLabel }}
|
|
</button>
|
|
</div>
|
|
}
|
|
</div>
|
|
`,
|
|
})
|
|
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 isMacChrome = IS_MAC_CHROME;
|
|
readonly modLabel = MOD_LABEL;
|
|
readonly appName = BRANDING.appName;
|
|
readonly buildLabel = BRANDING.buildLabel;
|
|
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<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);
|
|
}
|
|
|
|
/** "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<void> {
|
|
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);
|
|
}
|
|
}
|