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:
@@ -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' }),
|
||||
],
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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() });
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user