Stage 2: move the win7 component tree into app/skins/7like/

All 14 components (including title-bar/, which has no modern counterpart)
move under app/skins/7like/. git records every one as a rename — no content
changed except main.ts.

The @core alias from stage 1 paid off exactly as intended: not one of the 67
service imports needed touching despite every file changing depth. The only
relative imports left inside the tree are skin-internal (app -> its dialogs,
connection-manager -> title-bar), and those survived because the move
preserved their relative structure.

Added skins/7like/index.ts exporting SEVEN_ROOTS { main, manager, editor }.
Every skin will export that same shape, so main.ts can pick a tree by skin
and then a root by APP_MODE without knowing either skin's internals. main.ts
selects from that barrel now, with the second axis still hardcoded until the
shadlike tree lands.

Verified: tsc clean, build:ui clean, opaque interior pixel-identical to the
pre-refactor baseline. That last check also proves Tailwind still scans the
templates from their new skins/** paths — a silent utility purge would have
collapsed the layout rather than reproducing it exactly.

The three structural 7.css contracts that a careless move breaks silently are
intact by construction, since no markup changed: app-title-bar is still a
direct child of .window.w7-frame, nothing new nests inside menu[role=tablist],
and the context menu still emits ul[role=menu] > li[role=menuitem] > button.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-16 04:50:53 +02:00
co-authored by Claude
parent 17bb1a14fb
commit 39e019af9f
21 changed files with 21 additions and 4 deletions
+68
View File
@@ -0,0 +1,68 @@
<!-- .window.glass is 7.css's Aero frame (radius, 1px border, inset highlight,
drop shadow, backdrop blur); .active is what un-greys the title bar and its
control glyphs — every .title-bar-controls rule is keyed on it. -->
<div class="window glass w7-frame flex h-screen flex-col" [class.active]="windowFocused()">
@if (!rpc.available) {
<div class="flex flex-1 items-center justify-center">
<div class="max-w-sm rounded-lg border border-border bg-card p-6 text-center shadow-sm">
<ng-icon name="mynaXCircle" size="1.5rem" class="text-destructive" />
<p class="mt-2 text-sm font-medium">{{ i18n.t('app.notConnectedTitle') }}</p>
<p class="mt-1 text-xs text-muted-foreground">
{{ i18n.t('app.notConnectedBody') }}
</p>
</div>
</div>
} @else {
<!-- the window is frameless, so this IS the chrome -->
<app-title-bar [title]="appName" (closeRequested)="closeWindow()" />
<!-- 7.css's .window-body: the recessed inner panel, with the 6px inset and
its own border that give a Win7 window its depth. -->
<div class="window-body flex min-h-0 flex-1 flex-col overflow-hidden">
<app-tab-bar />
<main class="flex min-h-0 flex-1 divide-x divide-border">
<app-file-panel side="left" />
<app-file-panel side="right" />
</main>
<app-transfer-bar />
<app-status-bar />
</div>
}
<!-- modal block: the connection-manager window owns the app until closed -->
@if (managerOpen()) {
<div
class="fixed inset-0 z-60 flex items-center justify-center bg-black/40"
(mousedown)="focusManager()"
>
<div class="rounded-lg border border-border bg-card px-5 py-4 text-center shadow-xl">
<ng-icon name="mynaServers" size="1.25rem" class="text-muted-foreground" />
<p class="mt-1.5 text-xs text-muted-foreground">{{ i18n.t('app.managerOpen') }}</p>
<button
class="mt-3 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
>
{{ i18n.t('app.showConnections') }}
</button>
</div>
</div>
}
<!-- dialogs -->
@if (dialogs.prompt(); as req) {
<app-prompt-dialog [request]="req" />
}
@if (dialogs.confirm(); as req) {
<app-confirm-dialog [request]="req" />
}
@if (dialogs.chmod(); as req) {
<app-chmod-dialog [request]="req" />
}
@if (dialogs.overwrite(); as req) {
<app-overwrite-dialog [request]="req" />
}
<app-context-menu />
<app-toasts />
</div>
+253
View File
@@ -0,0 +1,253 @@
import { Component, inject, signal } from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import { I18nService } from '@core/i18n.service';
import { BRANDING } from '@shared/branding';
import type { ColumnKey } from '@shared/types';
import { ConnectionsService } from '@core/connections.service';
import { ContextMenu } from './context-menu';
import { ChmodDialog } from './dialogs/chmod-dialog';
import { ConfirmDialog } from './dialogs/confirm-dialog';
import { OverwriteDialog } from './dialogs/overwrite-dialog';
import { PromptDialog } from './dialogs/prompt-dialog';
import { DialogsService } from '@core/dialogs.service';
import { FileActionsService } from '@core/file-actions.service';
import { FilePanel } from './file-panel/file-panel';
import { PanelsService } from '@core/panels.service';
import { blockNativeContextMenu } from '@core/platform';
import { RpcService } from '@core/rpc.service';
import { SettingsService } from '@core/settings.service';
import { StatusBar } from './status-bar/status-bar';
import { TabBar } from './tab-bar/tab-bar';
import { TitleBar } from './title-bar/title-bar';
import { ToastService } from '@core/toast.service';
import { Toasts } from './toasts/toasts';
import { TransferBar } from './transfer-bar/transfer-bar';
import { TransfersService } from '@core/transfers.service';
import { UpdaterService } from '@core/updater.service';
@Component({
selector: 'app-root',
imports: [
NgIcon,
FilePanel,
TabBar,
StatusBar,
PromptDialog,
ConfirmDialog,
ChmodDialog,
OverwriteDialog,
Toasts,
TransferBar,
TitleBar,
ContextMenu,
],
templateUrl: './app.html',
host: {
'(window:keydown)': 'onWindowKeydown($event)',
'(window:contextmenu)': 'blockContextMenu($event)',
// Aero greys an inactive window's title bar and controls. The webview gets
// DOM focus/blur when the native window's focus changes, so this needs no
// RPC — see the `active` binding on .window in app.html.
'(window:focus)': 'windowFocused.set(true)',
'(window:blur)': 'windowFocused.set(false)',
},
})
export class App {
readonly i18n = inject(I18nService);
readonly rpc = inject(RpcService);
readonly toast = inject(ToastService);
readonly dialogs = inject(DialogsService);
readonly panels = inject(PanelsService);
readonly actions = inject(FileActionsService);
readonly connections = inject(ConnectionsService);
private readonly transfers = inject(TransfersService);
private readonly settings = inject(SettingsService);
private readonly updater = inject(UpdaterService);
/** The connection-manager window is open — this window is modal-blocked. */
readonly managerOpen = signal(false);
/** Drives `.window.active`: Aero desaturates an unfocused window's chrome. */
readonly windowFocused = signal(document.hasFocus());
/** Shown in the win7 title bar (no native titlebar to carry it). */
readonly appName = BRANDING.appName;
/**
* win7 title bar's X. Closing the main window quits, and the app-level
* before-quit guard (dirty editors + running transfers) still runs — it is
* the one close path electrobun lets us cancel.
*/
closeWindow(): void {
void this.rpc.request.windowClose({});
}
constructor() {
document.title = BRANDING.appName;
if (this.rpc.available) {
this.rpc.onMenuAction((action) => void this.handleMenuAction(action));
this.rpc.onManagerState((open) => this.managerOpen.set(open));
void this.promptForEditLeftovers();
}
}
focusManager(): void {
void this.rpc.request.openConnectionManager({});
}
blockContextMenu(event: Event): void {
blockNativeContextMenu(event);
}
/** Temp files from external edits in a previous run (crash / quit mid-edit). */
private async promptForEditLeftovers(): Promise<void> {
const res = await this.rpc.request.getEditLeftovers({});
if (!res.ok || !res.names.length) return;
const shown = res.names.slice(0, 5).join(', ');
const more =
res.names.length > 5 ? this.i18n.t('toasts.leftoverMore', { count: res.names.length - 5 }) : '';
this.dialogs.confirm.set({
title: this.i18n.t('toasts.leftoverTitle'),
body: this.i18n.t('toasts.leftoverBody', { count: res.names.length, names: shown + more }),
confirmLabel: this.i18n.t('toasts.leftoverClear'),
destructive: true,
onConfirm: () => void this.rpc.request.clearEditLeftovers({}),
});
}
/** A connection by id; reloads once if unseen (a manager-window save can
* race the connectionsChanged push). */
private async findConnection(id: string) {
const lookup = () => this.connections.connections().find((c) => c.id === id);
if (!lookup()) await this.connections.reload();
return lookup();
}
/** Native application-menu / manager-window actions forwarded from bun. */
private async handleMenuAction(action: string): Promise<void> {
if (action.startsWith('ctx-')) {
this.handleContextAction(action);
return;
}
if (action.startsWith('connect:')) {
const connection = await this.findConnection(action.slice('connect:'.length));
if (connection) {
// remote goes right, local stays left — the commander convention
void this.panels.connectPanel('right', connection, connection.name, connection.localDir);
}
return;
}
if (action.startsWith('col:')) {
this.settings.toggleColumn(action.slice('col:'.length) as ColumnKey);
return;
}
if (action.startsWith('open-stack:')) {
const id = action.slice('open-stack:'.length);
let stackConnections = this.connections.connections().filter((c) => c.stackId === id);
if (!stackConnections.length) {
await this.connections.reload();
stackConnections = this.connections.connections().filter((c) => c.stackId === id);
}
if (stackConnections.length) this.panels.openStack(stackConnections);
return;
}
switch (action) {
case 'new-tab':
this.panels.newWorkspace();
break;
case 'close-tab':
this.panels.closeWorkspace(this.panels.activeWorkspaceId());
break;
case 'new-folder':
this.actions.mkdir(this.panels.active());
break;
case 'new-file':
this.actions.createFile(this.panels.active());
break;
case 'edit-file':
void this.actions.edit(this.panels.active());
break;
case 'refresh-active':
void this.panels.active().refresh();
break;
case 'check-for-updates':
void this.updater.check();
break;
}
}
/** Tab (workspace) shortcuts — panel-level keys live on the panels themselves. */
onWindowKeydown(event: KeyboardEvent): void {
if (this.managerOpen()) return; // modal-blocked behind the manager
const mod = event.metaKey || event.ctrlKey;
if (mod && event.key.toLowerCase() === 't') {
event.preventDefault();
this.panels.newWorkspace();
} else if (mod && event.key.toLowerCase() === 'w') {
event.preventDefault();
this.panels.closeWorkspace(this.panels.activeWorkspaceId());
} else if (event.ctrlKey && event.key === 'Tab') {
event.preventDefault();
this.panels.cycleWorkspace(event.shiftKey ? -1 : 1);
} else if (mod && !event.shiftKey && !event.altKey) {
// ⌘1–⌘8 jump to that tab, ⌘9 to the last (Ctrl+digit elsewhere), like
// Firefox. Matched on event.code so the digit row works on any layout.
const digit = /^(?:Digit|Numpad)([1-9])$/.exec(event.code)?.[1];
if (!digit) return;
event.preventDefault();
this.panels.goToWorkspace(digit === '9' ? -1 : Number(digit) - 1);
}
}
/** Context-menu actions apply to the row/panel remembered at right-click time. */
private handleContextAction(action: string): void {
const target = this.actions.menuTarget;
if (!target) return;
const { panel, side, entry } = target;
// the empty-space menu acts on the directory itself
const path = entry?.path ?? panel.cwd();
switch (action) {
case 'ctx-open':
if (entry) panel.open(entry);
break;
// local shell integration — only bun can reach the OS shell/clipboard
case 'ctx-reveal':
void this.rpc.request.shellItemAction({ action: 'reveal', path });
break;
case 'ctx-open-default':
void this.rpc.request.shellItemAction({ action: 'open', path });
break;
case 'ctx-copy-path':
void this.rpc.request.shellItemAction({ action: 'copyPath', path });
break;
case 'ctx-edit':
void this.actions.edit(panel);
break;
case 'ctx-copy':
void this.transfers.copyToOtherPanel(side);
break;
case 'ctx-rename':
this.actions.rename(panel);
break;
case 'ctx-chmod':
this.actions.chmod(panel);
break;
case 'ctx-delete':
this.actions.delete(panel);
break;
case 'ctx-trash':
if (entry) void this.actions.trash(panel, entry);
break;
case 'ctx-new-folder':
this.actions.mkdir(panel);
break;
case 'ctx-new-file':
this.actions.createFile(panel);
break;
case 'ctx-refresh':
void panel.refresh();
break;
}
}
}
@@ -0,0 +1,385 @@
<div class="window glass w7-frame flex h-screen flex-col" [class.active]="windowFocused()">
<!-- Frameless window. Fixed-size modal, so Close only. -->
<app-title-bar
[title]="i18n.t('connMgr.title')"
[resizable]="false"
(closeRequested)="close()"
/>
<!-- Toolbar strip under the title bar. h-9/pl-3 are load-bearing: without a
height this row collapses onto the icon and the screen reads as broken. -->
<header
class="w7-toolbar flex h-9 shrink-0 items-center gap-2 border-b border-border bg-chrome pr-2 pl-3"
>
<ng-icon name="mynaServers" size="0.95rem" class="shrink-0 text-muted-foreground" />
<h1 class="text-sm font-semibold">{{ i18n.t('connMgr.title') }}</h1>
<div class="flex-1"></div>
<button
(click)="importWinScp()"
[disabled]="importing()"
[title]="i18n.t('connMgr.importWinscpHint')"
class="flex items-center gap-1.5 rounded-md px-2 py-1 text-[11px] text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground disabled:opacity-50"
>
@if (importing()) {
<ng-icon name="mynaSpinner" size="0.85rem" class="animate-spin" />
} @else {
<!-- <ng-icon name="mynaArchive" size="0.85rem" /> -->
}
{{ i18n.t('connMgr.importWinscp') }}
</button>
</header>
<div class="flex min-h-0 flex-1">
<!-- stacks + connections list -->
<div class="flex w-72 shrink-0 flex-col border-r border-border bg-chrome">
<div class="flex items-center gap-1 border-b border-border px-2 py-1.5">
<button
(click)="newConnection()"
[title]="i18n.t('connMgr.newConnectionHint')"
class="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaPlus" size="0.85rem" /> {{ i18n.t('connMgr.newConnection') }}
</button>
<button
(click)="newStack()"
[title]="i18n.t('connMgr.newStackHint')"
class="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaFolderPlus" size="0.85rem" /> {{ i18n.t('connMgr.newStack') }}
</button>
</div>
<div class="has-scrollbar flex-1 overflow-y-auto py-1">
<!-- stacks -->
@for (group of store.grouped().stacks; track group.stack.id) {
<div
(dragover)="onDragOver($event, group.stack.id)"
(dragleave)="dropTarget() === group.stack.id && dropTarget.set(null)"
(drop)="onDrop($event, group.stack.id)"
class="rounded-md"
[class.bg-primary/10]="dropTarget() === group.stack.id"
[class.ring-1]="dropTarget() === group.stack.id"
[class.ring-inset]="dropTarget() === group.stack.id"
[class.ring-primary]="dropTarget() === group.stack.id"
>
<div class="group flex items-center gap-1 px-2 pt-2 pb-0.5">
<button
(click)="toggleStack(group.stack)"
class="grid size-4 shrink-0 place-items-center rounded text-muted-foreground hover:text-foreground"
[title]="group.stack.collapsed ? i18n.t('connMgr.expand') : i18n.t('connMgr.collapse')"
>
<ng-icon
[name]="group.stack.collapsed ? 'mynaChevronRight' : 'mynaChevronDown'"
size="0.8rem"
/>
</button>
<span class="truncate text-[11px] font-medium text-muted-foreground uppercase">
{{ group.stack.name }}
</span>
<div class="min-w-0 flex-1"></div>
<!-- count and actions swap on hover: they never share the space,
so a long stack name can't collide with the buttons -->
<span
class="shrink-0 text-[10px] text-muted-foreground/70 tabular-nums group-hover:hidden"
>
{{ group.connections.length }}
</span>
<div class="hidden shrink-0 items-center gap-0.5 group-hover:flex">
<button
(click)="renameStack(group.stack)"
[title]="i18n.t('connMgr.renameStack')"
class="grid size-4 place-items-center rounded text-muted-foreground hover:text-foreground"
>
<ng-icon name="mynaEdit" size="0.75rem" />
</button>
<button
(click)="deleteStack(group.stack)"
[title]="i18n.t('connMgr.deleteStack')"
class="grid size-4 place-items-center rounded text-muted-foreground hover:text-destructive"
>
<ng-icon name="mynaTrash" size="0.75rem" />
</button>
@if (group.connections.length) {
<button
(click)="openStack(group.stack.id)"
[title]="i18n.t('connMgr.openStack')"
class="grid size-4 place-items-center rounded text-muted-foreground hover:text-primary"
>
<ng-icon name="mynaArrowRight" size="0.8rem" />
</button>
}
</div>
</div>
@if (!group.stack.collapsed) {
@for (connection of group.connections; track connection.id) {
<button
(click)="edit(connection)"
draggable="true"
(dragstart)="onDragStart($event, connection)"
(dragend)="onDragEnd()"
class="flex w-full items-center gap-1.5 py-1.5 pr-3 pl-6 text-left text-xs hover:bg-accent"
[class.bg-accent]="draft()?.id === connection.id"
[class.opacity-50]="draggedId() === connection.id"
>
<ng-icon name="mynaServers" size="0.85rem" class="shrink-0 text-muted-foreground" />
<span class="truncate">{{ connection.name }}</span>
</button>
} @empty {
<div class="py-1 pl-6 text-[11px] text-muted-foreground italic">{{ i18n.t('connMgr.empty') }}</div>
}
}
</div>
}
<!-- loose connections -->
<div
(dragover)="onDragOver($event, 'loose')"
(dragleave)="dropTarget() === 'loose' && dropTarget.set(null)"
(drop)="onDrop($event, null)"
class="min-h-8 rounded-md pb-1"
[class.bg-primary/10]="dropTarget() === 'loose'"
[class.ring-1]="dropTarget() === 'loose'"
[class.ring-inset]="dropTarget() === 'loose'"
[class.ring-primary]="dropTarget() === 'loose'"
>
@if (store.grouped().stacks.length && store.grouped().loose.length) {
<div class="px-2 pt-2 pb-0.5 text-[11px] font-medium text-muted-foreground uppercase">
{{ i18n.t('connMgr.connectionsHeader') }}
</div>
}
@for (connection of store.grouped().loose; track connection.id) {
<button
(click)="edit(connection)"
draggable="true"
(dragstart)="onDragStart($event, connection)"
(dragend)="onDragEnd()"
class="flex w-full items-center gap-1.5 px-3 py-1.5 text-left text-xs hover:bg-accent"
[class.bg-accent]="draft()?.id === connection.id"
[class.opacity-50]="draggedId() === connection.id"
>
<ng-icon name="mynaServers" size="0.85rem" class="shrink-0 text-muted-foreground" />
<span class="truncate">{{ connection.name }}</span>
</button>
}
</div>
@if (!store.connections().length && !store.stacks().length) {
<p class="px-3 py-2 text-[11px] text-muted-foreground">
{{ i18n.t('connMgr.noneTitle') }}
</p>
<p class="px-3 py-2 text-[11px] text-muted-foreground">
{{ i18n.t('connMgr.noneBody') }}
</p>
}
</div>
</div>
<!-- edit form -->
<div class="flex min-w-0 flex-1 flex-col">
@if (draft(); as d) {
<div class="has-scrollbar grid flex-1 auto-rows-min grid-cols-2 gap-x-4 gap-y-3 overflow-y-auto p-5">
<label class="col-span-2 flex flex-col gap-1 text-xs">
<span class="text-muted-foreground">{{ i18n.t('connMgr.name') }}</span>
<input
[value]="d.name"
(input)="patch('name', $any($event.target).value)"
[placeholder]="i18n.t('connMgr.namePlaceholder')"
spellcheck="false"
class="h-7 rounded-md border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring"
/>
</label>
<label class="flex flex-col gap-1 text-xs">
<span class="text-muted-foreground">{{ i18n.t('connMgr.host') }}</span>
<input
[value]="d.host"
(input)="patch('host', $any($event.target).value)"
placeholder="192.168.1.10"
spellcheck="false"
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
/>
</label>
<div class="grid grid-cols-2 gap-3">
<label class="flex flex-col gap-1 text-xs">
<span class="text-muted-foreground">{{ i18n.t('connMgr.port') }}</span>
<input
type="number"
[value]="d.port"
(input)="patch('port', +$any($event.target).value)"
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
/>
</label>
<label class="flex flex-col gap-1 text-xs">
<span class="text-muted-foreground">{{ i18n.t('connMgr.stack') }}</span>
<select
(change)="patch('stackId', $any($event.target).value || null)"
class="h-7 rounded-md border border-input bg-background px-1 outline-none focus:ring-2 focus:ring-ring"
>
<!-- [selected] per option: binding [value] on the <select> runs
before the options render and silently shows "(none)" -->
<option value="" [selected]="!d.stackId">{{ i18n.t('connMgr.stackNone') }}</option>
@for (stack of store.stacks(); track stack.id) {
<option [value]="stack.id" [selected]="d.stackId === stack.id">
{{ stack.name }}
</option>
}
</select>
</label>
</div>
<label class="flex flex-col gap-1 text-xs">
<span class="text-muted-foreground">{{ i18n.t('connMgr.username') }}</span>
<input
[value]="d.username"
(input)="patch('username', $any($event.target).value)"
spellcheck="false"
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
/>
</label>
<label class="flex flex-col gap-1 text-xs">
<span class="text-muted-foreground">{{ i18n.t('connMgr.remotePath') }}</span>
<input
[value]="d.initialPath"
(input)="patch('initialPath', $any($event.target).value)"
[placeholder]="i18n.t('connMgr.remotePathPlaceholder')"
spellcheck="false"
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
/>
</label>
<div class="col-span-2 flex items-center gap-4 text-xs">
<span class="text-muted-foreground">{{ i18n.t('connMgr.auth') }}</span>
<label class="flex items-center gap-1.5">
<input
type="radio"
name="authKind"
[checked]="d.authKind === 'password'"
(change)="patch('authKind', 'password')"
class="accent-primary"
/>
{{ i18n.t('connMgr.authPassword') }}
</label>
<label class="flex items-center gap-1.5">
<input
type="radio"
name="authKind"
[checked]="d.authKind === 'key'"
(change)="patch('authKind', 'key')"
class="accent-primary"
/>
{{ i18n.t('connMgr.authKey') }}
</label>
</div>
@if (d.authKind === 'password') {
<label class="col-span-2 flex flex-col gap-1 text-xs">
<span class="text-muted-foreground">{{ i18n.t('connMgr.password') }}</span>
<input
type="password"
[value]="d.password"
(input)="patch('password', $any($event.target).value)"
class="h-7 rounded-md border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring"
/>
</label>
} @else {
<label class="flex flex-col gap-1 text-xs">
<span class="text-muted-foreground">{{ i18n.t('connMgr.privateKeyFile') }}</span>
<div class="flex items-center gap-1">
<input
[value]="d.privateKeyPath"
(input)="patch('privateKeyPath', $any($event.target).value)"
placeholder="~/.ssh/id_ed25519"
spellcheck="false"
class="h-7 min-w-0 flex-1 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
/>
<button
type="button"
(click)="pickKeyFile()"
class="h-7 shrink-0 rounded-md border border-border px-2 text-[11px] hover:bg-accent"
>
{{ i18n.t('common.choose') }}
</button>
</div>
</label>
<label class="flex flex-col gap-1 text-xs">
<span class="text-muted-foreground">{{ i18n.t('connMgr.passphrase') }}</span>
<input
type="password"
[value]="d.passphrase"
(input)="patch('passphrase', $any($event.target).value)"
class="h-7 rounded-md border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring"
/>
</label>
}
@if (moreOpen()) {
<label class="col-span-2 flex flex-col gap-1 text-xs">
<span class="text-muted-foreground">{{ i18n.t('connMgr.localDir') }}</span>
<input
[value]="d.localDir"
(input)="patch('localDir', $any($event.target).value)"
[placeholder]="i18n.t('connMgr.localDirPlaceholder')"
spellcheck="false"
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
/>
<span class="text-[10px] text-muted-foreground/80">
{{ i18n.t('connMgr.localDirHint') }}
</span>
</label>
}
</div>
<div class="flex items-center gap-2 border-t border-border bg-chrome px-4 py-2.5">
<button
(click)="deleteCurrent()"
class="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-destructive"
>
<ng-icon name="mynaTrash" size="0.85rem" /> {{ i18n.t('common.delete') }}
</button>
<button
(click)="moreOpen.set(!moreOpen())"
class="rounded-md border border-border px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
>
{{ moreOpen() ? i18n.t('connMgr.less') : i18n.t('connMgr.more') }}
</button>
<div class="flex-1"></div>
<button
(click)="save()"
[disabled]="!valid()"
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent disabled:opacity-50"
>
{{ i18n.t('common.save') }}
</button>
<button
(click)="saveAndConnect()"
[disabled]="!valid() || busy()"
class="flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
@if (busy()) {
<ng-icon name="mynaSpinner" size="0.85rem" class="animate-spin" />
}
{{ i18n.t('common.connect') }}
</button>
</div>
} @else {
<div class="grid flex-1 place-items-center">
<div class="text-center">
<ng-icon name="mynaServers" size="1.75rem" class="text-muted-foreground" />
<p class="mt-2 text-xs text-muted-foreground">
{{ i18n.t('connMgr.selectPrompt') }}
</p>
</div>
</div>
}
</div>
</div>
<!-- in-window dialogs (safe here: they depend only on FocusService) -->
@if (dialogs.prompt(); as req) {
<app-prompt-dialog [request]="req" />
}
@if (dialogs.confirm(); as req) {
<app-confirm-dialog [request]="req" />
}
<app-toasts />
</div>
@@ -0,0 +1,277 @@
import { Component, computed, inject, signal } from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import { I18nService } from '@core/i18n.service';
import type { SavedConnection, Stack } from '@shared/types';
import { ConnectionsService } from '@core/connections.service';
import { ConfirmDialog } from '../dialogs/confirm-dialog';
import { PromptDialog } from '../dialogs/prompt-dialog';
import { DialogsService } from '@core/dialogs.service';
import { blockNativeContextMenu } from '@core/platform';
import { RpcService } from '@core/rpc.service';
import { SettingsService } from '@core/settings.service';
import { TitleBar } from '../title-bar/title-bar';
import { Toasts } from '../toasts/toasts';
interface Draft {
id: string;
name: string;
stackId: string | null;
host: string;
port: number;
username: string;
authKind: 'password' | 'key';
password: string;
privateKeyPath: string;
passphrase: string;
initialPath: string;
localDir: string;
}
function toDraft(c: SavedConnection): Draft {
return {
id: c.id,
name: c.name,
stackId: c.stackId,
host: c.host,
port: c.port,
username: c.username,
// agent auth only exists on ~/.ssh/config hosts, which never reach this form
authKind: c.auth.kind === 'password' ? 'password' : 'key',
password: c.auth.kind === 'password' ? c.auth.password : '',
privateKeyPath: c.auth.kind === 'key' ? c.auth.privateKeyPath : '',
passphrase: c.auth.kind === 'key' ? (c.auth.passphrase ?? '') : '',
initialPath: c.initialPath ?? '',
localDir: c.localDir ?? '',
};
}
function fromDraft(d: Draft): SavedConnection {
return {
id: d.id,
name: d.name.trim() || `${d.username}@${d.host}`,
stackId: d.stackId,
host: d.host.trim(),
port: d.port || 22,
username: d.username.trim(),
auth:
d.authKind === 'password'
? { kind: 'password', password: d.password }
: {
kind: 'key',
privateKeyPath: d.privateKeyPath.trim(),
...(d.passphrase ? { passphrase: d.passphrase } : {}),
},
initialPath: d.initialPath.trim() || null,
localDir: d.localDir.trim() || null,
};
}
/**
* Root component of the connection-manager window (__APP_MODE === 'manager').
* Modal to the main window (enforced bun-side); connect/open-stack requests
* are executed by the MAIN window — this window never touches panels.
*/
@Component({
selector: 'app-root',
imports: [NgIcon, ConfirmDialog, PromptDialog, Toasts, TitleBar],
templateUrl: './connection-manager.html',
host: {
'(window:keydown.escape)': 'onEscape()',
'(window:contextmenu)': 'blockContextMenu($event)',
// Aero desaturates an unfocused window's chrome — see .window.active.
'(window:focus)': 'windowFocused.set(true)',
'(window:blur)': 'windowFocused.set(false)',
},
})
export class ConnectionManager {
readonly i18n = inject(I18nService);
/** Drives `.window.active` on this window's Aero frame. */
readonly windowFocused = signal(document.hasFocus());
readonly store = inject(ConnectionsService);
readonly dialogs = inject(DialogsService);
private readonly rpc = inject(RpcService);
// injected for its side effects: fetches settings and applies theme/accent/
// text-size data attributes to this window's document root
private readonly settings = inject(SettingsService);
readonly draft = signal<Draft | null>(null);
readonly busy = signal(false);
readonly importing = signal(false);
/** The More… section (local directory) in the edit form. */
readonly moreOpen = signal(false);
/** Connection id being dragged between stacks. */
readonly draggedId = signal<string | null>(null);
/** Highlighted drop target: a stack id, 'loose', or null. */
readonly dropTarget = signal<string | null>(null);
readonly valid = computed(() => {
const d = this.draft();
if (!d) return false;
if (!d.host.trim() || !d.username.trim()) return false;
if (d.authKind === 'key' && !d.privateKeyPath.trim()) return false;
return true;
});
patch<K extends keyof Draft>(key: K, value: Draft[K]): void {
this.draft.update((d) => (d ? { ...d, [key]: value } : d));
}
newConnection(): void {
this.moreOpen.set(false);
this.draft.set({
id: '',
name: '',
stackId: null,
host: '',
port: 22,
username: 'root',
authKind: 'password',
password: '',
privateKeyPath: '',
passphrase: '',
initialPath: '',
localDir: '',
});
}
edit(connection: SavedConnection): void {
this.draft.set(toDraft(connection));
this.moreOpen.set(!!connection.localDir);
}
close(): void {
void this.rpc.request.managerClose({});
}
onEscape(): void {
// in-window dialogs (confirm/prompt) handle their own Escape first
if (!this.dialogs.anyOpen) this.close();
}
blockContextMenu(event: Event): void {
blockNativeContextMenu(event);
}
async save(): Promise<string | null> {
const d = this.draft();
if (!d || !this.valid()) return null;
const id = await this.store.upsert(fromDraft(d));
if (id) this.patch('id', id);
return id;
}
async saveAndConnect(): Promise<void> {
if (!this.draft()) return;
this.busy.set(true);
try {
const id = await this.save();
if (!id) return;
// the main window connects (and this window closes); a failed connect
// surfaces as an error there
await this.rpc.request.managerConnect({ id });
} finally {
this.busy.set(false);
}
}
deleteCurrent(): void {
const d = this.draft();
if (!d?.id) {
this.draft.set(null);
return;
}
this.dialogs.confirm.set({
title: this.i18n.t('connMgr.deleteConnectionTitle'),
body: this.i18n.t('connMgr.deleteConnectionBody', { name: d.name || d.host }),
confirmLabel: this.i18n.t('common.delete'),
destructive: true,
onConfirm: async () => {
await this.store.remove(d.id);
this.draft.set(null);
},
});
}
async pickKeyFile(): Promise<void> {
const res = await this.rpc.request.pickKeyFile({});
if (res.ok && res.path) this.patch('privateKeyPath', res.path);
}
async importWinScp(): Promise<void> {
this.importing.set(true);
try {
await this.store.importWinScp();
} finally {
this.importing.set(false);
}
}
// ---- stacks ----
openStack(stackId: string): void {
// opened as tabs in the main window; bun closes this window after
void this.rpc.request.managerOpenStack({ id: stackId });
}
toggleStack(stack: Stack): void {
void this.store.upsertStack({ ...stack, collapsed: !stack.collapsed });
}
newStack(): void {
this.dialogs.prompt.set({
title: this.i18n.t('connMgr.newStackTitle'),
placeholder: this.i18n.t('connMgr.newStackPrompt'),
confirmLabel: this.i18n.t('fileActions.createConfirm'),
onConfirm: (name) => void this.store.upsertStack({ id: '', name }),
});
}
renameStack(stack: Stack): void {
this.dialogs.prompt.set({
title: this.i18n.t('connMgr.renameStackTitle'),
initial: stack.name,
confirmLabel: this.i18n.t('connMgr.renameStack'),
onConfirm: (name) => void this.store.upsertStack({ ...stack, name }),
});
}
deleteStack(stack: Stack): void {
this.dialogs.confirm.set({
title: this.i18n.t('connMgr.deleteStackTitle'),
body: this.i18n.t('connMgr.deleteStackBody', { name: stack.name }),
confirmLabel: this.i18n.t('common.delete'),
destructive: true,
onConfirm: () => void this.store.removeStack(stack.id),
});
}
// ---- drag & drop (move a connection between stacks / loose) ----
onDragStart(event: DragEvent, connection: SavedConnection): void {
this.draggedId.set(connection.id);
event.dataTransfer?.setData('text/plain', connection.id);
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
}
onDragOver(event: DragEvent, target: string): void {
if (!this.draggedId()) return;
event.preventDefault();
this.dropTarget.set(target);
}
onDrop(event: DragEvent, stackId: string | null): void {
event.preventDefault();
const id = this.draggedId();
this.draggedId.set(null);
this.dropTarget.set(null);
const connection = this.store.connections().find((c) => c.id === id);
if (!connection || connection.stackId === stackId) return;
void this.store.upsert({ ...connection, stackId });
}
onDragEnd(): void {
this.draggedId.set(null);
this.dropTarget.set(null);
}
}
+185
View File
@@ -0,0 +1,185 @@
import { Component, ElementRef, computed, effect, inject, signal, viewChild } from '@angular/core';
import { ContextMenuService, type MenuItem } from '@core/context-menu.service';
import { FocusService } from '@core/focus.service';
/** Rough per-item metrics, used only to decide whether the menu must flip. */
const ITEM_H = 24; // 7.css: 4px padding x2 + ~15px line
const DIVIDER_H = 7; // .has-divider:after: 2px bevel + margins
const MENU_W = 200;
const EDGE_GAP = 6;
/** One rendered <li>: separators collapse onto the item BEFORE them. */
interface MenuRow {
item: MenuItem;
hasDivider: boolean;
}
/**
* The panel right-click menu. Replaces electrobun's native ContextMenu, which
* is a no-op on Linux — see ContextMenuService for the why. Emits the same
* `ctx-*` / `col:*` action strings the native menu did.
*
* The markup is 7.css's menu contract EXACTLY — `ul[role=menu] >
* li[role=menuitem] > button` for actions, `> input[type=checkbox] + label`
* for the checkable column toggles, and dividers as `.has-divider` on the
* PRECEDING item (7.css draws them as an ::after bevel, not as a node). Get
* any of that wrong and 7.css silently styles nothing: an earlier version
* rendered div > button, concluded "7.css has no menu component", and
* hand-built a lookalike. It does have one; the selectors just never matched.
*
* Keyboard follows the platform: arrows move REAL focus between items (7.css
* styles :focus-visible identically to :hover), Enter/Space on a button
* activate it natively; only labels need a synthetic click.
*/
@Component({
selector: 'app-context-menu',
template: `
@if (menu.request(); as req) {
<!-- click-outside catcher; mousedown so it beats the panel's own handlers -->
<div class="fixed inset-0 z-50" (mousedown)="close()" (contextmenu)="onBackdropContext($event)"></div>
<ul
#menuEl
role="menu"
tabindex="-1"
(keydown)="onKeydown($event)"
[style.left.px]="position().x"
[style.top.px]="position().y"
[style.width.px]="menuWidth"
class="fixed z-50 outline-none"
>
@for (row of rows(); track $index) {
<li role="menuitem" [class.has-divider]="row.hasDivider" [title]="row.item.tooltip ?? ''">
@if (row.item.checked !== undefined) {
<input type="checkbox" [id]="'ctx-check-' + $index" [checked]="row.item.checked" tabindex="-1" />
<label [for]="'ctx-check-' + $index" tabindex="-1" (click)="pick(row.item, $event)">
{{ row.item.label }}
</label>
} @else {
<button type="button" (click)="pick(row.item)">
{{ row.item.label }}
@if (row.item.accelerator) {
<span>{{ row.item.accelerator }}</span>
}
</button>
}
</li>
}
</ul>
}
`,
// Closing on scroll/blur matches native menu behavior: the anchor row moves
// out from under a menu that would otherwise float over stale content.
host: {
'(window:blur)': 'close()',
'(window:resize)': 'close()',
'(document:scroll)': 'close()',
},
})
export class ContextMenu {
readonly menu = inject(ContextMenuService);
private readonly focus = inject(FocusService);
private readonly menuEl = viewChild<ElementRef<HTMLUListElement>>('menuEl');
protected readonly menuWidth = MENU_W;
/** Model items → rendered rows: a separator becomes has-divider on its predecessor. */
readonly rows = computed<MenuRow[]>(() => {
const items = this.menu.request()?.items ?? [];
const rows: MenuRow[] = [];
for (const item of items) {
if (item.type === 'separator') {
if (rows.length) rows[rows.length - 1].hasDivider = true;
} else {
rows.push({ item, hasDivider: false });
}
}
return rows;
});
/** Click point, flipped/clamped so the menu never overflows the viewport. */
readonly position = computed(() => {
const req = this.menu.request();
if (!req) return { x: 0, y: 0 };
const rows = this.rows();
const height =
rows.length * ITEM_H + rows.filter((r) => r.hasDivider).length * DIVIDER_H + 6;
const x =
req.x + MENU_W > window.innerWidth - EDGE_GAP ? Math.max(EDGE_GAP, req.x - MENU_W) : req.x;
const y =
req.y + height > window.innerHeight - EDGE_GAP ? Math.max(EDGE_GAP, req.y - height) : req.y;
return { x, y };
});
constructor() {
// Focus the menu once it exists so arrow keys / Escape reach it. Keyed on
// the viewChild signal, NOT on request(): under zoneless CD an effect can
// run before the @if has rendered the element.
effect(() => this.menuEl()?.nativeElement.focus());
}
close(): void {
if (!this.menu.request()) return;
this.menu.close();
this.focus.bump();
}
pick(item: MenuItem, event?: Event): void {
// labels: don't let the click also toggle the real checkbox — the fresh
// `checked` state comes back through settings when the menu reopens
event?.preventDefault();
if (!item.action) return;
const action = item.action;
this.close();
this.menu.onPick(action);
}
/** Right-clicking the backdrop closes rather than stacking a second menu. */
onBackdropContext(event: MouseEvent): void {
event.preventDefault();
this.close();
}
onKeydown(event: KeyboardEvent): void {
switch (event.key) {
case 'Escape':
event.preventDefault();
event.stopPropagation();
this.close();
break;
case 'ArrowDown':
event.preventDefault();
this.step(1);
break;
case 'ArrowUp':
event.preventDefault();
this.step(-1);
break;
case 'Enter':
case ' ': {
// buttons activate natively from their own focus; labels don't, so
// synthesize the click (which routes through pick via the template)
const active = document.activeElement;
if (active instanceof HTMLLabelElement && this.menuEl()?.nativeElement.contains(active)) {
event.preventDefault();
active.click();
}
break;
}
}
}
/**
* Move REAL focus by one item, wrapping. 7.css styles :focus-visible the
* same as :hover, and keyboard-initiated programmatic focus counts as
* focus-visible, so the highlight comes from the framework for free.
*/
private step(delta: number): void {
const root = this.menuEl()?.nativeElement;
if (!root) return;
const targets = Array.from(root.querySelectorAll<HTMLElement>('li > button, li > label'));
if (!targets.length) return;
const current = targets.indexOf(document.activeElement as HTMLElement);
const next = current < 0 && delta < 0 ? targets.length - 1 : (current + delta + targets.length) % targets.length;
targets[next].focus();
}
}
@@ -0,0 +1,129 @@
import { Component, computed, inject, input, signal } from '@angular/core';
import { I18nService } from '@core/i18n.service';
import { DialogsService, type ChmodRequest } from '@core/dialogs.service';
import { FocusService } from '@core/focus.service';
const ROLES = ['roleOwner', 'roleGroup', 'roleOthers'] as const;
const PERMS = ['r', 'w', 'x'] as const;
@Component({
selector: 'app-chmod-dialog',
template: `
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancel()">
<div
class="w-80 rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl"
(mousedown)="$event.stopPropagation()"
(keydown.escape)="cancel()"
>
<h2 class="text-sm font-semibold">{{ i18n.t('dialogs.chmodTitle') }}</h2>
<p class="mt-1 truncate text-xs text-muted-foreground">{{ targetLabel() }}</p>
<table class="mt-3 w-full text-xs">
<thead>
<tr class="text-muted-foreground">
<th class="pb-1 text-left font-normal"></th>
<th class="pb-1 font-normal">{{ i18n.t('dialogs.read') }}</th>
<th class="pb-1 font-normal">{{ i18n.t('dialogs.write') }}</th>
<th class="pb-1 font-normal">{{ i18n.t('dialogs.execute') }}</th>
</tr>
</thead>
<tbody>
@for (role of roles; track role; let r = $index) {
<tr>
<td class="py-1 pr-2 text-muted-foreground">{{ i18n.t('dialogs.' + role) }}</td>
@for (perm of perms; track perm; let p = $index) {
<td class="py-1 text-center">
<input
type="checkbox"
[checked]="hasBit(r, p)"
(change)="toggleBit(r, p)"
class="size-3.5 accent-primary"
/>
</td>
}
</tr>
}
</tbody>
</table>
<div class="mt-3 flex items-center gap-2">
<label class="text-xs text-muted-foreground">{{ i18n.t('dialogs.octal') }}</label>
<input
[value]="octal()"
(input)="setOctal($any($event.target).value)"
maxlength="4"
spellcheck="false"
class="h-7 w-20 rounded-md border border-input bg-transparent px-2 font-mono text-xs outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div class="mt-4 flex justify-end gap-2">
<button
(click)="cancel()"
class="w7-btn rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
>
{{ i18n.t('common.cancel') }}
</button>
<button
(click)="submit()"
class="w7-btn rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
>
{{ i18n.t('common.apply') }}
</button>
</div>
</div>
</div>
`,
})
export class ChmodDialog {
readonly i18n = inject(I18nService);
readonly request = input.required<ChmodRequest>();
readonly roles = ROLES;
readonly perms = PERMS;
private readonly dialogs = inject(DialogsService);
private readonly focus = inject(FocusService);
// mode is initialized lazily from the request on first read
private readonly modeOverride = signal<number | null>(null);
readonly mode = computed(() => this.modeOverride() ?? this.request().initialMode & 0o777);
readonly octal = computed(() => this.mode().toString(8).padStart(3, '0'));
targetLabel(): string {
const targets = this.request().targets;
return targets.length === 1
? targets[0]
: this.i18n.t('dialogs.chmodItems', { count: targets.length });
}
hasBit(role: number, perm: number): boolean {
return !!(this.mode() & (0o400 >> (role * 3 + perm)));
}
toggleBit(role: number, perm: number): void {
this.modeOverride.set(this.mode() ^ (0o400 >> (role * 3 + perm)));
}
setOctal(text: string): void {
if (/^[0-7]{1,4}$/.test(text)) {
this.modeOverride.set(parseInt(text, 8) & 0o777);
}
}
submit(): void {
const req = this.request();
const mode = this.mode();
this.close();
req.onConfirm(mode);
}
cancel(): void {
this.close();
}
private close(): void {
this.dialogs.chmod.set(null);
this.focus.bump();
}
}
@@ -0,0 +1,66 @@
import { Component, ElementRef, afterNextRender, inject, input, viewChild } from '@angular/core';
import { I18nService } from '@core/i18n.service';
import { DialogsService, type ConfirmRequest } from '@core/dialogs.service';
import { FocusService } from '@core/focus.service';
@Component({
selector: 'app-confirm-dialog',
template: `
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancel()">
<div
class="w-96 rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl"
(mousedown)="$event.stopPropagation()"
(keydown.escape)="cancel()"
>
<h2 class="text-sm font-semibold">{{ request().title }}</h2>
<p class="mt-2 text-xs text-muted-foreground">{{ request().body }}</p>
<div class="mt-4 flex justify-end gap-2">
<button
(click)="cancel()"
class="w7-btn rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
>
{{ i18n.t('common.cancel') }}
</button>
<button
#confirmBtn
(click)="submit()"
[class]="
request().destructive
? 'w7-btn rounded-md bg-destructive px-3 py-1.5 text-xs font-medium text-destructive-foreground hover:bg-destructive/90'
: 'w7-btn rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90'
"
>
{{ request().confirmLabel }}
</button>
</div>
</div>
</div>
`,
})
export class ConfirmDialog {
readonly i18n = inject(I18nService);
readonly request = input.required<ConfirmRequest>();
private readonly confirmBtn = viewChild.required<ElementRef<HTMLButtonElement>>('confirmBtn');
private readonly dialogs = inject(DialogsService);
private readonly focus = inject(FocusService);
constructor() {
afterNextRender(() => this.confirmBtn().nativeElement.focus());
}
submit(): void {
const req = this.request();
this.close();
req.onConfirm();
}
cancel(): void {
this.close();
}
private close(): void {
this.dialogs.confirm.set(null);
this.focus.bump();
}
}
@@ -0,0 +1,149 @@
import {
Component,
ElementRef,
afterNextRender,
computed,
inject,
input,
viewChild,
} from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import { I18nService } from '@core/i18n.service';
import { DialogsService, type OverwriteChoice, type OverwriteRequest } from '@core/dialogs.service';
import { FocusService } from '@core/focus.service';
@Component({
selector: 'app-overwrite-dialog',
imports: [NgIcon],
template: `
<div
class="fixed inset-0 z-40 grid place-items-center bg-black/40"
(keydown.escape)="pick('cancel')"
>
<div
class="w-[26rem] rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl"
>
<div class="flex items-start justify-between gap-2">
<h2 class="min-w-0 text-sm font-semibold">
{{ isDir() ? i18n.t('dialogs.mergeFolder') : i18n.t('dialogs.replace') }}
<span class="font-mono">"{{ request().srcEntry.name }}"</span>?
</h2>
@if (request().total > 1) {
<span
class="shrink-0 rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground"
>
{{ i18n.t('dialogs.ofTotal', { index: request().index, total: request().total }) }}
</span>
}
</div>
<p class="mt-1 text-xs text-muted-foreground">
@if (isDir()) {
{{ i18n.t('dialogs.overwriteDirBody') }}
} @else {
{{ i18n.t('dialogs.overwriteFileBody') }}
}
</p>
<!-- comparison -->
<div class="mt-3 grid grid-cols-2 gap-2">
@for (side of comparison(); track side.label) {
<div class="rounded-md border border-border p-2.5">
<div class="flex items-center justify-between">
<span class="text-[10px] font-medium tracking-wide text-muted-foreground uppercase">
{{ i18n.t('dialogs.' + side.label) }}
</span>
@if (side.newer) {
<span class="rounded-full bg-success/15 px-1.5 text-[10px] font-medium text-success">
{{ i18n.t('dialogs.newer') }}
</span>
}
</div>
<div class="mt-1.5 flex items-center gap-1.5 text-xs">
<ng-icon
[name]="isDir() ? 'mynaFolder' : 'mynaFile'"
size="0.9rem"
class="shrink-0 text-muted-foreground"
/>
<span class="font-mono tabular-nums">
{{ isDir() ? '—' : i18n.size(side.entry.size) }}
</span>
</div>
<div class="mt-1 font-mono text-[11px] tabular-nums text-muted-foreground">
{{ i18n.date(side.entry.mtime) }}
</div>
</div>
}
</div>
<!-- actions -->
<div class="mt-4 flex items-center gap-2">
<button
(click)="pick('cancel')"
class="w7-btn rounded-md px-3 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground"
>
{{ i18n.t('common.cancel') }}
</button>
<div class="flex-1"></div>
@if (request().total > 1) {
<button
(click)="pick('skipAll')"
class="rounded-md px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
>
{{ i18n.t('dialogs.skipAll') }}
</button>
<button
(click)="pick('overwriteAll')"
class="rounded-md px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
>
{{ isDir() ? i18n.t('dialogs.mergeAll') : i18n.t('dialogs.replaceAll') }}
</button>
<div class="h-4 w-px bg-border"></div>
}
<button
(click)="pick('skip')"
class="w7-btn rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
>
{{ i18n.t('dialogs.skip') }}
</button>
<button
#primaryBtn
(click)="pick('overwrite')"
class="w7-btn rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
>
{{ isDir() ? i18n.t('dialogs.merge') : i18n.t('dialogs.replace') }}
</button>
</div>
</div>
</div>
`,
})
export class OverwriteDialog {
readonly i18n = inject(I18nService);
readonly request = input.required<OverwriteRequest>();
private readonly dialogs = inject(DialogsService);
private readonly focus = inject(FocusService);
private readonly primaryBtn = viewChild.required<ElementRef<HTMLButtonElement>>('primaryBtn');
constructor() {
afterNextRender(() => this.primaryBtn().nativeElement.focus());
}
readonly isDir = computed(() => this.request().srcEntry.type === 'dir');
readonly comparison = computed(() => {
const { srcEntry, dstEntry } = this.request();
return [
{ label: 'source', entry: srcEntry, newer: srcEntry.mtime > dstEntry.mtime },
{ label: 'destination', entry: dstEntry, newer: dstEntry.mtime > srcEntry.mtime },
];
});
pick(choice: OverwriteChoice): void {
const req = this.request();
this.dialogs.overwrite.set(null);
this.focus.bump();
req.resolve(choice);
}
}
@@ -0,0 +1,86 @@
import {
Component,
ElementRef,
afterNextRender,
inject,
input,
signal,
viewChild,
} from '@angular/core';
import { I18nService } from '@core/i18n.service';
import { DialogsService, type PromptRequest } from '@core/dialogs.service';
import { FocusService } from '@core/focus.service';
@Component({
selector: 'app-prompt-dialog',
template: `
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancel()">
<div
class="w-96 rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl"
(mousedown)="$event.stopPropagation()"
>
<h2 class="text-sm font-semibold">{{ request().title }}</h2>
<input
#box
[value]="value()"
(input)="value.set($any($event.target).value)"
(keydown.enter)="submit()"
(keydown.escape)="cancel()"
[placeholder]="request().placeholder ?? ''"
spellcheck="false"
class="mt-3 h-8 w-full rounded-md border border-input bg-transparent px-2 text-sm outline-none focus:ring-2 focus:ring-ring"
/>
<div class="mt-4 flex justify-end gap-2">
<button
(click)="cancel()"
class="w7-btn rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
>
{{ i18n.t('common.cancel') }}
</button>
<button
(click)="submit()"
[disabled]="!value().trim()"
class="w7-btn rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{{ request().confirmLabel }}
</button>
</div>
</div>
</div>
`,
})
export class PromptDialog {
readonly i18n = inject(I18nService);
readonly request = input.required<PromptRequest>();
readonly value = signal('');
private readonly box = viewChild.required<ElementRef<HTMLInputElement>>('box');
private readonly dialogs = inject(DialogsService);
private readonly focus = inject(FocusService);
constructor() {
afterNextRender(() => {
this.value.set(this.request().initial ?? '');
const el = this.box().nativeElement;
el.focus();
el.select();
});
}
submit(): void {
const value = this.value().trim();
if (!value) return;
const req = this.request();
this.close();
req.onConfirm(value);
}
cancel(): void {
this.close();
}
private close(): void {
this.dialogs.prompt.set(null);
this.focus.bump();
}
}
@@ -0,0 +1,207 @@
<div class="window glass w7-frame flex h-screen flex-col" [class.active]="windowFocused()">
@if (!ctx) {
<div class="flex flex-1 items-center justify-center">
<div class="max-w-sm rounded-lg border border-border bg-card p-6 text-center shadow-sm">
<ng-icon name="mynaXCircle" size="1.5rem" class="text-destructive" />
<p class="mt-2 text-sm font-medium">{{ i18n.t('editor.noFileTitle') }}</p>
<p class="mt-1 text-xs text-muted-foreground">
{{ i18n.t('editor.noFileBody') }}
</p>
</div>
</div>
} @else {
<!-- The window is frameless. The X runs the SAME dirty prompt as ⌘W and
the Close button — never a raw window close. -->
<app-title-bar [title]="ctx.name + (dirty() ? ' *' : '')" (closeRequested)="requestClose()" />
<!-- Toolbar strip under the title bar. h-9/pl-3 are load-bearing: without a
height this row collapses onto the icon and the screen reads as broken. -->
<header
class="w7-toolbar flex h-9 shrink-0 items-center gap-2 border-b border-border bg-chrome pr-2 pl-3"
>
<ng-icon name="mynaFileText" size="0.95rem" class="shrink-0 text-muted-foreground" />
<h1 class="min-w-0 truncate font-mono text-sm font-semibold">
{{ ctx.name }}{{ dirty() ? ' •' : '' }}
</h1>
<span class="shrink-0 text-[11px] text-muted-foreground">{{ ctx.sessionTitle }}</span>
<div class="flex-1"></div>
<button
(click)="toggleWrap()"
[title]="i18n.t('editor.wrapHint', { alt: altLabel })"
class="rounded-md px-2 py-1 text-[11px] electrobun-webkit-app-region-no-drag hover:bg-accent"
[class.bg-accent]="wrap()"
[class.text-foreground]="wrap()"
[class.text-muted-foreground]="!wrap()"
>
{{ i18n.t('editor.wrap') }}
</button>
<button
(click)="openFind()"
[title]="i18n.t('editor.findHint', { mod: modLabel })"
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="mynaSearch" size="0.85rem" />
</button>
</header>
@if (findOpen()) {
<div class="flex shrink-0 items-center gap-2 border-b border-border bg-chrome px-3 py-1.5">
<ng-icon name="mynaSearch" size="0.85rem" class="shrink-0 text-muted-foreground" />
<input
#findBox
[value]="findQuery()"
(input)="onFindInput($any($event.target).value)"
(keydown)="onFindKeydown($event)"
[placeholder]="i18n.t('editor.findPlaceholder')"
spellcheck="false"
class="h-6 w-56 rounded-md border border-input bg-transparent px-2 text-xs outline-none focus:ring-2 focus:ring-ring"
/>
<span
class="text-[11px] tabular-nums"
[class.text-destructive]="findQuery() && !matches().length"
[class.text-muted-foreground]="!findQuery() || matches().length"
>
{{ countLabel() }}
</span>
<button
(click)="findStep(-1)"
[title]="i18n.t('editor.prevMatch')"
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaChevronUp" size="0.8rem" />
</button>
<button
(click)="findStep(1)"
[title]="i18n.t('editor.nextMatch')"
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaChevronDown" size="0.8rem" />
</button>
<div class="flex-1"></div>
<button
(click)="closeFind()"
[title]="i18n.t('editor.closeHint')"
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaX" size="0.8rem" />
</button>
</div>
}
@if (sessionDead()) {
<div
class="flex shrink-0 items-center gap-2 border-b border-border bg-destructive/10 px-3 py-1.5 text-[11px] text-destructive"
>
<ng-icon name="mynaXCircle" size="0.85rem" class="shrink-0" />
{{ i18n.t('editor.disconnected', { name: ctx.sessionTitle }) }}
</div>
}
@if (loading()) {
<div class="flex flex-1 items-center justify-center">
<ng-icon name="mynaSpinner" size="1.25rem" class="animate-spin text-primary" />
</div>
} @else if (loadError(); as error) {
<div class="flex flex-1 items-center justify-center">
<div class="max-w-md rounded-lg border border-border bg-card p-6 text-center shadow-sm">
<ng-icon name="mynaXCircle" size="1.5rem" class="text-destructive" />
<p class="mt-2 text-sm font-medium">{{ i18n.t('editor.cantOpen', { name: ctx.name }) }}</p>
<p class="mt-1 text-xs text-muted-foreground">{{ error }}</p>
<button
(click)="discardAndClose()"
class="mt-4 rounded-md border border-border px-3 py-1.5 text-xs font-medium hover:bg-accent"
>
{{ i18n.t('editor.closeWindow') }}
</button>
</div>
</div>
} @else {
<div class="flex min-h-0 flex-1">
<!-- gutter and textarea share font/leading/vertical padding — that's
the whole alignment contract; keep the classes in lockstep -->
@if (!wrap()) {
<div
#gutter
aria-hidden="true"
[style.width.ch]="gutterDigits() + 3"
class="shrink-0 overflow-hidden border-r border-border py-4 pr-2 text-right font-mono text-xs leading-relaxed whitespace-pre text-muted-foreground select-none"
>{{ gutterText() }}</div>
}
<textarea
#box
[value]="text()"
(input)="onInput($any($event.target).value)"
(scroll)="onScroll()"
spellcheck="false"
[attr.wrap]="wrap() ? 'soft' : 'off'"
class="min-h-0 flex-1 resize-none overflow-auto bg-transparent px-4 py-4 font-mono text-xs leading-relaxed outline-none"
[class.whitespace-pre]="!wrap()"
></textarea>
</div>
<footer class="flex shrink-0 items-center gap-2 border-t border-border bg-chrome px-4 py-2">
<span class="text-[11px] text-muted-foreground">{{ i18n.t('editor.shortcuts', { mod: modLabel }) }}</span>
<span class="text-[11px] text-muted-foreground">·</span>
<span class="text-[11px] tabular-nums text-muted-foreground">
{{ i18n.t('editor.lines', { count: lineCount() }) }}
</span>
<div class="flex-1"></div>
<button
(click)="requestClose()"
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
>
{{ i18n.t('editor.close') }}
</button>
<button
(click)="save()"
[disabled]="saving() || !dirty()"
class="flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
@if (saving()) {
<ng-icon name="mynaSpinner" size="0.85rem" class="animate-spin" />
}
{{ i18n.t('common.save') }}
</button>
</footer>
}
<!-- dirty-close prompt. Component-local on purpose: the shared confirm
dialog pulls in PanelsService, which must not boot in this window. -->
@if (closePrompt()) {
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancelClose()">
<div
class="w-96 rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl"
(mousedown)="$event.stopPropagation()"
>
<h2 class="text-sm font-semibold">{{ i18n.t('editor.saveChangesTitle', { name: ctx.name }) }}</h2>
<p class="mt-1.5 text-xs text-muted-foreground">
{{ i18n.t('editor.saveChangesBody') }}
</p>
<div class="mt-4 flex items-center gap-2">
<button
(click)="discardAndClose()"
class="rounded-md px-3 py-1.5 text-xs font-medium text-destructive hover:bg-accent"
>
{{ i18n.t('editor.dontSave') }}
</button>
<div class="flex-1"></div>
<button
(click)="cancelClose()"
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
>
{{ i18n.t('common.cancel') }}
</button>
<button
(click)="saveAndClose()"
[disabled]="saving()"
class="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{{ i18n.t('common.save') }}
</button>
</div>
</div>
</div>
}
}
<app-toasts />
</div>
@@ -0,0 +1,355 @@
import {
Component,
DOCUMENT,
ElementRef,
afterNextRender,
computed,
effect,
inject,
signal,
viewChild,
} from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import { I18nService } from '@core/i18n.service';
import { ALT_LABEL, blockNativeContextMenu, EDITOR_CONTEXT, MOD_LABEL } from '@core/platform';
import { RpcService } from '@core/rpc.service';
import { SettingsService } from '@core/settings.service';
import { ToastService } from '@core/toast.service';
import { TitleBar } from '../title-bar/title-bar';
import { Toasts } from '../toasts/toasts';
/** Debounce for mirroring the draft to bun (the traffic-light-close safety net). */
const DRAFT_DEBOUNCE_MS = 400;
/**
* Root component of an editor window (window.__APP_MODE === 'editor').
*
* Deliberately injects only Rpc/Toast/Settings: main-window root services
* (PanelsService, ConnectionsService, TransfersService — and the dialogs that
* inject them) have constructor side effects like creating workspaces and
* loading connections that must not run here.
*/
@Component({
selector: 'app-root',
imports: [NgIcon, Toasts, TitleBar],
templateUrl: './editor-window.html',
host: {
'(window:keydown)': 'onKeydown($event)',
'(window:contextmenu)': 'blockContextMenu($event)',
// Aero desaturates an unfocused window's chrome — see .window.active.
// blur already had to flush the draft; both run from the one binding
// (a duplicated key is a TS1117 build error, not a merge).
'(window:focus)': 'windowFocused.set(true)',
'(window:blur)': 'flushDraft(); windowFocused.set(false)',
},
})
export class EditorWindow {
readonly i18n = inject(I18nService);
/** Drives `.window.active` on this window's Aero frame. */
readonly windowFocused = signal(document.hasFocus());
readonly ctx = EDITOR_CONTEXT;
readonly modLabel = MOD_LABEL;
readonly altLabel = ALT_LABEL;
readonly loading = signal(true);
readonly loadError = signal<string | null>(null);
readonly text = signal('');
readonly savedContent = signal('');
readonly dirty = computed(() => this.text() !== this.savedContent());
readonly saving = signal(false);
readonly closePrompt = signal(false);
readonly sessionDead = signal(false);
/** Soft-wrap long lines. Hides the gutter: correct wrapped line numbers
* would need per-edit height measurement — not worth it without an editor lib. */
readonly wrap = signal(false);
readonly findOpen = signal(false);
readonly findQuery = signal('');
readonly activeMatch = signal(0);
/** Line count without allocating a split array on every keystroke. */
readonly lineCount = computed(() => {
const t = this.text();
let n = 1;
let i = -1;
while ((i = t.indexOf('\n', i + 1)) !== -1) n++;
return n;
});
readonly gutterText = computed(() =>
Array.from({ length: this.lineCount() }, (_, i) => i + 1).join('\n'),
);
readonly gutterDigits = computed(() => String(this.lineCount()).length);
/** Case-insensitive match start offsets for the find query. */
readonly matches = computed(() => {
const query = this.findQuery().toLowerCase();
if (!query) return [];
const hay = this.text().toLowerCase();
const out: number[] = [];
let i = hay.indexOf(query);
while (i !== -1) {
out.push(i);
i = hay.indexOf(query, i + query.length);
}
return out;
});
readonly countLabel = computed(() =>
this.matches().length ? `${this.activeMatch() + 1}/${this.matches().length}` : '0/0',
);
private readonly box = viewChild<ElementRef<HTMLTextAreaElement>>('box');
private readonly gutter = viewChild<ElementRef<HTMLElement>>('gutter');
private readonly findBox = viewChild<ElementRef<HTMLInputElement>>('findBox');
private cachedCharWidth = 0;
private readonly rpc = inject(RpcService);
private readonly toast = inject(ToastService);
private readonly document = inject(DOCUMENT);
// injected for its side effects: fetches settings and applies theme/accent/
// text-size data attributes to this window's document root
private readonly settings = inject(SettingsService);
private draftTimer: ReturnType<typeof setTimeout> | null = null;
private lastSentDirty = false;
constructor() {
const ctx = this.ctx;
if (!ctx) return; // opened outside the app (plain browser) — error card renders
const baseTitle = `${ctx.name} — ${ctx.sessionTitle}`;
effect(() => {
this.document.title = `${this.dirty() ? '• ' : ''}${baseTitle}`;
});
this.rpc.onEditorCloseRequest(() => this.requestClose());
this.rpc.onSessionClosed((event) => {
if (event.sessionId === ctx.sessionId) this.sessionDead.set(true);
});
afterNextRender(() => void this.load(ctx.sessionId, ctx.path));
}
private async load(sessionId: string, path: string): Promise<void> {
const res = await this.rpc.request.readFile({ sessionId, path });
if (!res.ok) {
this.loadError.set(res.error);
this.loading.set(false);
return;
}
this.savedContent.set(res.content);
this.text.set(res.content);
this.loading.set(false);
queueMicrotask(() => this.box()?.nativeElement.focus());
}
blockContextMenu(event: Event): void {
blockNativeContextMenu(event);
}
onInput(value: string): void {
this.text.set(value);
this.scheduleDraft();
}
/** Keep the line-number gutter locked to the textarea's scroll position. */
onScroll(): void {
const gutter = this.gutter()?.nativeElement;
if (gutter) gutter.scrollTop = this.box()!.nativeElement.scrollTop;
}
toggleWrap(): void {
this.wrap.update((w) => !w);
queueMicrotask(() => this.onScroll());
}
// ---- find ----
openFind(): void {
const el = this.box()?.nativeElement;
// prefill from the current selection when it's a sane single-line snippet
if (el && el.selectionStart !== el.selectionEnd) {
const selected = el.value.slice(el.selectionStart, el.selectionEnd);
if (selected.length < 200 && !selected.includes('\n')) this.findQuery.set(selected);
}
this.findOpen.set(true);
this.activeMatch.set(0);
queueMicrotask(() => {
const input = this.findBox()?.nativeElement;
input?.focus();
input?.select();
});
}
closeFind(): void {
this.findOpen.set(false);
// selection was left on the match — the caret lands there
this.box()?.nativeElement.focus();
}
onFindInput(value: string): void {
this.findQuery.set(value);
const list = this.matches();
if (!list.length) return;
// jump to the first match at/after the caret, else wrap to the first
const from = this.box()?.nativeElement.selectionStart ?? 0;
const at = list.findIndex((start) => start >= from);
this.goTo(at >= 0 ? at : 0);
}
findStep(delta: 1 | -1): void {
const count = this.matches().length;
if (!count) return;
this.goTo((this.activeMatch() + delta + count) % count);
}
onFindKeydown(event: KeyboardEvent): void {
if (event.key === 'Enter') {
event.preventDefault();
this.findStep(event.shiftKey ? -1 : 1);
} else if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
this.closeFind();
}
}
/** Select match `index` and scroll it into the middle of the viewport. */
private goTo(index: number): void {
const el = this.box()?.nativeElement;
const start = this.matches()[index];
if (!el || start === undefined) return;
this.activeMatch.set(index);
el.setSelectionRange(start, start + this.findQuery().length);
const text = this.text();
const lineHeight = parseFloat(getComputedStyle(el).lineHeight) || 20;
let line = 0;
let i = text.indexOf('\n');
while (i !== -1 && i < start) {
line++;
i = text.indexOf('\n', i + 1);
}
el.scrollTop = Math.max(0, line * lineHeight - el.clientHeight / 2);
if (!this.wrap()) {
const column = start - (text.lastIndexOf('\n', start - 1) + 1);
el.scrollLeft = Math.max(0, column * this.charWidth(el) - el.clientWidth / 2);
}
this.onScroll();
}
/** Monospace advance width, measured once per window. */
private charWidth(el: HTMLTextAreaElement): number {
if (!this.cachedCharWidth) {
const canvas = this.document.createElement('canvas').getContext('2d')!;
canvas.font = getComputedStyle(el).font;
this.cachedCharWidth = canvas.measureText('0').width || 7;
}
return this.cachedCharWidth;
}
// ---- draft mirror: bun holds the latest content for the native-close prompt ----
private scheduleDraft(): void {
if (this.dirty() !== this.lastSentDirty) {
// dirty flag flipped — tell bun immediately so the native title updates
this.flushDraft();
return;
}
if (this.draftTimer) clearTimeout(this.draftTimer);
this.draftTimer = setTimeout(() => this.flushDraft(), DRAFT_DEBOUNCE_MS);
}
flushDraft(): void {
if (!this.ctx || this.loading()) return;
if (this.draftTimer) {
clearTimeout(this.draftTimer);
this.draftTimer = null;
}
this.lastSentDirty = this.dirty();
this.rpc.send.editorState({ dirty: this.dirty(), content: this.text() });
}
// ---- save / close ----
async save(): Promise<boolean> {
if (!this.dirty() || this.saving()) return true;
const content = this.text();
this.saving.set(true);
try {
const res = await this.rpc.request.editorSave({ content });
if (!res.ok) {
this.toast.error(res.error);
return false;
}
this.savedContent.set(content);
this.lastSentDirty = false; // bun record already cleaned by the handler
this.toast.success(this.i18n.t('toasts.saved', { name: this.ctx!.name }));
return true;
} finally {
this.saving.set(false);
}
}
/** ⌘W / Close button / bun's editorCloseRequest: prompt if dirty, else close. */
requestClose(): void {
if (this.dirty()) {
this.closePrompt.set(true);
} else {
void this.rpc.request.editorClose({});
}
}
async saveAndClose(): Promise<void> {
if (await this.save()) void this.rpc.request.editorClose({});
}
discardAndClose(): void {
void this.rpc.request.editorClose({});
}
cancelClose(): void {
this.closePrompt.set(false);
queueMicrotask(() => this.box()?.nativeElement.focus());
}
// ---- keyboard ----
onKeydown(event: KeyboardEvent): void {
const mod = event.metaKey || event.ctrlKey;
if (mod && event.key.toLowerCase() === 's') {
event.preventDefault();
void this.save();
} else if (mod && event.key.toLowerCase() === 'f') {
event.preventDefault();
this.openFind();
} else if (mod && event.key.toLowerCase() === 'w') {
// reaches us on Windows/Linux; on macOS the app menu intercepts ⌘W and
// bun routes it back as editorCloseRequest
event.preventDefault();
this.requestClose();
} else if (event.altKey && event.code === 'KeyZ') {
event.preventDefault();
this.toggleWrap();
} else if (event.key === 'Escape' && this.closePrompt()) {
event.preventDefault();
this.cancelClose();
} else if (event.key === 'Escape' && this.findOpen()) {
event.preventDefault();
this.closeFind();
} else if (event.key === 'Tab' && event.target === this.box()?.nativeElement) {
// keep Tab as an input character inside the editor
event.preventDefault();
this.insertTab();
}
}
private insertTab(): void {
const el = this.box()!.nativeElement;
// execCommand keeps the native undo stack intact where supported
if (!this.document.execCommand('insertText', false, '\t')) {
const { selectionStart, selectionEnd, value } = el;
el.value = `${value.slice(0, selectionStart)}\t${value.slice(selectionEnd)}`;
el.selectionStart = el.selectionEnd = selectionStart + 1;
}
this.onInput(el.value);
}
}
@@ -0,0 +1,349 @@
<!-- session strip.
w7-toolbar: these are Explorer command-bar verbs, not dialog push buttons,
so under the win7 skin they go flat with a hover glow instead of wearing
the full Aero slab — the same treatment the Connections button gets from
the tab-bar row. -->
<div class="w7-toolbar relative flex items-center gap-1.5 border-b border-border px-2 py-1">
<button
(click)="toggleMenu()"
class="flex min-w-0 items-center gap-1.5 rounded-md px-2 py-1 text-(length:--path-font) font-medium hover:bg-accent"
[title]="i18n.t('panel.choosePanel')"
>
@if (panel().connecting()) {
<ng-icon name="mynaSpinner" class="shrink-0 animate-spin text-primary" size="var(--action-icon)" />
} @else {
<ng-icon
[name]="isRemote() ? 'mynaServers' : 'mynaHardDrive'"
[class]="isRemote() ? 'shrink-0 text-primary' : 'shrink-0 text-muted-foreground'"
size="var(--action-icon)"
/>
}
<span class="truncate">{{ isRemote() ? panel().title() : i18n.t('common.local') }}</span>
<ng-icon
name="mynaChevronDown"
class="shrink-0 text-muted-foreground"
size="calc(var(--action-icon) - 0.1rem)"
/>
</button>
<div class="flex-1"></div>
<!-- panel actions: act on THIS panel. Frequent/destructive verbs grow text
labels when the panel is wide enough (container query). -->
<button
(click)="copyToOther()"
[title]="side() === 'left' ? i18n.t('panel.copyToRight') : i18n.t('panel.copyToLeft')"
class="flex h-(--action-h) shrink-0 items-center rounded-md px-1 text-muted-foreground hover:bg-accent hover:text-primary"
>
<ng-icon [name]="copyIcon()" size="var(--action-icon)" />
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('panel.copy') }}</span>
</button>
<button
(click)="run('edit')"
[title]="i18n.t('panel.editFile')"
class="flex h-(--action-h) shrink-0 items-center rounded-md px-1 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaFileText" size="var(--action-icon)" />
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('panel.edit') }}</span>
</button>
<button
(click)="run('rename')"
[title]="i18n.t('panel.rename')"
class="flex h-(--action-h) shrink-0 items-center rounded-md px-1 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaEdit" size="var(--action-icon)" />
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('common.rename') }}</span>
</button>
<button
(click)="run('mkdir')"
[title]="i18n.t('panel.newFolder')"
class="grid size-(--action-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaFolderPlus" size="var(--action-icon)" />
</button>
<button
(click)="run('createFile')"
[title]="i18n.t('panel.newFile')"
class="grid size-(--action-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaFilePlus" size="var(--action-icon)" />
</button>
@if (chmodAvailable()) {
<button
(click)="run('chmod')"
[title]="i18n.t('panel.permissions')"
class="grid size-(--action-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaLock" size="var(--action-icon)" />
</button>
}
<button
(click)="run('delete')"
[title]="i18n.t('panel.deleteHint')"
class="flex h-(--action-h) shrink-0 items-center rounded-md px-1 text-muted-foreground hover:bg-accent hover:text-destructive"
>
<ng-icon name="mynaTrash" size="var(--action-icon)" />
<span class="ml-1 hidden text-(length:--action-font) @[30rem]:inline">{{ i18n.t('common.delete') }}</span>
</button>
@if (menuOpen()) {
<div class="fixed inset-0 z-20" (click)="menuOpen.set(false)"></div>
<div
class="has-scrollbar absolute top-full left-2 z-30 mt-1 max-h-80 w-64 overflow-y-auto rounded-lg border border-border bg-card py-1 text-card-foreground shadow-xl"
>
<button
(click)="pickLocal()"
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-(length:--path-font) hover:bg-accent"
>
<ng-icon name="mynaHardDrive" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
<span class="flex-1">{{ i18n.t('common.local') }}</span>
@if (!isRemote()) {
<ng-icon name="mynaCheck" size="0.85rem" class="shrink-0 text-primary" />
}
</button>
<!-- Windows drive roots (empty list elsewhere) -->
@for (drive of drives(); track drive) {
<button
(click)="pickDrive(drive)"
class="flex w-full items-center gap-2 py-1.5 pr-3 pl-6 text-left text-(length:--path-font) hover:bg-accent"
>
<ng-icon name="mynaHardDrive" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
<span class="flex-1 font-mono">{{ drive }}</span>
</button>
}
@if (connections.grouped().loose.length || connections.grouped().stacks.length) {
<div class="my-1 border-t border-border"></div>
}
@for (connection of connections.grouped().loose; track connection.id) {
<button
(click)="pickConnection(connection)"
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-(length:--path-font) hover:bg-accent"
>
<ng-icon name="mynaServers" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
<span class="flex-1 truncate">{{ connection.name }}</span>
</button>
}
@for (group of connections.grouped().stacks; track group.stack.id) {
<div class="px-3 pt-1.5 pb-0.5 text-[10px] font-medium tracking-wide text-muted-foreground uppercase">
{{ group.stack.name }}
</div>
@for (connection of group.connections; track connection.id) {
<button
(click)="pickConnection(connection)"
class="flex w-full items-center gap-2 py-1.5 pr-3 pl-5 text-left text-(length:--path-font) hover:bg-accent"
>
<ng-icon name="mynaServers" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
<span class="flex-1 truncate">{{ connection.name }}</span>
</button>
}
}
@if (connections.sshHosts().length) {
<div class="my-1 border-t border-border"></div>
<div class="px-3 pt-1.5 pb-0.5 text-[10px] font-medium tracking-wide text-muted-foreground uppercase">
~/.ssh/config
</div>
@for (host of connections.sshHosts(); track host.name) {
<button
(click)="pickSshHost(host)"
[disabled]="host.unsupported"
[title]="sshHostTitle(host)"
class="flex w-full items-center gap-2 py-1.5 pr-3 pl-5 text-left text-(length:--path-font) hover:bg-accent disabled:opacity-60 disabled:hover:bg-transparent"
>
<ng-icon name="mynaServers" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
<span class="flex-1 truncate">{{ host.name }}</span>
</button>
}
}
</div>
}
</div>
<!-- path bar. Also w7-toolbar: the up/refresh controls sit next to the action
row, so leaving them as Aero slabs would read as arbitrary. The class only
targets buttons — the path <input> keeps its 7.css textbox treatment. -->
<div class="w7-toolbar flex items-center gap-1 border-b border-border px-2 py-1.5">
<button
(click)="panel().up()"
[disabled]="blocked()"
[title]="i18n.t('panel.parentDir')"
class="grid size-(--path-ctl-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50 disabled:hover:bg-transparent"
>
<ng-icon name="mynaArrowUp" size="1rem" />
</button>
<input
[value]="pathDraft()"
(input)="pathDraft.set($any($event.target).value)"
(keydown.enter)="commitPath()"
(keydown.escape)="resetPath()"
(keydown)="$event.stopPropagation()"
[disabled]="blocked()"
spellcheck="false"
class="h-(--path-ctl-h) min-w-0 flex-1 rounded-md border border-input bg-transparent px-2 font-mono text-(length:--path-font) outline-none focus:ring-2 focus:ring-ring disabled:opacity-50"
/>
<button
(click)="panel().refresh()"
[disabled]="blocked()"
[title]="i18n.t('panel.refresh')"
class="grid size-(--path-ctl-h) shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50 disabled:hover:bg-transparent"
>
@if (panel().loading()) {
<ng-icon name="mynaSpinner" size="1rem" class="animate-spin" />
} @else {
<ng-icon name="mynaRefresh" size="1rem" />
}
</button>
</div>
<!-- Column headers live INSIDE the table now (7.css makes thead sticky), so
there is no separate header row here — see the ListView below. -->
<!-- type-to-filter bar (appears while a query is active) -->
@if (panel().filter(); as query) {
<div class="flex items-center gap-1.5 border-b border-border bg-accent/50 px-2 py-1">
<ng-icon name="mynaSearch" size="0.85rem" class="shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate font-mono text-xs">{{ query }}</span>
<span class="shrink-0 text-[11px] text-muted-foreground">
{{ i18n.t('panel.matches', { count: filterMatches() }) }}
</span>
<button
(click)="clearFilter()"
[title]="i18n.t('panel.clearFilter')"
class="grid size-5 shrink-0 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaX" size="0.8rem" />
</button>
</div>
}
<!-- file list -->
<div class="relative min-h-0 flex-1">
<div
#list
tabindex="0"
(keydown)="onKeydown($event)"
(mousedown)="activate()"
(contextmenu)="listContextMenu($event)"
class="has-scrollbar h-full overflow-y-auto outline-none"
>
@if (panel().connecting()) {
<div class="panel-fade-in grid h-full place-items-center">
<div class="flex flex-col items-center gap-3">
<ng-icon name="mynaSpinner" size="1.4rem" class="animate-spin text-primary" />
<span class="text-xs text-muted-foreground">
{{ i18n.t('panel.connectingTo', { name: panel().connectingTo() ?? '' }) }}
</span>
<button
(click)="cancelConnect()"
class="rounded-md border border-border px-3 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
>
{{ i18n.t('common.cancel') }}
</button>
</div>
</div>
} @else {
<!-- 7.css ListView: a plain <table>. The header is <thead> (7.css makes it
position:sticky, which is why it moved in here), the sorted column is
.highlighted.indicator[.up], and the selection band is tr.highlighted.
Widths come from <colgroup> because 7.css sets table-layout:fixed.
`w-full` is Tailwind's job and safe here: 7.css never declares `width` on
table, so the `skin` layer has nothing to outrank `utilities` with.
Without it the fixed layout shrinks to content and leaves a dead grey
gutter down the right of the pane; with it the unsized first <col>
absorbs the slack, which is what makes Name the elastic column. -->
<table class="w-full" [class.is-active]="isActive()">
<colgroup>
<col />
@for (col of visibleColumns(); track col.key) {
<col [style.width]="col.width" />
}
</colgroup>
<thead>
<tr (contextmenu)="headerContextMenu($event)">
<th
(click)="panel().toggleSort('name')"
[class.highlighted]="panel().sortKey() === 'name'"
[class.indicator]="panel().sortKey() === 'name'"
[class.up]="panel().sortKey() === 'name' && panel().sortAsc()"
>
{{ i18n.t('columns.name') }}
</th>
@for (col of visibleColumns(); track col.key) {
<th
(click)="panel().toggleSort(col.key)"
[class.highlighted]="panel().sortKey() === col.key"
[class.indicator]="panel().sortKey() === col.key"
[class.up]="panel().sortKey() === col.key && panel().sortAsc()"
[style.text-align]="col.key === 'size' ? 'right' : 'left'"
>
{{ i18n.t('columns.' + col.key) }}
</th>
}
</tr>
</thead>
<tbody>
@for (entry of panel().sorted(); track entry.name; let i = $index) {
<tr
[attr.data-index]="i"
(click)="rowClick($event, i)"
(dblclick)="openAt(i)"
(contextmenu)="rowContextMenu($event, i)"
[class.highlighted]="isSelected(entry) || i === panel().cursor()"
[class.is-cursor]="i === panel().cursor() && isActive()"
>
<td>
<span class="flex min-w-0 items-center gap-1.5">
<ng-icon [name]="iconFor(entry)" size="0.95rem" [class]="iconClass(entry)" />
<span class="truncate" [class.italic]="entry.type === 'symlink'">{{ entry.name }}</span>
</span>
</td>
@for (col of visibleColumns(); track col.key) {
@switch (col.key) {
@case ('size') {
<td style="text-align: right">{{ entry.type === 'dir' ? '—' : formatSize(entry.size) }}</td>
}
@case ('mtime') {
<td>{{ formatDate(entry.mtime) }}</td>
}
@case ('kind') {
<!-- fileKind is localized in file-panel.ts via i18n -->
<td>{{ fileKind(entry) }}</td>
}
@case ('rights') {
<td>{{ entry.name === '..' ? '' : formatMode(entry.mode) }}</td>
}
@case ('owner') {
<td>{{ entry.name === '..' ? '' : (entry.owner ?? '—') }}</td>
}
@case ('group') {
<td>{{ entry.name === '..' ? '' : (entry.group ?? '—') }}</td>
}
}
}
</tr>
} @empty {
<tr>
<td [attr.colspan]="visibleColumns().length + 1" style="text-align: center; padding: 24px">
{{ i18n.t('panel.emptyDir') }}
</td>
</tr>
}
@if (panel().filter() && filterMatches() === 0) {
<tr>
<td [attr.colspan]="visibleColumns().length + 1" style="text-align: center; padding: 24px">
{{ i18n.t('panel.noMatches') }}
</td>
</tr>
}
</tbody>
</table>
}
</div>
@if (panel().slowLoad() && !panel().connecting()) {
<div class="panel-fade-in absolute inset-0 z-10 grid place-items-center bg-background/60">
<ng-icon name="mynaSpinner" size="1.4rem" class="animate-spin text-primary" />
</div>
}
</div>
@@ -0,0 +1,353 @@
import {
Component,
ElementRef,
computed,
effect,
inject,
input,
signal,
viewChild,
} from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import { I18nService } from '@core/i18n.service';
import type { ColumnKey, FileEntry, SavedConnection, SshConfigHost } from '@shared/types';
import { ConnectionsService } from '@core/connections.service';
import { ContextMenuService } from '@core/context-menu.service';
import { DialogsService } from '@core/dialogs.service';
import { FileActionsService } from '@core/file-actions.service';
import { fileGlyph, fileIconClass, fileKindKey } from '@core/file-icons';
import { formatMode } from '@core/format';
import { PanelsService, type Side, type SortKey } from '@core/panels.service';
import { IS_WINDOWS } from '@core/platform';
import { RpcService } from '@core/rpc.service';
import { SettingsService } from '@core/settings.service';
import { TransfersService } from '@core/transfers.service';
/** Optional columns in fixed display order, with their track widths. */
const COLUMN_DEFS: { key: ColumnKey; label: string; width: string }[] = [
{ key: 'size', label: 'Size', width: '5.5rem' },
{ key: 'mtime', label: 'Modified', width: '8.5rem' },
{ key: 'kind', label: 'Kind', width: '5.5rem' },
{ key: 'rights', label: 'Rights', width: '6.5rem' },
{ key: 'owner', label: 'Owner', width: '6rem' },
{ key: 'group', label: 'Group', width: '6rem' },
];
@Component({
selector: 'app-file-panel',
imports: [NgIcon],
templateUrl: './file-panel.html',
host: { class: '@container flex min-w-0 flex-1 flex-col overflow-hidden' },
})
export class FilePanel {
readonly i18n = inject(I18nService);
readonly side = input.required<Side>();
readonly panels = inject(PanelsService);
readonly actions = inject(FileActionsService);
readonly transfers = inject(TransfersService);
readonly connections = inject(ConnectionsService);
readonly dialogs = inject(DialogsService);
private readonly menu = inject(ContextMenuService);
private readonly settings = inject(SettingsService);
private readonly rpc = inject(RpcService);
readonly panel = computed(() => this.panels.get(this.side()));
readonly isActive = computed(() => this.panels.activeSide() === this.side());
readonly isRemote = computed(() => this.panel().sessionId() !== 'local');
/** Input is ignored while the panel is connecting or a listing is in flight. */
readonly blocked = computed(() => this.panel().busy());
/** The copy button points toward the other panel. */
readonly copyIcon = computed(() => (this.side() === 'left' ? 'mynaArrowRight' : 'mynaArrowLeft'));
readonly pathDraft = signal('');
readonly menuOpen = signal(false);
/** Windows drive roots for the source picker; always empty elsewhere. */
readonly drives = signal<string[]>([]);
/** chmod on Windows local files only flips the read-only bit — hide it. */
readonly chmodAvailable = computed(() => !(IS_WINDOWS && !this.isRemote()));
private readonly listEl = viewChild.required<ElementRef<HTMLDivElement>>('list');
private lastClickedIndex = 0;
// locale-reactive wrappers: reading i18n.locale() inside makes the file-list
// cells re-render on a language switch (see i18n.service reactive t())
readonly formatSize = (bytes: number): string => this.i18n.size(bytes);
readonly formatDate = (ms: number): string => this.i18n.date(ms);
readonly formatMode = formatMode;
readonly fileKind = (entry: FileEntry): string => {
const key = fileKindKey(entry);
return key ? this.i18n.t('kinds.' + key) : '';
};
/** The optional columns currently shown, in canonical order. */
readonly visibleColumns = computed(() => {
const visible = this.settings.columns();
return COLUMN_DEFS.filter((c) => visible.includes(c.key));
});
headerContextMenu(event: MouseEvent): void {
event.preventDefault();
this.menu.open(event, this.menu.columnItems(this.settings.columns()));
}
pickDrive(drive: string): void {
this.menuOpen.set(false);
this.activate();
void this.panel().load(drive);
}
constructor() {
if (IS_WINDOWS && this.rpc.available) {
void this.rpc.request.listDrives({}).then((res) => {
if (res.ok) this.drives.set(res.drives);
});
}
// keep the editable path bar in sync with the actual cwd
effect(() => this.pathDraft.set(this.panel().cwd()));
// focus follows the active side (Tab switches panels, dialogs refocus on close)
effect(() => {
this.panels.focusTick();
if (this.isActive()) this.listEl().nativeElement.focus();
});
}
activate(): void {
this.panels.setActiveSide(this.side());
}
cancelConnect(): void {
this.panels.cancelConnect(this.side());
}
copyToOther(): void {
if (this.blocked()) return;
this.activate();
void this.transfers.copyToOtherPanel(this.side());
}
run(action: 'edit' | 'rename' | 'mkdir' | 'createFile' | 'chmod' | 'delete'): void {
if (this.blocked()) return;
this.activate();
void this.actions[action](this.panel());
}
pickLocal(): void {
this.menuOpen.set(false);
void this.panels.disconnectPanel(this.side());
}
pickConnection(connection: SavedConnection): void {
this.menuOpen.set(false);
this.activate();
void this.panels.connectPanel(this.side(), connection, connection.name, connection.localDir);
}
toggleMenu(): void {
this.menuOpen.set(!this.menuOpen());
// re-read ~/.ssh/config on each open so external edits show up
if (this.menuOpen()) void this.connections.reloadSshHosts();
}
pickSshHost(host: SshConfigHost): void {
this.menuOpen.set(false);
this.activate();
void this.panels.connectPanel(
this.side(),
{
host: host.hostname,
port: host.port,
username: host.username,
auth: host.identityFile
? { kind: 'key', privateKeyPath: host.identityFile }
: { kind: 'agent' },
initialPath: null,
},
host.name,
);
}
sshHostTitle(host: SshConfigHost): string {
if (host.unsupported === 'proxy') return 'Uses ProxyJump/ProxyCommand — not supported';
return `${host.username}@${host.hostname}:${host.port}`;
}
iconFor(entry: FileEntry): string {
return fileGlyph(entry, this.settings.iconColors() === 'multi');
}
iconClass(entry: FileEntry): string {
return fileIconClass(entry, this.settings.iconColors() === 'multi');
}
isSelected(entry: FileEntry): boolean {
return this.panel().selected().has(entry.name);
}
clearFilter(): void {
this.panel().setFilter('');
this.listEl().nativeElement.focus();
}
filterMatches(): number {
return this.panel()
.sorted()
.filter((e) => e.name !== '..').length;
}
commitPath(): void {
if (this.blocked()) return;
const value = this.pathDraft().trim();
if (value) void this.panel().load(value);
this.listEl().nativeElement.focus();
}
resetPath(): void {
this.pathDraft.set(this.panel().cwd());
this.listEl().nativeElement.focus();
}
rowClick(event: MouseEvent, index: number): void {
if (this.blocked()) return;
this.activate();
const panel = this.panel();
if (event.shiftKey) {
panel.selectRange(this.lastClickedIndex, index);
} else if (event.metaKey || event.ctrlKey) {
const entry = panel.sorted()[index];
if (entry) panel.toggleSelect(entry.name);
} else {
panel.clearSelection();
}
panel.cursor.set(index);
this.lastClickedIndex = index;
}
rowContextMenu(event: MouseEvent, index: number): void {
event.preventDefault();
event.stopPropagation();
if (this.blocked()) return;
this.activate();
const panel = this.panel();
const entry = panel.sorted()[index] ?? null;
// standard behavior: keep the selection when right-clicking inside it
if (entry && !panel.selected().has(entry.name)) panel.clearSelection();
panel.cursor.set(index);
this.actions.openContextMenu(event, { side: this.side(), panel, entry });
}
listContextMenu(event: MouseEvent): void {
event.preventDefault();
if (this.blocked()) return;
this.activate();
this.actions.openContextMenu(event, { side: this.side(), panel: this.panel(), entry: null });
}
openAt(index: number): void {
if (this.blocked()) return;
const entry = this.panel().sorted()[index];
if (entry) this.panel().open(entry);
}
onKeydown(event: KeyboardEvent): void {
if (this.blocked()) return;
const panel = this.panel();
const key = event.key;
if (key === 'Tab') {
event.preventDefault();
this.panels.setActiveSide(this.panels.otherSide(this.side()));
return;
}
if ((event.metaKey || event.ctrlKey) && key.toLowerCase() === 'a') {
event.preventDefault();
panel.selectAll();
return;
}
// type-to-filter: printable characters build the query (Space only once a
// query exists — an empty-filter Space keeps its toggle-select meaning)
if (
key.length === 1 &&
!event.metaKey &&
!event.ctrlKey &&
!event.altKey &&
(key !== ' ' || panel.filter() !== '')
) {
event.preventDefault();
panel.setFilter(panel.filter() + key);
this.scrollCursorIntoView();
return;
}
if (key === 'Backspace' && panel.filter() !== '') {
event.preventDefault();
panel.setFilter(panel.filter().slice(0, -1));
return;
}
if (key === 'Escape' && panel.filter() !== '') {
event.preventDefault();
panel.setFilter('');
return;
}
switch (key) {
case 'ArrowDown':
panel.moveCursor(1);
break;
case 'ArrowUp':
panel.moveCursor(-1);
break;
case 'PageDown':
panel.moveCursor(20);
break;
case 'PageUp':
panel.moveCursor(-20);
break;
case 'Home':
panel.cursor.set(0);
break;
case 'End':
panel.cursor.set(Math.max(0, panel.sorted().length - 1));
break;
case ' ':
{
const entry = panel.sorted()[panel.cursor()];
if (entry) panel.toggleSelect(entry.name);
panel.moveCursor(1);
}
break;
case 'Enter':
this.openAt(panel.cursor());
return void event.preventDefault();
case 'Backspace':
panel.up();
break;
case 'F2':
this.actions.rename(panel);
break;
case 'F4':
void this.actions.edit(panel);
break;
case 'F5':
void this.transfers.copyToOtherPanel(this.side());
break;
case 'F7':
this.actions.mkdir(panel);
break;
case 'F8':
case 'Delete':
this.actions.delete(panel);
break;
default:
return; // let unhandled keys through
}
event.preventDefault();
this.scrollCursorIntoView();
}
private scrollCursorIntoView(): void {
const row = this.listEl().nativeElement.querySelector(
`[data-index="${this.panel().cursor()}"]`,
);
row?.scrollIntoView({ block: 'nearest' });
}
}
+17
View File
@@ -0,0 +1,17 @@
import { App } from './app';
import { ConnectionManager } from './connection-manager/connection-manager';
import { EditorWindow } from './editor-window/editor-window';
/**
* The three window roots of the Windows 7 skin, keyed by APP_MODE.
*
* Every skin exports this same shape, so main.ts selects a tree by skin and
* then a root by mode without knowing anything about either skin's internals.
* All three roots declare `selector: 'app-root'` — the single <app-root> in
* index.html hosts whichever one gets bootstrapped.
*/
export const SEVEN_ROOTS = {
main: App,
manager: ConnectionManager,
editor: EditorWindow,
} as const;
@@ -0,0 +1,81 @@
import { Component, computed, inject } from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import { I18nService } from '@core/i18n.service';
import type { TransferSnapshot } from '@shared/types';
import { formatSpeed } from '@core/format';
import { PanelsService } from '@core/panels.service';
import { SettingsService } from '@core/settings.service';
import { TransfersService } from '@core/transfers.service';
@Component({
selector: 'app-status-bar',
imports: [NgIcon],
template: `
<div
class="flex h-6 items-center justify-between gap-4 border-t border-border bg-chrome px-3 text-[11px] text-muted-foreground select-none"
>
<!-- transfers summary; click toggles the detailed list -->
<!-- w7-flat: this is a text toggle, not a push button. Without it 7.css
paints the full Aero slab and the status line reads as a stray
bordered button sitting in the status bar. -->
<button
(click)="transfers.expanded.set(!transfers.expanded())"
class="w7-flat flex min-w-0 items-center gap-1.5 hover:text-foreground"
[title]="i18n.t('transfer.showDetails')"
>
@if (transfers.currentJob(); as job) {
<ng-icon name="mynaSpinner" size="0.8rem" class="shrink-0 animate-spin text-primary" />
<span class="truncate">
{{ i18n.t('transfer.copying', { name: job.name, percent: percent(job), speed: speedSuffix(job) }) }}
@if (transfers.queuedCount(); as queued) {
· {{ i18n.t('transfer.queued', { count: queued }) }}
}
</span>
} @else if (transfers.failedJobs().length; as failed) {
<ng-icon name="mynaXCircle" size="0.8rem" class="shrink-0 text-destructive" />
<span class="text-destructive">
{{ i18n.t('transfer.failed', { count: failed }) }}
</span>
} @else if (transfers.hasJobs()) {
<ng-icon name="mynaCheckCircle" size="0.8rem" class="shrink-0 text-success" />
<span>{{ i18n.t('transfer.finished') }}</span>
} @else {
<span>{{ i18n.t('transfer.ready') }}</span>
}
</button>
<!-- active panel info -->
<div class="flex shrink-0 items-center gap-1.5">
<ng-icon
[name]="activePanel().sessionId() === 'local' ? 'mynaHardDrive' : 'mynaServers'"
size="0.8rem"
[class.text-primary]="activePanel().sessionId() !== 'local'"
/>
<span class="max-w-56 truncate">
{{ activePanel().sessionId() === 'local' ? i18n.t('common.local') : activePanel().title() }}
</span>
<span>· {{ i18n.t('transfer.items', { count: activePanel().sorted().length }) }}</span>
@if (activePanel().selected().size; as count) {
<span>· {{ i18n.t('transfer.selected', { count }) }}</span>
}
</div>
</div>
`,
})
export class StatusBar {
readonly i18n = inject(I18nService);
readonly transfers = inject(TransfersService);
readonly panels = inject(PanelsService);
readonly settings = inject(SettingsService);
readonly activePanel = computed(() => this.panels.get(this.panels.activeSide()));
percent(job: TransferSnapshot): number {
if (!job.totalBytes) return 0;
return Math.min(100, Math.round((job.doneBytes / job.totalBytes) * 100));
}
speedSuffix(job: TransferSnapshot): string {
const speed = this.transfers.speeds().get(job.id);
return speed ? ` · ${formatSpeed(speed, this.settings.speedUnit())}` : '';
}
}
+547
View File
@@ -0,0 +1,547 @@
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 '@core/i18n.service';
import { PanelsService } from '@core/panels.service';
import { MOD_LABEL } from '@core/platform';
import { RpcService } from '@core/rpc.service';
import { SettingsService } from '@core/settings.service';
import { UpdaterService } from '@core/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: `
<!-- The workspace strip, directly under the title bar. Draggable so the
empty stretch between the tabs and the right-hand tools still moves the
frameless window. ONE class attribute: a second one is silently dropped
by the template parser, which is exactly how this row lost its height. -->
<div
class="w7-toolbar relative flex h-9 items-center gap-0.5 border-b border-border bg-chrome pt-1 pr-1.5 pl-1.5 electrobun-webkit-app-region-drag"
>
<!-- 7.css's real tab component. The contract is exact: menu[role=tablist]
for the strip and a button[aria-selected] per tab — 7.css then draws
the joined inactive tabs and raises the active one (white fill, no
bottom border, pulled 2px down over the row's border so it reads as
joined to the content). Everything the hand-rolled version painted
(bg-accent, the absolutely-positioned bg-primary underline) is gone:
it was a lookalike competing with the framework.
NOTE the selector is "menu[role=tablist] button" — a DESCENDANT, so
any button in here becomes a tab. The "+" and the right-hand tools
deliberately live OUTSIDE this element.
(No backticks in this template: it is a TS template literal.) -->
<menu
#tabScroll
role="tablist"
class="flex min-w-0 items-center overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
@for (ws of panels.workspaces(); track ws.id; let i = $index) {
<!-- No ✕ inside the tab: real Win7 tab controls had none, and a button
nested in a button is invalid HTML. Closing is Ctrl/Cmd+W or
middle-click (auxclick), so a pointer path survives. -->
<button
role="tab"
[attr.aria-selected]="ws.id === panels.activeWorkspaceId()"
[attr.data-tab-id]="ws.id"
(click)="panels.setActiveWorkspace(ws.id)"
(auxclick)="onAuxClick($event, ws.id)"
class="flex max-w-52 min-w-0 shrink-0 items-center gap-1.5 electrobun-webkit-app-region-no-drag"
[class.opacity-50]="draggedId() === ws.id"
[style.box-shadow]="dropIndicatorShadow(i)"
[title]="panels.workspaceName(ws)"
draggable="true"
(dragstart)="onDragStart($event, ws.id)"
(dragover)="onDragOver($event, i)"
(drop)="onDrop($event, i)"
(dragend)="onDragEnd()"
>
<ng-icon
[name]="panels.workspaceIsRemote(ws) ? 'mynaServers' : 'mynaHardDrive'"
size="0.85rem"
class="shrink-0"
/>
<span class="truncate">{{ panels.workspaceName(ws) }}</span>
</button>
}
</menu>
<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 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<number | null>(null);
readonly dropTarget = signal<{ index: number; before: boolean } | null>(null);
private readonly tabScroll = viewChild.required<ElementRef<HTMLMenuElement>>('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 — the pointer path that replaced the per-tab ✕.
* `auxclick` is the non-primary-button click event; guard on button 1 so the
* right button (which also fires auxclick) never closes anything. Refuses the
* last tab, matching closeWorkspace's own floor.
*/
onAuxClick(event: MouseEvent, id: number): void {
if (event.button !== 1) return;
event.preventDefault();
if (this.panels.workspaces().length > 1) this.close(event, 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);
}
}
@@ -0,0 +1,81 @@
import { Component, inject, input, output, signal } from '@angular/core';
import { RpcService } from '@core/rpc.service';
/**
* The Aero title bar. Rendered by all three window kinds. The native frame is
* gone on every platform (titleBarStyle 'hidden'), so this row IS the window's
* chrome.
*
* The markup is 7.css's contract, not ours: `.title-bar` > `.title-bar-text` +
* `.title-bar-controls` > buttons keyed by `aria-label` (Minimize / Maximize /
* Restore / Close each get their own glyph). Nothing here is styled with
* Tailwind on purpose — the whole row comes from @layer skin.
*
* `.title-bar` lives on the HOST element, not on an inner div. 7.css couples
* the glass treatment with a DIRECT-child selector — `.window.glass >
* .title-bar` — and an inner div puts <app-title-bar> in between, so the rule
* never matched: the title bar rendered as the opaque fallback with its own
* border, visibly disconnected from the frame, instead of the translucent
* streaked glass that fuses with it.
*
* Dragging rides on electrobun's preload drag-region classes. Two things
* electrobun does NOT provide and this component implements:
* - double-click to maximize (it only wires mousedown/mouseup)
* - the maximize/restore glyph state, which has no event; bun derives it
* from the native resize and pushes `windowState` (see trackMaximizeState).
*/
@Component({
selector: 'app-title-bar',
host: {
class: 'title-bar electrobun-webkit-app-region-drag',
// Published so the FRAME can react: a maximized Win7 window has square
// corners. The window root reads this off its own child via :has() rather
// than subscribing to windowState a second time — see styles.css.
'[class.is-maximized]': 'maximized()',
'(dblclick)': 'toggle()',
},
template: `
<div class="title-bar-text">{{ title() }}</div>
<div class="title-bar-controls electrobun-webkit-app-region-no-drag">
@if (resizable()) {
<button aria-label="Minimize" (click)="minimize()"></button>
<button
[attr.aria-label]="maximized() ? 'Restore' : 'Maximize'"
(click)="toggle()"
></button>
}
<button aria-label="Close" (click)="closeRequested.emit()"></button>
</div>
`,
})
export class TitleBar {
readonly title = input('');
/** The manager window is a fixed-size modal — it gets Close only. */
readonly resizable = input(true);
/** Hosts decide what closing means (quit / dirty prompt / managerClose). */
readonly closeRequested = output<void>();
protected readonly maximized = signal(false);
private readonly rpc = inject(RpcService);
constructor() {
if (!this.rpc.available) return;
// No maximize event exists, so seed from a one-shot read and then follow
// the resize-derived push (which also covers Aero Snap and Win+Up).
void this.rpc.request.windowIsMaximized({}).then((res) => {
if (res.ok) this.maximized.set(res.maximized);
});
this.rpc.onWindowState((maximized) => this.maximized.set(maximized));
}
protected minimize(): void {
void this.rpc.request.windowMinimize({});
}
protected toggle(): void {
void this.rpc.request.windowToggleMaximize({}).then((res) => {
if (res.ok) this.maximized.set(res.maximized);
});
}
}
+50
View File
@@ -0,0 +1,50 @@
import { Component, inject } from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import { ToastService } from '@core/toast.service';
/** Toast stack overlay; shared by the main window and editor windows. */
@Component({
selector: 'app-toasts',
imports: [NgIcon],
template: `
<div class="pointer-events-none fixed right-3 bottom-8 z-50 flex w-80 flex-col gap-2">
@for (t of toast.toasts(); track t.id) {
<div
(mouseenter)="toast.pause(t.id)"
(mouseleave)="toast.resume(t.id)"
class="group pointer-events-auto relative flex items-start gap-2 overflow-hidden rounded-lg border border-border bg-card p-3 text-xs text-card-foreground shadow-lg"
>
<ng-icon
[name]="toastIcon(t.kind)"
size="1rem"
[class]="t.kind === 'error' ? 'shrink-0 text-destructive' : 'shrink-0 text-success'"
/>
<span class="min-w-0 flex-1 wrap-break-word">{{ t.text }}</span>
<button
(click)="toast.dismiss(t.id)"
class="shrink-0 text-muted-foreground hover:text-foreground"
>
<ng-icon name="mynaX" size="0.9rem" />
</button>
<!-- auto-dismiss countdown; freezes with the timer while hovered -->
<div
class="toast-countdown absolute inset-x-0 bottom-0 h-0.5 origin-left"
[class.bg-destructive]="t.kind === 'error'"
[class.bg-success]="t.kind === 'success'"
[class.bg-primary]="t.kind === 'info'"
[style.animation-duration.ms]="t.ttlMs"
></div>
</div>
}
</div>
`,
})
export class Toasts {
readonly toast = inject(ToastService);
toastIcon(kind: 'error' | 'info' | 'success'): string {
if (kind === 'error') return 'mynaXCircle';
if (kind === 'success') return 'mynaCheckCircle';
return 'mynaCheck';
}
}
@@ -0,0 +1,59 @@
@if (transfers.expanded() && transfers.hasJobs()) {
<div class="flex items-center justify-between border-t border-border bg-chrome px-3 py-1 text-[11px] text-muted-foreground">
<span>
{{ i18n.t('transfer.summary', { active: transfers.activeJobs().length, finished: transfers.finishedJobs().length }) }}
</span>
<button
(click)="transfers.clearFinished()"
[disabled]="!transfers.finishedJobs().length"
class="flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-accent hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent"
>
<ng-icon name="mynaTrash" size="0.75rem" /> {{ i18n.t('transfer.clearFinished') }}
</button>
</div>
<div class="has-scrollbar max-h-40 overflow-y-auto border-t border-border bg-chrome">
@for (job of transfers.jobs(); track job.id) {
<div class="flex items-center gap-2 px-3 py-1.5 text-xs">
<ng-icon [name]="stateIcon(job)" size="0.95rem" [class]="stateClass(job)" />
<span class="w-40 shrink-0 truncate font-medium" [title]="job.srcPath">{{ job.name }}</span>
<div class="min-w-0 flex-1">
<div class="truncate text-[11px] text-muted-foreground">{{ progressLabel(job) }}</div>
@if (isActive(job)) {
<!-- role+aria are 7.css's hook for the Win7 progress bar
([role=progressbar] > div); under the modern skin they are
inert and the Tailwind classes below still draw the bar. -->
<div
role="progressbar"
aria-valuemin="0"
aria-valuemax="100"
[attr.aria-valuenow]="jobPercent(job)"
class="mt-0.5 h-1 overflow-hidden rounded-full bg-muted"
>
<div
class="h-full rounded-full bg-primary transition-[width] duration-100"
[style.width.%]="jobPercent(job)"
></div>
</div>
}
</div>
@if (isActive(job)) {
<button
(click)="transfers.cancel(job.id)"
[title]="i18n.t('transfer.cancel')"
class="shrink-0 rounded p-1 text-muted-foreground hover:bg-accent hover:text-destructive"
>
<ng-icon name="mynaX" size="0.9rem" />
</button>
} @else {
<button
(click)="transfers.dismiss(job.id)"
[title]="i18n.t('transfer.dismiss')"
class="shrink-0 rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaX" size="0.9rem" />
</button>
}
</div>
}
</div>
}
@@ -0,0 +1,82 @@
import { Component, inject } from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import type { TransferSnapshot } from '@shared/types';
import { formatEta } from '@core/format';
import { I18nService } from '@core/i18n.service';
import { SettingsService } from '@core/settings.service';
import { TransfersService } from '@core/transfers.service';
@Component({
selector: 'app-transfer-bar',
imports: [NgIcon],
templateUrl: './transfer-bar.html',
})
export class TransferBar {
readonly i18n = inject(I18nService);
readonly transfers = inject(TransfersService);
readonly settings = inject(SettingsService);
readonly formatSize = (bytes: number): string => this.i18n.size(bytes);
/** "14.2 MB/s · 0:42 left" while a job is moving bytes. */
speedLabel(job: TransferSnapshot): string {
if (job.state !== 'running') return '';
const speed = this.transfers.speeds().get(job.id);
if (!speed) return '';
const parts = [this.i18n.speed(speed, this.settings.speedUnit())];
const remaining = job.totalBytes - job.doneBytes;
if (remaining > 0 && speed > 0) {
parts.push(this.i18n.t('transfer.left', { eta: formatEta(remaining / speed) }));
}
return parts.join(' · ');
}
stateIcon(job: TransferSnapshot): string {
switch (job.state) {
case 'completed':
return 'mynaCheckCircle';
case 'failed':
case 'cancelled':
return 'mynaXCircle';
default:
return 'mynaSpinner';
}
}
stateClass(job: TransferSnapshot): string {
switch (job.state) {
case 'completed':
return 'shrink-0 text-success';
case 'failed':
return 'shrink-0 text-destructive';
case 'cancelled':
return 'shrink-0 text-muted-foreground';
default:
return 'shrink-0 animate-spin text-primary';
}
}
isActive(job: TransferSnapshot): boolean {
return job.state === 'queued' || job.state === 'scanning' || job.state === 'running';
}
jobPercent(job: TransferSnapshot): number {
if (!job.totalBytes) return job.state === 'completed' ? 100 : 0;
return Math.min(100, (job.doneBytes / job.totalBytes) * 100);
}
progressLabel(job: TransferSnapshot): string {
if (job.state === 'queued') return this.i18n.t('transfer.queuedState');
if (job.state === 'scanning') return this.i18n.t('transfer.scanning');
if (job.state === 'failed')
return `${job.currentFile ? `${job.currentFile} — ` : ''}${job.error ?? this.i18n.t('transfer.failedState')}`;
if (job.state === 'cancelled') return this.i18n.t('transfer.cancelled');
if (job.state === 'completed')
return this.i18n.t('transfer.completedFiles', {
count: job.doneFiles,
size: this.i18n.size(job.doneBytes),
});
const speed = this.speedLabel(job);
const base = `${job.currentFile} · ${this.i18n.size(job.doneBytes)} / ${this.i18n.size(job.totalBytes)}`;
return speed ? `${base} · ${speed}` : base;
}
}