- AppTray: template menu-bar icon (22pt, adapts to light/dark). While transfers run it swaps to pre-rendered frames — faded arrows + a fill bar showing aggregate doneBytes/totalBytes of the queue (nearest 5%, throttled 500ms); back to the glyph when idle. No runtime image encoding. - Menu: one entry per tab (checkmark on the active one) with live per-tab transfer count and percentage (jobs mapped via session ids), an overall progress row while transferring, Show <app>, Quit. Selecting a tab activates the window and switches to it. - First use of webview->bun messages: PanelsService pushes workspacesSync (tab names, sessions, active id) via a new ready-gated send proxy; verified end-to-end in the running app. - engine.aggregateProgress(): totals + per-session breakdown over non-terminal jobs (unit-verified idle/mid-transfer/idle). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
383 lines
12 KiB
TypeScript
383 lines
12 KiB
TypeScript
import { Injectable, computed, effect, signal, type WritableSignal } from '@angular/core';
|
|
import type { ConnectionConfig, FileEntry, SavedConnection } from '@shared/types';
|
|
import { RpcService } from './rpc.service';
|
|
import { ToastService } from './toast.service';
|
|
|
|
export type Side = 'left' | 'right';
|
|
export type SortKey = 'name' | 'size' | 'mtime';
|
|
|
|
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);
|
|
readonly cwd = signal('');
|
|
readonly entries = signal<FileEntry[]>([]);
|
|
readonly loading = signal(false);
|
|
readonly sortKey = signal<SortKey>('name');
|
|
readonly sortAsc = signal(true);
|
|
readonly cursor = signal(0);
|
|
readonly selected = signal<ReadonlySet<string>>(new Set());
|
|
|
|
/** Directories first, then the active sort column. ".." (parent) is pinned on top. */
|
|
readonly sorted = computed(() => {
|
|
const list = [...this.entries()];
|
|
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;
|
|
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 && cwd !== '/') {
|
|
list.unshift({ name: '..', path: `${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> {
|
|
this.loading.set(true);
|
|
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());
|
|
} finally {
|
|
this.loading.set(false);
|
|
}
|
|
}
|
|
|
|
/** Reload cwd, keeping cursor position and selection by name. */
|
|
async refresh(): Promise<void> {
|
|
const keepCursorName = this.sorted()[this.cursor()]?.name;
|
|
const keepSelected = this.selected();
|
|
this.loading.set(true);
|
|
try {
|
|
const res = await this.deps.rpc.request.listDir({
|
|
sessionId: this.sessionId(),
|
|
path: this.cwd(),
|
|
});
|
|
if (!res.ok) {
|
|
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 {
|
|
this.loading.set(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(`${this.cwd()}/..`);
|
|
}
|
|
|
|
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 {
|
|
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];
|
|
});
|
|
/** Bumped to tell the active panel to grab keyboard focus again (e.g. after a dialog closes). */
|
|
readonly focusTick = signal(0);
|
|
|
|
private nextWorkspaceId = 1;
|
|
|
|
constructor(
|
|
private rpc: RpcService,
|
|
private toast: ToastService,
|
|
) {
|
|
this.newWorkspace();
|
|
|
|
// keep the bun-side tray informed about tabs (names, sessions, active)
|
|
if (this.rpc.available) {
|
|
effect(() => {
|
|
this.rpc.send.workspacesSync({
|
|
workspaces: this.workspaces().map((ws) => ({
|
|
id: ws.id,
|
|
name: this.workspaceName(ws),
|
|
remote: this.workspaceIsRemote(ws),
|
|
sessionIds: [ws.left.sessionId(), ws.right.sessionId()],
|
|
})),
|
|
activeId: this.activeWorkspaceId(),
|
|
});
|
|
});
|
|
}
|
|
|
|
// 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(`${panel.title()}: disconnected (${event.reason})`);
|
|
this.resetToLocal(panel);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// ---- 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.focusTick.update((n) => n + 1);
|
|
}
|
|
|
|
// ---- 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();
|
|
}
|
|
}
|
|
|
|
/** One new tab per connection, its left panel connecting (right stays local). */
|
|
openInNewTabs(connections: SavedConnection[]): void {
|
|
for (const connection of connections) {
|
|
this.newWorkspace();
|
|
// connectPanel resolves the panel synchronously (before awaiting), so
|
|
// firing these back-to-back binds each connection to its own tab
|
|
void this.connectPanel('left', connection, connection.name);
|
|
}
|
|
}
|
|
|
|
/** 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);
|
|
}
|
|
|
|
/** 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(' ⇄ ') : '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. */
|
|
async connectPanel(side: Side, config: ConnectionConfig, title: string): Promise<boolean> {
|
|
const panel = this.get(side);
|
|
panel.connecting.set(true);
|
|
try {
|
|
const res = await this.rpc.request.connect({ config });
|
|
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);
|
|
await panel.load(config.initialPath ?? res.homeDir);
|
|
return true;
|
|
} finally {
|
|
panel.connecting.set(false);
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|