External editor via cmd/start + exe picker; Permissions hidden for Windows-local (chmod only flips read-only there); Explorer/Recycle Bin wording; Ctrl vs Cmd shortcut labels; separator-aware path joins + drive-root detection; drives list in the panel source picker; icon.ico + colored tray icon; icacls ACL on connections.json; README Windows (beta) section with upstream known issues and a smoke-test checklist. Runtime paths pending first boot on a real Windows machine. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
474 lines
16 KiB
TypeScript
474 lines
16 KiB
TypeScript
import { Injectable, computed, inject, signal, type WritableSignal } from '@angular/core';
|
|
import type { ColumnKey, ConnectionConfig, FileEntry, SavedConnection } from '@shared/types';
|
|
import { fileKind } from './file-icons';
|
|
import { FocusService } from './focus.service';
|
|
import { formatMode, isRootPath, joinPath } from './format';
|
|
import { RpcService } from './rpc.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 fileKind(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);
|
|
}
|
|
}
|
|
|
|
/** 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(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 {
|
|
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);
|
|
}
|
|
}
|