Stage 1: extract the skin-agnostic UI layer into app/core/

First step of splitting the UI into two skin trees. All 12 services plus
platform.ts, format.ts, file-icons.ts, icons.ts and app.config.ts move to
ui/src/app/core/ — the layer both skins will share.

This is a pure move: git records all 17 as renames, and no file content
changed beyond import paths. The premise holds because `git diff
main...feat/7css-skin` touches none of these files — the service layer was
already skin-agnostic, so there is nothing to untangle, only to relocate.

Components now reach it through a new `@core/*` tsconfig alias rather than a
relative path. That is deliberate and load-bearing for the next stage: skin
components are about to move under app/skins/<skin>/ at varying depths, and
an alias keeps every one of these 67 imports correct wherever its file ends
up. `@shared/*` (the bun-side src/shared) already set the precedent.

Intra-core imports needed no edits at all — every ./sibling reference among
the moved files points at another moved file.

Verified: tsc clean, `bun run build:ui` clean (AOT resolves every import and
template symbol), and the running window's opaque interior is pixel-identical
to a pre-refactor capture.

Note on that comparison: the Aero frame and title bar do NOT compare equal
between launches, and that is not a regression — .window.glass::before runs
at opacity .6, so the chrome composites over whatever desktop is behind the
window and shifts by a uniform ~8% brightness. The check therefore diffs the
opaque interior (inset 30px sides, 80px top), where anything this refactor
could break would show.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-16 04:49:27 +02:00
co-authored by Claude
parent ab0bb4ef6f
commit 17bb1a14fb
33 changed files with 74 additions and 68 deletions
+11
View File
@@ -0,0 +1,11 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideIcons, provideNgIconsConfig } from '@ng-icons/core';
import { APP_ICONS } from './icons';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideIcons(APP_ICONS),
provideNgIconsConfig({ size: '1em' }),
],
};
+99
View File
@@ -0,0 +1,99 @@
import { Injectable, computed, inject, signal } from '@angular/core';
import type { SavedConnection, SshConfigHost, Stack } from '@shared/types';
import { RpcService } from './rpc.service';
import { I18nService } from './i18n.service';
import { ToastService } from './toast.service';
@Injectable({ providedIn: 'root' })
export class ConnectionsService {
private readonly i18n = inject(I18nService);
private readonly rpc = inject(RpcService);
private readonly toast = inject(ToastService);
readonly stacks = signal<Stack[]>([]);
readonly connections = signal<SavedConnection[]>([]);
/** Read-only hosts from ~/.ssh/config; empty when the file is absent or unreadable. */
readonly sshHosts = signal<SshConfigHost[]>([]);
/** One group per stack (stacks always shown), plus the loose connections. */
readonly grouped = computed(() => {
const connections = this.connections();
return {
stacks: this.stacks().map((stack) => ({
stack,
connections: connections.filter((c) => c.stackId === stack.id),
})),
loose: connections.filter((c) => !c.stackId),
};
});
constructor() {
if (this.rpc.available) {
void this.reload();
void this.reloadSshHosts();
// the connection-manager window edited the store — pick it up
this.rpc.onConnectionsChanged(() => void this.reload());
}
}
async reload(): Promise<void> {
const res = await this.rpc.request.getConnections({});
if (!res.ok) {
this.toast.error(res.error);
return;
}
this.stacks.set(res.store.stacks);
this.connections.set(res.store.connections);
}
/** Silent on failure — a broken ssh config just means an empty group. */
async reloadSshHosts(): Promise<void> {
const res = await this.rpc.request.getSshConfigHosts({});
this.sshHosts.set(res.ok ? res.hosts : []);
}
async upsert(connection: SavedConnection): Promise<string | null> {
const res = await this.rpc.request.upsertConnection({ connection });
if (!res.ok) {
this.toast.error(res.error);
return null;
}
await this.reload();
return res.id;
}
async remove(id: string): Promise<void> {
const res = await this.rpc.request.deleteConnection({ id });
if (!res.ok) this.toast.error(res.error);
await this.reload();
}
async upsertStack(stack: Stack): Promise<void> {
const res = await this.rpc.request.upsertStack({ stack });
if (!res.ok) this.toast.error(res.error);
await this.reload();
}
async removeStack(id: string): Promise<void> {
const res = await this.rpc.request.deleteStack({ id });
if (!res.ok) this.toast.error(res.error);
await this.reload();
}
/** Native picker + WinSCP.ini import; silent when the dialog is cancelled. */
async importWinScp(): Promise<void> {
const res = await this.rpc.request.importWinScp({});
if (!res.ok) {
this.toast.error(res.error);
return;
}
if (res.connections || res.stacks || res.skipped) {
const skipped = res.skipped ? this.i18n.t('toasts.importedSkipped', { skipped: res.skipped }) : '';
const stacks = res.stacks ? this.i18n.t('toasts.importedIntoStacks', { count: res.stacks }) : '';
this.toast.success(
this.i18n.t('toasts.imported', { count: res.connections }) + stacks + skipped,
);
}
await this.reload();
}
}
+125
View File
@@ -0,0 +1,125 @@
import { Injectable, inject, signal } from '@angular/core';
import type { ColumnKey, EntryType } from '@shared/types';
import { I18nService } from './i18n.service';
import { IS_MAC, IS_WINDOWS } from './platform';
import { RpcService } from './rpc.service';
/** Optional list columns, in display order (Name is fixed). */
const COLUMN_KEYS: ColumnKey[] = ['size', 'mtime', 'kind', 'rights', 'owner', 'group'];
export interface MenuItem {
type?: 'separator';
label?: string;
/** Emitted verbatim to the action dispatcher (`ctx-*` / `col:*`). */
action?: string;
checked?: boolean;
tooltip?: string;
/** Shortcut hint, rendered right-aligned. */
accelerator?: string;
}
export interface MenuRequest {
x: number;
y: number;
items: MenuItem[];
}
export interface RowMenuContext {
path: string;
name: string;
type: EntryType;
isLocal: boolean;
hasEntry: boolean; // false = right-click on empty panel space
}
// PORTABILITY: shell-integration wording per OS (resolved at build time for locale)
const revealKey = (): string =>
IS_MAC ? 'ctx.revealFinder' : IS_WINDOWS ? 'ctx.revealExplorer' : 'ctx.revealFileManager';
const trashKey = (): string => (IS_WINDOWS ? 'ctx.moveToRecycleBin' : 'ctx.moveToTrash');
/**
* Builds and opens the panel right-click menus. This used to be a native
* electrobun ContextMenu on the bun side, but `showContextMenu` is a documented
* no-op on Linux (electrobun's own dist/api/bun/proc/linux.md), which left the
* whole right-click surface dead there. The menu is plain HTML now — one code
* path on every platform — and still emits the exact same action strings, so
* the dispatcher in app.ts is unchanged.
*/
@Injectable({ providedIn: 'root' })
export class ContextMenuService {
private readonly i18n = inject(I18nService);
private readonly rpc = inject(RpcService);
readonly request = signal<MenuRequest | null>(null);
open(event: MouseEvent, items: MenuItem[]): void {
this.request.set({ x: event.clientX, y: event.clientY, items });
}
close(): void {
this.request.set(null);
}
/** A picked item goes through the same dispatcher the app menubar uses. */
onPick(action: string): void {
this.rpc.emitMenuAction(action);
}
/** Row / empty-space menu for a file panel. */
rowItems(ctx: RowMenuContext): MenuItem[] {
const t = (key: string) => this.i18n.t(key);
const items: MenuItem[] = [];
if (!ctx.hasEntry || ctx.name === '..') {
// empty space (or the parent row): folder-level actions
items.push({ label: t('ctx.newFolder'), action: 'ctx-new-folder' });
items.push({ label: t('ctx.newFile'), action: 'ctx-new-file' });
items.push({ type: 'separator' });
items.push({ label: t('ctx.refresh'), action: 'ctx-refresh' });
if (ctx.isLocal) {
items.push({ type: 'separator' });
items.push({ label: t(revealKey()), action: 'ctx-reveal' });
}
return items;
}
items.push({ label: t('ctx.copyToOther'), action: 'ctx-copy' });
if (ctx.type === 'dir') {
items.push({ label: t('ctx.open'), action: 'ctx-open' });
} else {
if (ctx.isLocal) {
items.push({ label: t('ctx.open'), action: 'ctx-open-default', tooltip: t('ctx.openDefault') });
}
items.push({ label: t('ctx.edit'), action: 'ctx-edit', accelerator: 'F4' });
}
items.push({ label: t('ctx.rename'), action: 'ctx-rename' });
// chmod on Windows local files only flips the read-only bit — hide the lie
if (!(ctx.isLocal && IS_WINDOWS)) {
items.push({ label: t('ctx.permissions'), action: 'ctx-chmod' });
}
items.push({ type: 'separator' });
items.push({ label: t('ctx.copyPath'), action: 'ctx-copy-path' });
if (ctx.isLocal) {
items.push({ label: t(revealKey()), action: 'ctx-reveal' });
}
items.push({ type: 'separator' });
if (ctx.isLocal) {
items.push({
label: t(trashKey()),
action: 'ctx-trash',
...(IS_MAC ? { tooltip: t('ctx.trashTooltip') } : {}),
});
}
items.push({ label: t('ctx.delete'), action: 'ctx-delete' });
return items;
}
/** Column picker for the list headers: checkable toggles per column. */
columnItems(visible: ColumnKey[]): MenuItem[] {
return COLUMN_KEYS.map((key) => ({
label: this.i18n.t(`columns.${key}`),
action: `col:${key}`,
checked: visible.includes(key),
}));
}
}
+59
View File
@@ -0,0 +1,59 @@
import { Injectable, signal } from '@angular/core';
import type { FileEntry } from '@shared/types';
export type OverwriteChoice = 'overwrite' | 'skip' | 'overwriteAll' | 'skipAll' | 'cancel';
export interface OverwriteRequest {
srcEntry: FileEntry;
dstEntry: FileEntry;
index: number; // 1-based position within the colliding items
total: number; // number of colliding items in this batch
resolve: (choice: OverwriteChoice) => void;
}
export interface PromptRequest {
title: string;
placeholder?: string;
initial?: string;
confirmLabel: string;
onConfirm: (value: string) => void;
}
export interface ConfirmRequest {
title: string;
body: string;
confirmLabel: string;
destructive?: boolean;
onConfirm: () => void;
}
export interface ChmodRequest {
targets: string[]; // display names
initialMode: number;
onConfirm: (mode: number) => void;
}
@Injectable({ providedIn: 'root' })
export class DialogsService {
readonly prompt = signal<PromptRequest | null>(null);
readonly confirm = signal<ConfirmRequest | null>(null);
readonly chmod = signal<ChmodRequest | null>(null);
readonly overwrite = signal<OverwriteRequest | null>(null);
/** Ask what to do about one name collision; resolves with the user's choice. */
askOverwrite(request: Omit<OverwriteRequest, 'resolve'>): Promise<OverwriteChoice> {
return new Promise((resolve) => {
this.overwrite.set({ ...request, resolve });
});
}
get anyOpen(): boolean {
return !!(this.prompt() || this.confirm() || this.chmod());
}
closeAll(): void {
this.prompt.set(null);
this.confirm.set(null);
this.chmod.set(null);
}
}
+188
View File
@@ -0,0 +1,188 @@
import { Injectable, inject } from '@angular/core';
import type { FileEntry } from '@shared/types';
import { ContextMenuService } from './context-menu.service';
import { DialogsService } from './dialogs.service';
import { joinPath } from './format';
import { type PanelState, type Side } from './panels.service';
import { IS_WINDOWS } from './platform';
import { RpcService } from './rpc.service';
import { SettingsService } from './settings.service';
import { I18nService } from './i18n.service';
import { ToastService } from './toast.service';
/** The row (or empty space) the user last right-clicked; ctx-* actions apply here. */
export interface MenuTarget {
side: Side;
panel: PanelState;
entry: FileEntry | null;
}
/**
* File operations invoked from a panel's toolbar / keyboard. They act on the
* GIVEN panel: on the selection if there is one, else on the cursor row.
*/
@Injectable({ providedIn: 'root' })
export class FileActionsService {
private readonly i18n = inject(I18nService);
private readonly rpc = inject(RpcService);
private readonly toast = inject(ToastService);
private readonly dialogs = inject(DialogsService);
private readonly menu = inject(ContextMenuService);
private readonly settings = inject(SettingsService);
menuTarget: MenuTarget | null = null;
/** Right-click entry point: remember the target, then open the menu on it. */
openContextMenu(event: MouseEvent, target: MenuTarget): void {
this.menuTarget = target;
const { panel, entry } = target;
this.menu.open(
event,
this.menu.rowItems({
path: entry?.path ?? panel.cwd(),
name: entry?.name ?? '',
type: entry?.type ?? 'dir',
isLocal: panel.sessionId() === 'local',
hasEntry: !!entry,
}),
);
}
async trash(panel: PanelState, entry: FileEntry): Promise<void> {
const res = await this.rpc.request.moveToTrash({ path: entry.path });
if (!res.ok) this.toast.error(res.error);
else this.toast.info(this.i18n.t('toasts.movedToTrash', { name: entry.name }));
await panel.refresh();
}
/** Selection if non-empty, else the cursor entry. Never the ".." parent row. */
targets(panel: PanelState): FileEntry[] {
const selected = panel.selectedEntries();
if (selected.length) return selected;
const atCursor = panel.sorted()[panel.cursor()];
return atCursor && atCursor.name !== '..' ? [atCursor] : [];
}
mkdir(panel: PanelState): void {
this.dialogs.prompt.set({
title: this.i18n.t('fileActions.newFolderTitle'),
placeholder: this.i18n.t('fileActions.newFolderPlaceholder'),
confirmLabel: this.i18n.t('fileActions.createConfirm'),
onConfirm: async (name) => {
const res = await this.rpc.request.mkdir({
sessionId: panel.sessionId(),
path: joinPath(panel.cwd(), name),
});
if (!res.ok) this.toast.error(res.error);
await panel.refresh();
},
});
}
createFile(panel: PanelState): void {
this.dialogs.prompt.set({
title: this.i18n.t('fileActions.newFileTitle'),
placeholder: this.i18n.t('fileActions.newFilePlaceholder'),
confirmLabel: this.i18n.t('fileActions.createConfirm'),
onConfirm: async (name) => {
const res = await this.rpc.request.createFile({
sessionId: panel.sessionId(),
path: joinPath(panel.cwd(), name),
});
if (!res.ok) this.toast.error(res.error);
await panel.refresh();
},
});
}
/** Open the cursor file in the built-in editor or the configured external app. */
async edit(panel: PanelState): Promise<void> {
const entry = panel.sorted()[panel.cursor()];
if (!entry || entry.type !== 'file' || entry.name === '..') return;
if (this.settings.editor() === 'custom') {
const res = await this.rpc.request.editExternal({
sessionId: panel.sessionId(),
path: entry.path,
});
if (!res.ok) {
this.toast.error(res.error);
} else if (res.watching) {
this.toast.info(this.i18n.t('toasts.editingExternal', { name: entry.name }));
}
return;
}
// built-in editor: its own native window (reads the file itself)
const res = await this.rpc.request.openEditor({
sessionId: panel.sessionId(),
path: entry.path,
name: entry.name,
sessionTitle: panel.title(),
});
if (!res.ok) this.toast.error(res.error);
}
rename(panel: PanelState): void {
const entry = panel.sorted()[panel.cursor()];
if (!entry || entry.name === '..') return;
this.dialogs.prompt.set({
title: this.i18n.t('fileActions.renameTitle', { name: entry.name }),
initial: entry.name,
confirmLabel: this.i18n.t('fileActions.renameConfirm'),
onConfirm: async (name) => {
if (name === entry.name) return;
const res = await this.rpc.request.rename({
sessionId: panel.sessionId(),
oldPath: entry.path,
newPath: joinPath(panel.cwd(), name),
});
if (!res.ok) this.toast.error(res.error);
await panel.refresh();
},
});
}
delete(panel: PanelState): void {
const targets = this.targets(panel);
if (!targets.length) return;
const what =
targets.length === 1
? `"${targets[0].name}"`
: this.i18n.t('fileActions.itemsCount', { count: targets.length });
this.dialogs.confirm.set({
title: this.i18n.t('fileActions.deleteTitle'),
body: this.i18n.t('fileActions.deleteBody', { what }),
confirmLabel: this.i18n.t('fileActions.deleteConfirm'),
destructive: true,
onConfirm: async () => {
const res = await this.rpc.request.deleteEntries({
sessionId: panel.sessionId(),
entries: targets.map(({ path, type }) => ({ path, type })),
});
if (!res.ok) this.toast.error(res.error);
await panel.refresh();
},
});
}
chmod(panel: PanelState): void {
// Windows local chmod only flips the read-only bit — don't pretend
if (IS_WINDOWS && panel.sessionId() === 'local') return;
const targets = this.targets(panel);
if (!targets.length) return;
this.dialogs.chmod.set({
targets: targets.map((t) => t.name),
initialMode: targets[0].mode,
onConfirm: async (mode) => {
const res = await this.rpc.request.chmod({
sessionId: panel.sessionId(),
paths: targets.map((t) => t.path),
mode,
});
if (!res.ok) this.toast.error(res.error);
await panel.refresh();
},
});
}
}
+105
View File
@@ -0,0 +1,105 @@
// File-list glyphs + per-category colors ("Multi" icon mode). Category colors
// are fixed tokens (never follow the accent) so a folder is always folder-blue.
// Only the icon glyph is colored; row text stays neutral. Docs and unknown
// files stay muted on purpose — sparing color is what keeps it scannable.
import type { FileEntry } from '@shared/types';
type Category = 'image' | 'audio' | 'video' | 'archive' | 'code' | 'doc';
// Class strings are literals so the Tailwind scanner picks them up.
const MUTED = 'shrink-0 text-muted-foreground';
const PRIMARY = 'shrink-0 text-primary';
const FOLDER = 'shrink-0 text-icon-folder';
const LINK = 'shrink-0 text-icon-link';
const CATEGORIES: Record<Category, { glyph: string; class: string; extensions: string[] }> = {
image: {
glyph: 'mynaImage',
class: 'shrink-0 text-icon-image',
extensions: 'png jpg jpeg gif webp avif svg ico icns bmp tiff tif heic heif raw'.split(' '),
},
audio: {
glyph: 'mynaMusic',
class: 'shrink-0 text-icon-media',
extensions: 'mp3 wav flac aac ogg oga m4a opus wma aiff aif mid midi'.split(' '),
},
video: {
glyph: 'mynaVideo',
class: 'shrink-0 text-icon-media',
extensions: 'mp4 mkv mov avi webm m4v mpg mpeg wmv flv 3gp'.split(' '),
},
archive: {
glyph: 'mynaArchive',
class: 'shrink-0 text-icon-archive',
extensions: 'zip tar gz tgz bz2 xz zst 7z rar dmg iso jar deb rpm pkg apk'.split(' '),
},
code: {
glyph: 'mynaCode',
class: 'shrink-0 text-icon-code',
// ".ts" is TypeScript here, not an MPEG transport stream (deliberate)
extensions: (
'js mjs cjs ts tsx jsx json jsonc yaml yml toml xml html htm css scss sass less ' +
'py rb go rs java kt kts swift c h cpp hpp cc m mm cs php sh bash zsh fish ps1 bat ' +
'sql lua pl r vue svelte astro ini conf cfg env plist gradle cmake nix proto graphql ' +
'dart ex exs zig lock makefile dockerfile zshrc bashrc bash_profile profile ' +
'gitignore gitconfig gitattributes editorconfig npmrc nvmrc'
).split(' '),
},
doc: {
glyph: 'mynaFileText',
class: MUTED, // docs keep the neutral color; only the glyph changes
extensions: 'md markdown txt rtf pdf doc docx xls xlsx ppt pptx csv tsv log odt ods odp epub'.split(' '),
},
};
const EXT_CATEGORY = new Map<string, Category>();
for (const category of Object.keys(CATEGORIES) as Category[]) {
for (const ext of CATEGORIES[category].extensions) EXT_CATEGORY.set(ext, category);
}
/** "a.tar.gz" -> "gz"; ".zshrc" -> "zshrc"; "Makefile" (no dot) -> "makefile". */
function categoryOf(name: string): Category | null {
const dot = name.lastIndexOf('.');
const key = (dot >= 0 ? name.slice(dot + 1) : name).toLowerCase();
return EXT_CATEGORY.get(key) ?? null;
}
const KIND_KEYS: Record<Category, string> = {
image: 'image',
audio: 'audio',
video: 'video',
archive: 'archive',
code: 'code',
doc: 'document',
};
/**
* i18n key suffix for the optional Kind column (category-based, like the
* icons); '' for the synthetic '..' row. The caller resolves `kinds.<suffix>`.
*/
export function fileKindKey(entry: FileEntry): string {
if (entry.name === '..') return '';
if (entry.type === 'dir') return 'folder';
if (entry.type === 'symlink') return 'symlink';
const category = categoryOf(entry.name);
return category ? KIND_KEYS[category] : 'file';
}
export function fileGlyph(entry: FileEntry, multi: boolean): string {
if (entry.name === '..') return 'mynaArrowUp';
if (entry.type === 'dir') return 'mynaFolder';
if (!multi) return 'mynaFile';
if (entry.type === 'symlink') return 'mynaLink';
const category = categoryOf(entry.name);
return category ? CATEGORIES[category].glyph : 'mynaFile';
}
export function fileIconClass(entry: FileEntry, multi: boolean): string {
// plain mode: exactly the pre-color look ('..' has type 'dir' -> primary)
if (!multi) return entry.type === 'dir' ? PRIMARY : MUTED;
if (entry.name === '..') return MUTED;
if (entry.type === 'dir') return FOLDER;
if (entry.type === 'symlink') return LINK;
const category = categoryOf(entry.name);
return category ? CATEGORIES[category].class : MUTED;
}
+16
View File
@@ -0,0 +1,16 @@
import { Injectable, signal } from '@angular/core';
/**
* "Give keyboard focus back to the main surface" signal. Dependency-free so
* dialogs can bump it from ANY window kind — in the main window the file
* panels listen (via PanelsService.focusTick); elsewhere nobody does, which
* is exactly right.
*/
@Injectable({ providedIn: 'root' })
export class FocusService {
readonly tick = signal(0);
bump(): void {
this.tick.update((n) => n + 1);
}
}
+84
View File
@@ -0,0 +1,84 @@
const SIZE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'];
/** Locale-aware number formatting (decimal separator); cached per locale. */
const NUM_FORMATS = new Map<string, Intl.NumberFormat>();
function num(value: number, locale: string | undefined): string {
const key = locale ?? '';
let fmt = NUM_FORMATS.get(key);
if (!fmt) {
fmt = new Intl.NumberFormat(locale, { maximumFractionDigits: 1 });
NUM_FORMATS.set(key, fmt);
}
return fmt.format(value);
}
export function formatSize(bytes: number, locale?: string): string {
let value = bytes;
let unit = 0;
while (value >= 1024 && unit < SIZE_UNITS.length - 1) {
value /= 1024;
unit++;
}
return `${unit === 0 ? num(value, locale) : num(Number(value.toFixed(1)), locale)} ${SIZE_UNITS[unit]}`;
}
const DATE_FORMATS = new Map<string, Intl.DateTimeFormat>();
export function formatDate(epochMs: number, locale?: string): string {
if (!epochMs) return '';
const key = locale ?? '';
let fmt = DATE_FORMATS.get(key);
if (!fmt) {
fmt = new Intl.DateTimeFormat(locale, {
year: '2-digit',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
DATE_FORMATS.set(key, fmt);
}
return fmt.format(new Date(epochMs));
}
/** bytes/sec -> "14.2 MB/s" or "113.8 Mbps" depending on the configured unit. */
export function formatSpeed(bytesPerSec: number, unit: 'MBps' | 'Mbps', locale?: string): string {
if (unit === 'Mbps') {
return `${num(Number(((bytesPerSec * 8) / 1_000_000).toFixed(1)), locale)} Mbps`;
}
return `${formatSize(bytesPerSec, locale)}/s`;
}
/** Seconds -> "0:42" / "3:07" / "1:02:15". */
export function formatEta(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) return '';
const s = Math.round(seconds);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
const mmss = `${m}:${String(sec).padStart(2, '0')}`;
return h ? `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}` : mmss;
}
/**
* Separator-aware join for UI-composed paths: remote and POSIX-local dirs use
* "/", a Windows-local dir ("C:\Users\x") keeps "\" so paths stay consistent.
*/
export function joinPath(dir: string, name: string): string {
const sep = dir.includes('\\') && !dir.includes('/') ? '\\' : '/';
return dir.endsWith(sep) ? dir + name : `${dir}${sep}${name}`;
}
/** "/" (POSIX) or a drive root like "C:\" — nothing above to navigate to. */
export function isRootPath(p: string): boolean {
return p === '/' || /^[A-Za-z]:[\\/]?$/.test(p);
}
/** 0o755 -> "rwxr-xr-x" */
export function formatMode(mode: number): string {
const flags = 'rwxrwxrwx';
let out = '';
for (let i = 0; i < 9; i++) {
out += mode & (0o400 >> i) ? flags[i] : '-';
}
return out;
}
+61
View File
@@ -0,0 +1,61 @@
import { Injectable, computed, signal } from '@angular/core';
import { DICTS, type Messages } from '@shared/i18n';
import type { AppSettings, SupportedLocale } from '@shared/types';
import { formatDate, formatSize, formatSpeed } from './format';
type Params = Record<string, string | number>;
function lookup(dict: Messages, key: string): string | Messages | undefined {
let node: string | Messages | undefined = dict;
for (const part of key.split('.')) {
if (typeof node !== 'object') return undefined;
node = node[part];
}
return node;
}
function interpolate(template: string, params?: Params): string {
if (!params) return template;
return template.replace(/\{(\w+)\}/g, (_, name: string) =>
name in params ? String(params[name]) : `{${name}}`,
);
}
/**
* Runtime translator. `t()` reads the `locale` signal, so any template binding
* that calls it re-renders live on a language switch (the same reactive
* function-call tracking the file-list icon classes rely on). Missing keys fall
* back to English, then to the raw key so gaps are visible in dev.
*/
@Injectable({ providedIn: 'root' })
export class I18nService {
readonly locale = signal<SupportedLocale>('en');
private readonly dict = computed(() => DICTS[this.locale()]);
t(key: string, params?: Params): string {
let value = lookup(this.dict(), key) ?? lookup(DICTS.en, key);
// plural map: pick the CLDR category for params.count (fallback to English)
if (value && typeof value === 'object') {
const count = Number(params?.['count'] ?? NaN);
if (Number.isFinite(count)) {
const rule = new Intl.PluralRules(this.locale()).select(count);
const picked = value[rule] ?? value['other'] ?? lookup(DICTS.en, `${key}.other`);
value = typeof picked === 'string' ? picked : undefined;
} else {
value = undefined;
}
}
return typeof value === 'string' ? interpolate(value, params) : key;
}
// Locale-reactive formatting (live-updates the file list on switch).
date(epochMs: number): string {
return formatDate(epochMs, this.locale());
}
size(bytes: number): string {
return formatSize(bytes, this.locale());
}
speed(bytesPerSec: number, unit: AppSettings['speedUnit']): string {
return formatSpeed(bytesPerSec, unit, this.locale());
}
}
+76
View File
@@ -0,0 +1,76 @@
// Single icon registry for the whole app, registered once in app.config.ts.
// NOTE: @ng-icons/mynaui's package root is empty — always import from /outline
// (or /solid, where names get a `Solid` suffix).
import {
mynaArchive,
mynaArrowLeft,
mynaArrowLeftRight,
mynaArrowRight,
mynaArrowUp,
mynaCheck,
mynaCheckCircle,
mynaChevronDown,
mynaChevronRight,
mynaChevronUp,
mynaCode,
mynaCog,
mynaDots,
mynaDownload,
mynaEdit,
mynaFile,
mynaFilePlus,
mynaFileText,
mynaFolder,
mynaFolderPlus,
mynaHardDrive,
mynaImage,
mynaLink,
mynaLock,
mynaMusic,
mynaPlus,
mynaRefresh,
mynaSearch,
mynaServers,
mynaSpinner,
mynaTrash,
mynaVideo,
mynaX,
mynaXCircle,
} from "@ng-icons/mynaui/outline";
export const APP_ICONS = {
mynaArchive,
mynaArrowLeft,
mynaArrowLeftRight,
mynaArrowRight,
mynaArrowUp,
mynaCheck,
mynaCheckCircle,
mynaChevronDown,
mynaChevronRight,
mynaChevronUp,
mynaCode,
mynaCog,
mynaDots,
mynaDownload,
mynaEdit,
mynaFile,
mynaFilePlus,
mynaFileText,
mynaFolder,
mynaFolderPlus,
mynaHardDrive,
mynaImage,
mynaLink,
mynaLock,
mynaMusic,
mynaPlus,
mynaRefresh,
mynaSearch,
mynaServers,
mynaSpinner,
mynaTrash,
mynaVideo,
mynaX,
mynaXCircle,
};
+544
View File
@@ -0,0 +1,544 @@
import { Injectable, computed, effect, inject, signal, type WritableSignal } from '@angular/core';
import type { ColumnKey, ConnectionConfig, FileEntry, SavedConnection } from '@shared/types';
import { fileKindKey } from './file-icons';
import { FocusService } from './focus.service';
import { formatMode, isRootPath, joinPath } from './format';
import { RpcService } from './rpc.service';
import { I18nService } from './i18n.service';
import { ToastService } from './toast.service';
export type Side = 'left' | 'right';
/** Any visible column sorts; Name is always available. */
export type SortKey = 'name' | ColumnKey;
/** Display string the text-based columns sort by. */
function sortText(entry: FileEntry, key: 'kind' | 'rights' | 'owner' | 'group'): string {
if (key === 'kind') return fileKindKey(entry);
if (key === 'rights') return formatMode(entry.mode);
return (key === 'owner' ? entry.owner : entry.group) ?? '';
}
interface PanelDeps {
rpc: RpcService;
toast: ToastService;
}
export class PanelState {
readonly sessionId = signal('local');
/** Filesystem identity ("local" or resolvedIp:port) — self-copy guards use this. */
readonly fsKey = signal('local');
readonly title = signal('Local');
readonly connecting = signal(false);
/** Name shown in the "Connecting to X…" placeholder while connecting. */
readonly connectingTo = signal<string | null>(null);
readonly cwd = signal('');
readonly entries = signal<FileEntry[]>([]);
readonly loading = signal(false);
/** True once an in-flight load has taken >300ms — drives the dim overlay. */
readonly slowLoad = signal(false);
/** Bumping this discards the result of any in-flight connect (cancel/supersede). */
connectToken = 0;
/** True once the user navigated or connected — a "pristine" panel is still at local home. */
touched = false;
/** Input on this panel is ignored while it's connecting or loading. */
readonly busy = computed(() => this.connecting() || this.loading());
readonly sortKey = signal<SortKey>('name');
readonly sortAsc = signal(true);
readonly cursor = signal(0);
readonly selected = signal<ReadonlySet<string>>(new Set());
/** Type-to-filter query; entries not matching are hidden (".." always stays). */
readonly filter = signal('');
/** Directories first, then the active sort column. ".." (parent) is pinned on top. */
readonly sorted = computed(() => {
const query = this.filter().toLowerCase();
let list = [...this.entries()];
if (query) {
list = list.filter((e) => e.name.toLowerCase().includes(query));
}
const key = this.sortKey();
const dir = this.sortAsc() ? 1 : -1;
list.sort((a, b) => {
const aDir = a.type === 'dir';
const bDir = b.type === 'dir';
if (aDir !== bDir) return aDir ? -1 : 1;
let cmp = 0;
if (key === 'size') cmp = a.size - b.size;
else if (key === 'mtime') cmp = a.mtime - b.mtime;
else if (key !== 'name') cmp = sortText(a, key).localeCompare(sortText(b, key));
if (cmp === 0) {
cmp = a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
if (key === 'name') return cmp * dir;
}
return cmp * dir;
});
const cwd = this.cwd();
if (cwd && !isRootPath(cwd)) {
list.unshift({ name: '..', path: joinPath(cwd, '..'), type: 'dir', size: 0, mtime: 0, mode: 0 });
}
return list;
});
readonly selectedEntries = computed(() => {
const names = this.selected();
return this.sorted().filter((e) => names.has(e.name));
});
constructor(private deps: PanelDeps) {}
async load(path: string | null): Promise<void> {
if (path !== null) this.touched = true;
this.loading.set(true);
const slowTimer = setTimeout(() => this.slowLoad.set(true), 300);
try {
const res = await this.deps.rpc.request.listDir({ sessionId: this.sessionId(), path });
if (!res.ok) {
this.deps.toast.error(res.error);
return;
}
this.cwd.set(res.path);
this.entries.set(res.entries);
this.cursor.set(0);
this.selected.set(new Set());
this.filter.set('');
} finally {
clearTimeout(slowTimer);
this.slowLoad.set(false);
this.loading.set(false);
}
}
private refreshing = false;
/**
* Reload cwd, keeping cursor position and selection by name. `silent` (used by
* auto-refresh) skips the loading/slowLoad flags — so no spinner, no dim
* overlay, input never blocked — and touches no DOM, so scroll stays put.
*/
async refresh(opts?: { silent?: boolean }): Promise<void> {
const silent = opts?.silent ?? false;
// a silent refresh must never fight the user or overlap itself
if (silent && (this.refreshing || this.busy())) return;
this.refreshing = true;
// snapshot what we're listing; a silent refresh has no busy-block, so the
// user can navigate mid-flight — discard the result if they did
const forSession = this.sessionId();
const forDir = this.cwd();
const keepCursorName = this.sorted()[this.cursor()]?.name;
const keepSelected = this.selected();
if (!silent) this.loading.set(true);
const slowTimer = silent ? null : setTimeout(() => this.slowLoad.set(true), 300);
try {
const res = await this.deps.rpc.request.listDir({
sessionId: forSession,
path: forDir,
});
if (this.sessionId() !== forSession || this.cwd() !== forDir) return;
if (!res.ok) {
if (!silent) this.deps.toast.error(res.error);
return;
}
this.cwd.set(res.path);
this.entries.set(res.entries);
const names = new Set(res.entries.map((e) => e.name));
this.selected.set(new Set([...keepSelected].filter((n) => names.has(n))));
const idx = this.sorted().findIndex((e) => e.name === keepCursorName);
this.cursor.set(idx >= 0 ? idx : Math.min(this.cursor(), Math.max(0, this.sorted().length - 1)));
} finally {
if (slowTimer) clearTimeout(slowTimer);
if (!silent) {
this.slowLoad.set(false);
this.loading.set(false);
}
this.refreshing = false;
}
}
open(entry: FileEntry): void {
if (entry.type === 'file') return;
// symlinks: try to open as a dir; the error toast covers file links
void this.load(entry.path);
}
up(): void {
if (!this.cwd()) return;
void this.load(joinPath(this.cwd(), '..'));
}
/** Update the filter and park the cursor on the first real match. */
setFilter(query: string): void {
this.filter.set(query);
const first = this.sorted().findIndex((e) => e.name !== '..');
this.cursor.set(first >= 0 ? first : 0);
}
toggleSort(key: SortKey): void {
if (this.sortKey() === key) {
this.sortAsc.update((v) => !v);
} else {
this.sortKey.set(key);
this.sortAsc.set(true);
}
}
moveCursor(delta: number): void {
const max = this.sorted().length - 1;
if (max < 0) return;
this.cursor.set(Math.max(0, Math.min(max, this.cursor() + delta)));
}
toggleSelect(name: string): void {
if (name === '..') return;
this.selected.update((prev) => {
const next = new Set(prev);
if (next.has(name)) next.delete(name);
else next.add(name);
return next;
});
}
selectRange(fromIndex: number, toIndex: number): void {
const [lo, hi] = fromIndex <= toIndex ? [fromIndex, toIndex] : [toIndex, fromIndex];
const names = this.sorted()
.slice(lo, hi + 1)
.map((e) => e.name)
.filter((n) => n !== '..');
this.selected.update((prev) => new Set([...prev, ...names]));
}
selectAll(): void {
this.selected.set(new Set(this.sorted().map((e) => e.name).filter((n) => n !== '..')));
}
clearSelection(): void {
if (this.selected().size) this.selected.set(new Set());
}
}
/** One tab: an independent left/right panel pair. */
export interface Workspace {
id: number;
left: PanelState;
right: PanelState;
activeSide: WritableSignal<Side>;
}
@Injectable({ providedIn: 'root' })
export class PanelsService {
private readonly i18n = inject(I18nService);
readonly workspaces = signal<Workspace[]>([]);
readonly activeWorkspaceId = signal(0);
readonly activeWorkspace = computed(() => {
const list = this.workspaces();
return list.find((w) => w.id === this.activeWorkspaceId()) ?? list[0];
});
private readonly focus = inject(FocusService);
/** Bumped to tell the active panel to grab keyboard focus again (e.g. after a dialog closes). */
readonly focusTick = this.focus.tick;
private nextWorkspaceId = 1;
constructor(
private rpc: RpcService,
private toast: ToastService,
) {
this.newWorkspace();
// a session died on its own — reset every panel bound to it, in any tab
this.rpc.onSessionClosed((event) => {
for (const workspace of this.workspaces()) {
for (const panel of [workspace.left, workspace.right]) {
if (panel.sessionId() === event.sessionId) {
this.toast.error(
this.i18n.t('toasts.disconnectedReason', {
title: panel.title(),
reason: event.reason,
}),
);
this.resetToLocal(panel);
}
}
}
});
// a watched local dir changed, or an editor saved a file — silently
// refresh every panel showing that directory (no spinner, view preserved)
this.rpc.onPathRefresh(({ sessionId, dir }) => {
for (const panel of this.allPanels()) {
if (panel.sessionId() === sessionId && panel.cwd() === dir) {
void panel.refresh({ silent: true });
}
}
});
this.setupAutoRefresh();
}
/**
* Auto-refresh the active tab's panels without disturbing the view. Local
* panels are watched instantly via fs.watch (backend pushes pathRefresh);
* remote panels have no change notification, so they're polled. Only the
* active tab is watched/polled; switching tabs refreshes the new one at once.
*/
private setupAutoRefresh(): void {
// REMOTE_POLL_MS: also the cadence a future "auto-refresh interval" setting
// would drive (see the commented breadcrumbs in settings).
const REMOTE_POLL_MS = 3000;
setInterval(() => {
const ws = this.activeWorkspace();
if (!ws) return;
for (const panel of [ws.left, ws.right]) {
if (panel.sessionId() !== 'local') void panel.refresh({ silent: true });
}
}, REMOTE_POLL_MS);
// register the active tab's LOCAL dirs for fs.watch; re-runs when the active
// tab changes or either panel navigates/(dis)connects
effect(() => {
const ws = this.activeWorkspace();
const dirs = ws
? [ws.left, ws.right]
.filter((p) => p.sessionId() === 'local' && p.cwd())
.map((p) => p.cwd())
: [];
void this.rpc.request.setWatchedDirs({ dirs: [...new Set(dirs)] });
});
// switching tabs shows fresh data immediately instead of waiting a poll tick
let lastActive = this.activeWorkspaceId();
effect(() => {
const id = this.activeWorkspaceId();
if (id === lastActive) return;
lastActive = id;
const ws = this.activeWorkspace();
if (ws) for (const panel of [ws.left, ws.right]) void panel.refresh({ silent: true });
});
}
// ---- panels of the active workspace ----
get(side: Side): PanelState {
const workspace = this.activeWorkspace();
return side === 'left' ? workspace.left : workspace.right;
}
activeSide(): Side {
return this.activeWorkspace().activeSide();
}
setActiveSide(side: Side): void {
this.activeWorkspace().activeSide.set(side);
}
active(): PanelState {
return this.get(this.activeSide());
}
otherSide(side: Side): Side {
return side === 'left' ? 'right' : 'left';
}
other(side: Side): PanelState {
return this.get(this.otherSide(side));
}
/** Every panel across every workspace (for session-wide updates). */
allPanels(): PanelState[] {
return this.workspaces().flatMap((w) => [w.left, w.right]);
}
refocus(): void {
this.focus.bump();
}
// ---- workspace (tab) management ----
newWorkspace(): void {
const deps = { rpc: this.rpc, toast: this.toast };
const workspace: Workspace = {
id: this.nextWorkspaceId++,
left: new PanelState(deps),
right: new PanelState(deps),
activeSide: signal<Side>('left'),
};
this.workspaces.update((list) => [...list, workspace]);
this.activeWorkspaceId.set(workspace.id);
if (this.rpc.available) {
// may be called during template rendering, where signal writes inside
// load() are forbidden — defer past the render pass
queueMicrotask(() => {
void workspace.left.load(null);
void workspace.right.load(null);
});
}
this.refocus();
}
/** Close a tab; its remote sessions are disconnected. The last tab stays. */
closeWorkspace(id: number): void {
const list = this.workspaces();
if (list.length <= 1) return;
const index = list.findIndex((w) => w.id === id);
if (index < 0) return;
const workspace = list[index];
for (const panel of [workspace.left, workspace.right]) {
const sessionId = panel.sessionId();
if (sessionId !== 'local') {
void this.rpc.request.disconnect({ sessionId });
}
}
this.workspaces.update((l) => l.filter((w) => w.id !== id));
if (this.activeWorkspaceId() === id) {
const neighbor = list[index + 1] ?? list[index - 1];
this.activeWorkspaceId.set(neighbor.id);
}
this.refocus();
}
setActiveWorkspace(id: number): void {
if (this.activeWorkspaceId() !== id) {
this.activeWorkspaceId.set(id);
this.refocus();
}
}
/**
* Open a stack: one tab per connection, left panel connecting (right stays
* local, jumping to the connection's localDir when set). The first
* connection reuses the current tab if it's pristine (local/local, never
* navigated) instead of leaving a useless Local tab behind.
*/
openStack(connections: SavedConnection[]): void {
for (const [index, connection] of connections.entries()) {
if (index > 0 || !this.workspaceIsPristine(this.activeWorkspace())) {
this.newWorkspace();
}
// connectPanel resolves the panel synchronously (before awaiting), so
// firing these back-to-back binds each connection to its own tab.
// Remote goes right, local stays left — the commander convention.
void this.connectPanel('right', connection, connection.name, connection.localDir);
}
}
private workspaceIsPristine(workspace: Workspace): boolean {
return [workspace.left, workspace.right].every(
(p) => p.sessionId() === 'local' && !p.touched && !p.connecting(),
);
}
/** Reorder a tab (drag & drop). Order is purely visual; ids stay stable. */
moveWorkspace(id: number, toIndex: number): void {
this.workspaces.update((list) => {
const fromIndex = list.findIndex((w) => w.id === id);
if (fromIndex < 0) return list;
const next = [...list];
const [moved] = next.splice(fromIndex, 1);
next.splice(Math.max(0, Math.min(next.length, toIndex)), 0, moved);
return next;
});
}
cycleWorkspace(delta: 1 | -1): void {
const list = this.workspaces();
if (list.length < 2) return;
const index = list.findIndex((w) => w.id === this.activeWorkspaceId());
const next = list[(index + delta + list.length) % list.length];
this.setActiveWorkspace(next.id);
}
/** Jump to a tab by visual position; negative counts from the end (⌘9 = last). */
goToWorkspace(index: number): void {
const target = this.workspaces().at(index);
if (target) this.setActiveWorkspace(target.id);
}
/** Tab label: "Local" for a local/local pair, else the remote titles. */
workspaceName(workspace: Workspace): string {
const remoteTitles = [workspace.left, workspace.right]
.filter((p) => p.sessionId() !== 'local')
.map((p) => p.title());
return remoteTitles.length ? remoteTitles.join(' ⇄ ') : this.i18n.t('common.local');
}
workspaceIsRemote(workspace: Workspace): boolean {
return workspace.left.sessionId() !== 'local' || workspace.right.sessionId() !== 'local';
}
// ---- session binding ----
/**
* Connect a panel to a server; replaces (and disconnects) its previous remote
* session. When the connection has a localDir and the workspace's other panel
* is local and untouched, that panel jumps to the localDir.
*/
async connectPanel(
side: Side,
config: ConnectionConfig,
title: string,
localDir?: string | null,
): Promise<boolean> {
const workspace = this.activeWorkspace();
const panel = workspace[side];
const token = ++panel.connectToken;
panel.connecting.set(true);
panel.connectingTo.set(title);
try {
const res = await this.rpc.request.connect({ config });
if (panel.connectToken !== token) {
// cancelled or superseded while waiting — clean up the orphan session
if (res.ok) void this.rpc.request.disconnect({ sessionId: res.sessionId });
return false;
}
if (!res.ok) {
this.toast.error(`${title}: ${res.error}`);
return false;
}
const previous = panel.sessionId();
if (previous !== 'local') {
void this.rpc.request.disconnect({ sessionId: previous });
}
panel.sessionId.set(res.sessionId);
panel.fsKey.set(res.fsKey);
panel.title.set(title);
panel.touched = true;
const other = workspace[side === 'left' ? 'right' : 'left'];
if (localDir && other.sessionId() === 'local' && !other.touched) {
void other.load(localDir);
}
await panel.load(config.initialPath ?? res.homeDir);
return true;
} finally {
if (panel.connectToken === token) {
panel.connecting.set(false);
panel.connectingTo.set(null);
}
}
}
/** Abort waiting on an in-flight connect; the panel keeps its previous state. */
cancelConnect(side: Side): void {
const panel = this.get(side);
if (!panel.connecting()) return;
panel.connectToken++;
panel.connecting.set(false);
panel.connectingTo.set(null);
}
async disconnectPanel(side: Side): Promise<void> {
const panel = this.get(side);
const sessionId = panel.sessionId();
if (sessionId === 'local') return;
await this.rpc.request.disconnect({ sessionId });
this.resetToLocal(panel);
}
private resetToLocal(panel: PanelState): void {
panel.sessionId.set('local');
panel.fsKey.set('local');
panel.title.set('Local');
void panel.load(null);
}
}
+44
View File
@@ -0,0 +1,44 @@
// Injected by the bun process via BrowserWindow preload (src/bun/windows.ts
// buildPreload) before any page script. Brand-neutral names so rebrands never
// touch this file.
import type { EditorOpenContext } from '@shared/rpc';
declare global {
interface Window {
__APP_PLATFORM?: string;
/** Which root component this window boots. */
__APP_MODE?: 'main' | 'editor' | 'manager';
/** Window-kind specific context (EditorOpenContext for editor windows). */
__APP_CTX?: unknown;
}
}
export const PLATFORM = typeof window !== 'undefined' ? (window.__APP_PLATFORM ?? '') : '';
export const IS_MAC = PLATFORM === 'darwin';
export const IS_WINDOWS = PLATFORM === 'win32';
export const IS_LINUX = PLATFORM === 'linux';
/** Shortcut-hint labels: "⌘W" on macOS, "Ctrl+W" elsewhere. Keyed on the real
* platform — the keyboard does not change with the window chrome. */
export const MOD_LABEL = IS_MAC ? '⌘' : 'Ctrl+';
export const ALT_LABEL = IS_MAC ? '⌥' : 'Alt+';
export const APP_MODE: 'main' | 'editor' | 'manager' =
typeof window !== 'undefined' ? (window.__APP_MODE ?? 'main') : 'main';
export const EDITOR_CONTEXT =
typeof window !== 'undefined' ? (window.__APP_CTX as EditorOpenContext | undefined) : undefined;
/**
* Kill the webview's default right-click menu (Reload, Inspect Element…)
* everywhere the app doesn't show its own native menu. Text fields keep the
* system text menu (cut/copy/paste, spelling).
*/
export function blockNativeContextMenu(event: Event): void {
const target = event.target as HTMLElement | null;
if (target?.closest('input, textarea, [contenteditable="true"]')) return;
event.preventDefault();
}
+180
View File
@@ -0,0 +1,180 @@
import { Injectable } from '@angular/core';
import { Electroview } from 'electrobun/view';
import type { AppRPC } from '@shared/rpc';
import type { AppSettings, TransferSnapshot } from '@shared/types';
export type UpdateStatus = {
phase: 'available' | 'downloading' | 'error';
version?: string;
progress?: number;
error?: string;
};
// Matches MAX_REQUEST_TIME_MS on the bun side — SFTP ops can be slow.
const MAX_REQUEST_TIME_MS = 10 * 60_000;
export type SessionClosedEvent = { sessionId: string; reason: string };
@Injectable({ providedIn: 'root' })
export class RpcService {
/** False when the page is opened in a plain browser instead of the app. */
readonly available: boolean;
// Push messages are delivered through plain callbacks, NOT signals: a signal
// consumed from an effect that also writes signals re-triggers itself
// (this froze the app), and signal coalescing would drop progress snapshots.
private transferListeners = new Set<(s: TransferSnapshot) => void>();
private closedListeners = new Set<(e: SessionClosedEvent) => void>();
private menuListeners = new Set<(action: string) => void>();
private settingsListeners = new Set<(settings: AppSettings) => void>();
private pathRefreshListeners = new Set<(e: { sessionId: string; dir: string }) => void>();
private editorCloseListeners = new Set<() => void>();
private managerStateListeners = new Set<(open: boolean) => void>();
private connectionsListeners = new Set<() => void>();
private updateStatusListeners = new Set<(status: UpdateStatus) => void>();
private windowStateListeners = new Set<(maximized: boolean) => void>();
private rpc: ReturnType<typeof Electroview.defineRPC<AppRPC>> | undefined;
private ready: Promise<void> = Promise.resolve();
constructor() {
this.available = typeof window !== 'undefined' && '__electrobun' in window;
if (!this.available) return;
this.rpc = Electroview.defineRPC<AppRPC>({
maxRequestTime: MAX_REQUEST_TIME_MS,
handlers: {
messages: {
transferUpdate: (snapshot) => {
for (const listener of this.transferListeners) listener(snapshot);
},
sessionClosed: (event) => {
for (const listener of this.closedListeners) listener(event);
},
menuAction: ({ action }) => {
for (const listener of this.menuListeners) listener(action);
},
settingsChanged: ({ settings }) => {
for (const listener of this.settingsListeners) listener(settings);
},
pathRefresh: (event) => {
for (const listener of this.pathRefreshListeners) listener(event);
},
editorCloseRequest: () => {
for (const listener of this.editorCloseListeners) listener();
},
managerState: ({ open }) => {
for (const listener of this.managerStateListeners) listener(open);
},
connectionsChanged: () => {
for (const listener of this.connectionsListeners) listener();
},
updateStatus: (status) => {
for (const listener of this.updateStatusListeners) listener(status);
},
windowState: ({ maximized }) => {
for (const listener of this.windowStateListeners) listener(maximized);
},
},
},
});
const electroview = new Electroview({ rpc: this.rpc });
// Requests fired before the RPC WebSocket finishes connecting are lost, so
// everything below gates on it being open (services fire calls at startup).
const socket = electroview.bunSocket;
if (socket && socket.readyState !== WebSocket.OPEN) {
this.ready = new Promise((resolve) => {
socket.addEventListener('open', () => resolve(), { once: true });
socket.addEventListener('error', () => resolve(), { once: true });
});
}
}
onTransferUpdate(listener: (snapshot: TransferSnapshot) => void): void {
this.transferListeners.add(listener);
}
onSessionClosed(listener: (event: SessionClosedEvent) => void): void {
this.closedListeners.add(listener);
}
onMenuAction(listener: (action: string) => void): void {
this.menuListeners.add(listener);
}
/**
* Dispatch an action that originated in the UI (the HTML context menu) as if
* it had arrived from bun, so app.ts keeps ONE handler for menu actions
* regardless of whether the app menubar or a right-click produced them.
*/
emitMenuAction(action: string): void {
for (const listener of this.menuListeners) listener(action);
}
onSettingsChanged(listener: (settings: AppSettings) => void): void {
this.settingsListeners.add(listener);
}
onPathRefresh(listener: (event: { sessionId: string; dir: string }) => void): void {
this.pathRefreshListeners.add(listener);
}
onEditorCloseRequest(listener: () => void): void {
this.editorCloseListeners.add(listener);
}
onManagerState(listener: (open: boolean) => void): void {
this.managerStateListeners.add(listener);
}
onConnectionsChanged(listener: () => void): void {
this.connectionsListeners.add(listener);
}
onUpdateStatus(listener: (status: UpdateStatus) => void): void {
this.updateStatusListeners.add(listener);
}
/** win7 skin: the frameless window's maximize state changed (derived from
* the native resize event, so Aero Snap and Win+Up land here too). */
onWindowState(listener: (maximized: boolean) => void): void {
this.windowStateListeners.add(listener);
}
/** Typed request proxy: `await rpc.request.listDir({...})`. Waits for the bridge. */
get request() {
const rpc = this.rpc;
if (!rpc) throw new Error('RPC is only available inside the desktop app');
const ready = this.ready;
return new Proxy({} as (typeof rpc)['request'], {
get:
(_target, method: string) =>
async (params?: unknown) => {
await ready;
const methods = rpc.request as unknown as Record<
string,
(p?: unknown) => Promise<unknown>
>;
return methods[method](params);
},
});
}
/** Fire-and-forget message proxy: `rpc.send.editorState({...})`. Waits for the bridge. */
get send() {
const rpc = this.rpc;
if (!rpc) throw new Error('RPC is only available inside the desktop app');
const ready = this.ready;
return new Proxy({} as (typeof rpc)['send'], {
get:
(_target, method: string) =>
(payload?: unknown) => {
void ready.then(() => {
const methods = rpc.send as unknown as Record<string, (p?: unknown) => void>;
methods[method](payload);
});
},
});
}
}
+206
View File
@@ -0,0 +1,206 @@
import { Injectable, computed, inject, signal } from '@angular/core';
import { resolveLocale } from '@shared/i18n';
import { DEFAULT_SETTINGS, type AppSettings, type ColumnKey } from '@shared/types';
import { I18nService } from './i18n.service';
import { RpcService } from './rpc.service';
@Injectable({ providedIn: 'root' })
export class SettingsService {
readonly locale = signal<AppSettings['locale']>(DEFAULT_SETTINGS.locale);
readonly theme = signal<AppSettings['theme']>(DEFAULT_SETTINGS.theme);
readonly darkTone = signal<AppSettings['darkTone']>(DEFAULT_SETTINGS.darkTone);
readonly accent = signal<AppSettings['accent']>(DEFAULT_SETTINGS.accent);
readonly iconColors = signal<AppSettings['iconColors']>(DEFAULT_SETTINGS.iconColors);
readonly columns = signal<ColumnKey[]>(DEFAULT_SETTINGS.columns);
readonly speedUnit = signal<AppSettings['speedUnit']>(DEFAULT_SETTINGS.speedUnit);
readonly textSize = signal<AppSettings['textSize']>(DEFAULT_SETTINGS.textSize);
readonly transferMode = signal<AppSettings['transferMode']>(DEFAULT_SETTINGS.transferMode);
readonly transferConcurrency = signal(DEFAULT_SETTINGS.transferConcurrency);
readonly autoClearCompleted = signal(DEFAULT_SETTINGS.autoClearCompleted);
readonly editor = signal<AppSettings['editor']>(DEFAULT_SETTINGS.editor);
readonly editorApp = signal(DEFAULT_SETTINGS.editorApp);
// Auto-refresh is always on for now (see panels.service). To make it
// user-controllable: uncomment the field in types.ts, then here add
// readonly autoRefresh = signal(DEFAULT_SETTINGS.autoRefresh);
// load it in the constructor, add setAutoRefresh(), include it in settings(),
// gate the poll/watch in panels.service on it, and add the tab-bar row below.
private readonly rpc = inject(RpcService);
private readonly i18n = inject(I18nService);
/** Whether the app is actually rendering dark right now (drives the Tone row). */
readonly effectiveDark = computed(
() => this.theme() === 'dark' || (this.theme() === 'system' && this.systemDark()),
);
private readonly systemDark = signal(window.matchMedia('(prefers-color-scheme: dark)').matches);
constructor() {
window
.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', (e) => this.systemDark.set(e.matches));
// the default accent is not the CSS default — apply before first paint
this.applyAccent();
if (!this.rpc.available) return;
void this.rpc.request.getSettings({}).then((res) => {
if (res.ok) this.applyAll(res.settings);
});
// editor windows: follow settings the main window saves, live
this.rpc.onSettingsChanged((settings) => this.applyAll(settings));
}
private applyAll(settings: AppSettings): void {
this.locale.set(settings.locale);
this.theme.set(settings.theme);
this.darkTone.set(settings.darkTone);
this.accent.set(settings.accent);
this.iconColors.set(settings.iconColors);
this.columns.set(settings.columns);
this.speedUnit.set(settings.speedUnit);
this.textSize.set(settings.textSize);
this.transferMode.set(settings.transferMode);
this.transferConcurrency.set(settings.transferConcurrency);
this.autoClearCompleted.set(settings.autoClearCompleted);
this.editor.set(settings.editor);
this.editorApp.set(settings.editorApp);
this.applyTheme();
this.applyDarkTone();
this.applyAccent();
this.applyTextSize();
this.applyLocale();
}
setLocale(locale: AppSettings['locale']): void {
this.locale.set(locale);
this.applyLocale();
void this.persist();
}
setTheme(theme: AppSettings['theme']): void {
this.theme.set(theme);
this.applyTheme();
void this.persist();
}
setDarkTone(tone: AppSettings['darkTone']): void {
this.darkTone.set(tone);
this.applyDarkTone();
void this.persist();
}
setAccent(tone: AppSettings['accent']): void {
this.accent.set(tone);
this.applyAccent();
void this.persist();
}
setIconColors(mode: AppSettings['iconColors']): void {
// no apply step — the file panels read the signal reactively
this.iconColors.set(mode);
void this.persist();
}
toggleColumn(key: ColumnKey): void {
this.columns.update((list) =>
list.includes(key) ? list.filter((c) => c !== key) : [...list, key],
);
void this.persist();
}
setSpeedUnit(unit: AppSettings['speedUnit']): void {
this.speedUnit.set(unit);
void this.persist();
}
setTextSize(size: AppSettings['textSize']): void {
this.textSize.set(size);
this.applyTextSize();
void this.persist();
}
setTransferMode(mode: AppSettings['transferMode']): void {
this.transferMode.set(mode);
void this.persist();
}
setTransferConcurrency(count: number): void {
this.transferConcurrency.set(count);
void this.persist();
}
setAutoClearCompleted(auto: boolean): void {
this.autoClearCompleted.set(auto);
void this.persist();
}
setEditor(editor: AppSettings['editor']): void {
this.editor.set(editor);
void this.persist();
}
setEditorApp(path: string): void {
this.editorApp.set(path);
void this.persist();
}
private applyTheme(): void {
const theme = this.theme();
if (theme === 'system') {
delete document.documentElement.dataset['theme'];
} else {
document.documentElement.dataset['theme'] = theme;
}
}
private applyDarkTone(): void {
const tone = this.darkTone();
if (tone === 'dark') {
delete document.documentElement.dataset['darkTone'];
} else {
document.documentElement.dataset['darkTone'] = tone;
}
}
private applyAccent(): void {
const tone = this.accent();
if (tone === 'graphite') {
delete document.documentElement.dataset['accent'];
} else {
document.documentElement.dataset['accent'] = tone;
}
}
private applyTextSize(): void {
const size = this.textSize();
if (size === 'default') {
delete document.documentElement.dataset['text'];
} else {
document.documentElement.dataset['text'] = size;
}
}
private applyLocale(): void {
this.i18n.locale.set(resolveLocale(this.locale(), navigator.language));
}
protected settings(): AppSettings {
return {
locale: this.locale(),
theme: this.theme(),
darkTone: this.darkTone(),
accent: this.accent(),
iconColors: this.iconColors(),
columns: this.columns(),
speedUnit: this.speedUnit(),
textSize: this.textSize(),
transferMode: this.transferMode(),
transferConcurrency: this.transferConcurrency(),
autoClearCompleted: this.autoClearCompleted(),
editor: this.editor(),
editorApp: this.editorApp(),
};
}
private async persist(): Promise<void> {
await this.rpc.request.saveSettings({ settings: this.settings() });
}
}
+63
View File
@@ -0,0 +1,63 @@
import { Injectable, signal } from '@angular/core';
export interface Toast {
id: number;
kind: 'error' | 'info' | 'success';
text: string;
ttlMs: number;
}
@Injectable({ providedIn: 'root' })
export class ToastService {
readonly toasts = signal<Toast[]>([]);
private nextId = 1;
private readonly timers = new Map<
number,
{ timer: ReturnType<typeof setTimeout>; expiresAt: number; remaining: number }
>();
error(text: string) {
this.show('error', text, 6000);
}
info(text: string) {
this.show('info', text, 3000);
}
success(text: string) {
this.show('success', text, 3000);
}
dismiss(id: number) {
const entry = this.timers.get(id);
if (entry) clearTimeout(entry.timer);
this.timers.delete(id);
this.toasts.update((list) => list.filter((t) => t.id !== id));
}
/** Freeze the auto-dismiss countdown (hover). */
pause(id: number) {
const entry = this.timers.get(id);
if (!entry) return;
clearTimeout(entry.timer);
entry.remaining = Math.max(0, entry.expiresAt - Date.now());
}
/** Resume the countdown with whatever time was left when paused. */
resume(id: number) {
const entry = this.timers.get(id);
if (!entry) return;
entry.expiresAt = Date.now() + entry.remaining;
entry.timer = setTimeout(() => this.dismiss(id), entry.remaining);
}
private show(kind: Toast['kind'], text: string, ttlMs: number) {
const toast = { id: this.nextId++, kind, text, ttlMs };
this.toasts.update((list) => [...list, toast]);
this.timers.set(toast.id, {
timer: setTimeout(() => this.dismiss(toast.id), ttlMs),
expiresAt: Date.now() + ttlMs,
remaining: ttlMs,
});
}
}
+221
View File
@@ -0,0 +1,221 @@
import { Injectable, computed, inject, signal } from '@angular/core';
import type { FileEntry, TransferSnapshot } from '@shared/types';
import { DialogsService } from './dialogs.service';
import { PanelsService, type Side } from './panels.service';
import { RpcService } from './rpc.service';
import { SettingsService } from './settings.service';
import { I18nService } from './i18n.service';
import { ToastService } from './toast.service';
const AUTO_DISMISS_COMPLETED_MS = 5000;
const ACTIVE_STATES = ['queued', 'scanning', 'running'];
// EMA smoothing for the displayed transfer speed
const SPEED_SMOOTHING = 0.7;
@Injectable({ providedIn: 'root' })
export class TransfersService {
private readonly i18n = inject(I18nService);
private readonly rpc = inject(RpcService);
private readonly panels = inject(PanelsService);
private readonly toast = inject(ToastService);
private readonly dialogs = inject(DialogsService);
private readonly settings = inject(SettingsService);
private readonly jobMap = signal<ReadonlyMap<string, TransferSnapshot>>(new Map());
/** Smoothed bytes/sec per running job (UI-computed from progress deltas). */
readonly speeds = signal<ReadonlyMap<string, number>>(new Map());
private speedTrack = new Map<string, { bytes: number; time: number; speed: number }>();
readonly jobs = computed(() => [...this.jobMap().values()]);
readonly hasJobs = computed(() => this.jobMap().size > 0);
readonly activeJobs = computed(() => this.jobs().filter((j) => ACTIVE_STATES.includes(j.state)));
readonly currentJob = computed(
() => this.jobs().find((j) => j.state === 'running' || j.state === 'scanning') ?? null,
);
readonly queuedCount = computed(() => this.jobs().filter((j) => j.state === 'queued').length);
readonly failedJobs = computed(() => this.jobs().filter((j) => j.state === 'failed'));
readonly finishedJobs = computed(() => this.jobs().filter((j) => !ACTIVE_STATES.includes(j.state)));
/** Whether the detailed per-job list is shown (toggled from the status bar). */
readonly expanded = signal(false);
constructor() {
if (this.rpc.available) {
this.rpc.onTransferUpdate((snapshot) => this.apply(snapshot));
}
}
/** Copy the given panel's targets into the other panel's cwd (F5 / toolbar). */
async copyToOtherPanel(fromSide: Side): Promise<void> {
const src = this.panels.get(fromSide);
const dst = this.panels.other(fromSide);
const selected = src.selectedEntries();
const atCursor = src.sorted()[src.cursor()];
const targets = (selected.length ? selected : atCursor ? [atCursor] : []).filter(
(e) => e.type !== 'symlink' && e.name !== '..',
);
if (!targets.length) {
this.toast.info(this.i18n.t('toasts.nothingToCopy'));
return;
}
const sameFs = src.fsKey() === dst.fsKey();
if (sameFs && src.cwd() === dst.cwd()) {
this.toast.info(this.i18n.t('toasts.sameFolder'));
return;
}
const finalTargets = await this.resolveCollisions(targets, dst.entries(), sameFs);
if (finalTargets === null) return; // user cancelled
if (!finalTargets.length) {
this.toast.info(this.i18n.t('toasts.nothingToCopyAllSkipped'));
return;
}
const res = await this.rpc.request.enqueueTransfers({
srcSessionId: src.sessionId(),
items: finalTargets.map(({ path, type }) => ({ path, type })),
dstSessionId: dst.sessionId(),
dstDir: dst.cwd(),
});
if (!res.ok) this.toast.error(res.error);
}
/**
* Confirm name collisions with the destination listing one by one.
* Returns the approved target list, or null if the user cancelled.
*/
private async resolveCollisions(
targets: FileEntry[],
dstEntries: FileEntry[],
sameFs: boolean,
): Promise<FileEntry[] | null> {
const dstByName = new Map(dstEntries.map((e) => [e.name, e]));
const collisions = targets.filter((t) => {
const dst = dstByName.get(t.name);
return dst && (dst.type === 'dir') === (t.type === 'dir');
});
let mode: 'ask' | 'all' | 'none' = 'ask';
let asked = 0;
const finalTargets: FileEntry[] = [];
for (const target of targets) {
const dstEntry = dstByName.get(target.name);
if (!dstEntry) {
finalTargets.push(target);
continue;
}
if (sameFs && dstEntry.path === target.path) {
// replacing a file with ITSELF would only destroy it — never offer that
this.toast.info(this.i18n.t('toasts.sameFile', { name: target.name }));
continue;
}
if ((dstEntry.type === 'dir') !== (target.type === 'dir')) {
this.toast.info(this.i18n.t('toasts.typeMismatch', { name: target.name }));
continue;
}
if (mode === 'all') {
finalTargets.push(target);
continue;
}
if (mode === 'none') continue;
const choice = await this.dialogs.askOverwrite({
srcEntry: target,
dstEntry,
index: ++asked,
total: collisions.length,
});
if (choice === 'cancel') return null;
if (choice === 'overwriteAll') {
mode = 'all';
finalTargets.push(target);
} else if (choice === 'skipAll') {
mode = 'none';
} else if (choice === 'overwrite') {
finalTargets.push(target);
}
}
return finalTargets;
}
async cancel(jobId: string): Promise<void> {
await this.rpc.request.cancelTransfer({ jobId });
}
/** Remove every finished (completed/failed/cancelled) entry; running ones stay. */
clearFinished(): void {
for (const job of this.finishedJobs()) {
void this.dismiss(job.id);
}
}
async dismiss(jobId: string): Promise<void> {
this.jobMap.update((prev) => {
const next = new Map(prev);
next.delete(jobId);
return next;
});
this.speedTrack.delete(jobId);
this.speeds.update((prev) => {
const next = new Map(prev);
next.delete(jobId);
return next;
});
await this.rpc.request.dismissTransfer({ jobId });
}
private apply(snapshot: TransferSnapshot): void {
const previous = this.jobMap().get(snapshot.id);
this.jobMap.update((prev) => new Map(prev).set(snapshot.id, snapshot));
this.trackSpeed(snapshot);
const justFinished = previous?.state !== snapshot.state;
if (!justFinished) return;
if (snapshot.state === 'completed') {
this.refreshPanelsOn(snapshot.dstSessionId);
if (this.settings.autoClearCompleted()) {
setTimeout(() => void this.dismiss(snapshot.id), AUTO_DISMISS_COMPLETED_MS);
}
} else if (snapshot.state === 'failed') {
this.expanded.set(true);
this.toast.error(
this.i18n.t('toasts.transferFailed', {
name: snapshot.name,
error: snapshot.error ?? this.i18n.t('toasts.unknownError'),
}),
);
this.refreshPanelsOn(snapshot.dstSessionId);
} else if (snapshot.state === 'cancelled') {
this.refreshPanelsOn(snapshot.dstSessionId);
}
}
private refreshPanelsOn(sessionId: string): void {
for (const panel of this.panels.allPanels()) {
if (panel.sessionId() === sessionId) void panel.refresh();
}
}
private trackSpeed(snapshot: TransferSnapshot): void {
if (snapshot.state !== 'running') {
if (snapshot.state !== 'queued' && snapshot.state !== 'scanning') {
this.speedTrack.delete(snapshot.id);
}
return;
}
const now = performance.now();
const prev = this.speedTrack.get(snapshot.id);
if (!prev) {
this.speedTrack.set(snapshot.id, { bytes: snapshot.doneBytes, time: now, speed: 0 });
return;
}
const dt = (now - prev.time) / 1000;
if (dt < 0.2) return; // wait for a meaningful window
const instant = Math.max(0, (snapshot.doneBytes - prev.bytes) / dt);
const speed = prev.speed ? SPEED_SMOOTHING * prev.speed + (1 - SPEED_SMOOTHING) * instant : instant;
this.speedTrack.set(snapshot.id, { bytes: snapshot.doneBytes, time: now, speed });
this.speeds.update((map) => new Map(map).set(snapshot.id, speed));
}
}
+57
View File
@@ -0,0 +1,57 @@
import { Injectable, inject, signal } from '@angular/core';
import { RpcService } from './rpc.service';
export type UpdaterState = 'idle' | 'available' | 'downloading' | 'ready' | 'error';
/**
* Drives the tab-bar update pill. The backend pushes updateStatus events
* (available / downloading progress / error); "ready" is derived locally when
* downloadUpdate resolves ok. Two-step UX: available -> download -> ready ->
* apply (relaunch).
*/
@Injectable({ providedIn: 'root' })
export class UpdaterService {
private rpc = inject(RpcService);
readonly state = signal<UpdaterState>('idle');
readonly version = signal('');
readonly progress = signal(0);
constructor() {
if (!this.rpc.available) return;
this.rpc.onUpdateStatus((s) => {
if (s.phase === 'available') {
this.version.set(s.version ?? '');
this.state.set('available');
} else if (s.phase === 'downloading') {
this.state.set('downloading');
this.progress.set(s.progress ?? 0);
} else if (s.phase === 'error') {
this.state.set('error');
}
});
}
async download(): Promise<void> {
if (this.state() !== 'available') return;
this.state.set('downloading');
this.progress.set(0);
const r = await this.rpc.request.downloadUpdate({});
this.state.set(r?.ok ? 'ready' : 'error');
}
apply(): void {
if (this.state() !== 'ready') return;
void this.rpc.request.applyUpdate({}); // quits + relaunches
}
/** Manual re-check (menu item). */
async check(): Promise<void> {
if (!this.rpc.available) return;
const r = await this.rpc.request.checkForUpdate({});
if (r?.ok && r.updateAvailable) {
this.version.set(r.version);
this.state.set('available');
}
}
}