Files
miniSCP/ui/src/app/panels.service.ts
T
JokinandClaude Fable 5 485603fd30 Saved connections and stacks open remote on the right panel
Local left, remote right — the commander convention. Panel-dropdown
connects still target the panel they were opened from.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 23:18:34 +02:00

463 lines
15 KiB
TypeScript

import { Injectable, computed, inject, signal, type WritableSignal } from '@angular/core';
import type { ConnectionConfig, FileEntry, SavedConnection } from '@shared/types';
import { FocusService } from './focus.service';
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);
/** 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;
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> {
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);
}
}
/** 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);
const slowTimer = setTimeout(() => this.slowLoad.set(true), 300);
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 {
clearTimeout(slowTimer);
this.slowLoad.set(false);
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()}/..`);
}
/** 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 {
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(`${panel.title()}: disconnected (${event.reason})`);
this.resetToLocal(panel);
}
}
}
});
// an editor window saved a file — refresh panels showing that directory
this.rpc.onPathRefresh(({ sessionId, dir }) => {
for (const panel of this.allPanels()) {
if (panel.sessionId() === sessionId && panel.cwd() === dir) void panel.refresh();
}
});
}
// ---- 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(' ⇄ ') : '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);
}
}