Files
miniSCP/ui/src/app/core/panels.service.ts
T
jgamboaandClaude c4364c1051 Fix the dead win7 context menu, flatten the selection, add a tab menu
Right-click did nothing under the win7 skin, and then swallowed the next
click. 7.css declares `position: relative` on ul[role=menu] and the `skin`
layer outranks `utilities`, so Tailwind's `.fixed` lost: [style.left/top.px]
became flow offsets from the menu's static position — the last child of the
h-screen frame — and every menu rendered below the viewport, unreachable
behind overflow:hidden. Its backdrop is a plain <div>, unmatched by 7.css, so
that stayed genuinely fixed and ate the following click. Regressed in 0ae2a62,
when the menu adopted 7.css's real markup.

Restated `position: fixed` in skinfix, scoped to a new `w7-ctx` marker so
7.css's own menus keep theirs. scripts/menu-probe.ts is the regression test:
it renders the menu at declared coordinates, pixel-scans whether it landed
there, and carries a marker-less control menu (the bug verbatim) as its own
discriminator. A resting pixel diff cannot see this class of bug.

Selection band goes flat: --w7-sel-bg/-bd (#cbe8f6/#26a0da) is what Explorer
actually painted, where 7.css reused --w7-el-grad-h, its Aero element-hover
ramp. That var is left alone — buttons and tabs still want the gloss. The
unfocused pane gets the grey idle band; the band still never disappears with
focus, it only weakens. Harness diff confirms the change touches selection
rows and nothing else, in both themes.

New workspace-tab right-click menu in both skins: New Tab, Close Tab, Close
Other Tabs, Close Tabs to the Right, Disconnect. The target id travels inside
the action string because the right-clicked tab is not necessarily the active
one. Illegal entries are disabled rather than omitted, the way Win7 greyed
out — and since 7.css's [aria-disabled] handling is CSS-only, the keyboard
paths check the flag themselves.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 20:50:42 +02:00

585 lines
20 KiB
TypeScript

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();
}
/**
* Close every tab except one — the tab menu's "Close Other Tabs".
*
* Snapshot the ids FIRST: closeWorkspace mutates the list on every call, so
* iterating the live signal would skip half of them. It also refuses the
* last tab, which is exactly the wanted floor here.
*/
closeOtherWorkspaces(id: number): void {
for (const other of this.workspaces().map((w) => w.id)) {
if (other !== id) this.closeWorkspace(other);
}
this.setActiveWorkspace(id);
}
/** Close every tab to the right of one, in visual order. */
closeWorkspacesToRight(id: number): void {
const list = this.workspaces();
const index = list.findIndex((w) => w.id === id);
if (index < 0) return;
for (const target of list.slice(index + 1).map((w) => w.id)) {
this.closeWorkspace(target);
}
}
/**
* Drop both of a tab's remote sessions, keeping the tab itself. Unlike
* `disconnectPanel` this targets a tab by id, because the right-clicked tab
* is not necessarily the active one.
*/
async disconnectWorkspace(id: number): Promise<void> {
const workspace = this.workspaces().find((w) => w.id === id);
if (!workspace) return;
for (const panel of [workspace.left, workspace.right]) {
const sessionId = panel.sessionId();
if (sessionId === 'local') continue;
await this.rpc.request.disconnect({ sessionId });
this.resetToLocal(panel);
}
}
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);
}
}