- shared/types + shared/rpc: full wire contract (Result envelope, no exceptions cross the bridge) - FileSystemProvider interface, LocalProvider, SessionRegistry with built-in 'local' - walk.ts: provider-generic recursive walk + removeRecursive - rpc-handlers wired into BrowserWindow; 10min request timeout for slow SFTP ops - Angular: RpcService (Electroview bridge + plain-browser guard), PanelsService with signals-based PanelState (sort/cursor/selection), FilePanel component with keyboard nav (arrows/Space/Enter/Backspace/Tab), path bar, toasts - verified: both panels listDir home over the bridge in the running app Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
155 lines
4.0 KiB
TypeScript
155 lines
4.0 KiB
TypeScript
import {
|
|
Component,
|
|
ElementRef,
|
|
computed,
|
|
effect,
|
|
inject,
|
|
input,
|
|
signal,
|
|
viewChild,
|
|
} from '@angular/core';
|
|
import { NgIcon } from '@ng-icons/core';
|
|
import type { FileEntry } from '@shared/types';
|
|
import { formatDate, formatSize } from '../format';
|
|
import { PanelsService, type Side, type SortKey } from '../panels.service';
|
|
|
|
@Component({
|
|
selector: 'app-file-panel',
|
|
imports: [NgIcon],
|
|
templateUrl: './file-panel.html',
|
|
host: { class: 'flex min-w-0 flex-1 flex-col overflow-hidden' },
|
|
})
|
|
export class FilePanel {
|
|
readonly side = input.required<Side>();
|
|
|
|
readonly panels = inject(PanelsService);
|
|
readonly panel = computed(() => this.panels.get(this.side()));
|
|
readonly isActive = computed(() => this.panels.activeSide() === this.side());
|
|
|
|
readonly pathDraft = signal('');
|
|
|
|
private readonly listEl = viewChild.required<ElementRef<HTMLDivElement>>('list');
|
|
private lastClickedIndex = 0;
|
|
|
|
readonly formatSize = formatSize;
|
|
readonly formatDate = formatDate;
|
|
|
|
constructor() {
|
|
// keep the editable path bar in sync with the actual cwd
|
|
effect(() => this.pathDraft.set(this.panel().cwd()));
|
|
// focus follows the active side (Tab switches panels)
|
|
effect(() => {
|
|
if (this.isActive()) this.listEl().nativeElement.focus();
|
|
});
|
|
}
|
|
|
|
activate(): void {
|
|
this.panels.activeSide.set(this.side());
|
|
}
|
|
|
|
iconFor(entry: FileEntry): string {
|
|
return entry.type === 'dir' ? 'mynaFolder' : 'mynaFile';
|
|
}
|
|
|
|
isSelected(entry: FileEntry): boolean {
|
|
return this.panel().selected().has(entry.name);
|
|
}
|
|
|
|
sortIndicator(key: SortKey): string {
|
|
if (this.panel().sortKey() !== key) return '';
|
|
return this.panel().sortAsc() ? '↑' : '↓';
|
|
}
|
|
|
|
commitPath(): void {
|
|
const value = this.pathDraft().trim();
|
|
if (value) void this.panel().load(value);
|
|
this.listEl().nativeElement.focus();
|
|
}
|
|
|
|
resetPath(): void {
|
|
this.pathDraft.set(this.panel().cwd());
|
|
this.listEl().nativeElement.focus();
|
|
}
|
|
|
|
rowClick(event: MouseEvent, index: number): void {
|
|
this.activate();
|
|
const panel = this.panel();
|
|
if (event.shiftKey) {
|
|
panel.selectRange(this.lastClickedIndex, index);
|
|
} else if (event.metaKey || event.ctrlKey) {
|
|
const entry = panel.sorted()[index];
|
|
if (entry) panel.toggleSelect(entry.name);
|
|
} else {
|
|
panel.clearSelection();
|
|
}
|
|
panel.cursor.set(index);
|
|
this.lastClickedIndex = index;
|
|
}
|
|
|
|
openAt(index: number): void {
|
|
const entry = this.panel().sorted()[index];
|
|
if (entry) this.panel().open(entry);
|
|
}
|
|
|
|
onKeydown(event: KeyboardEvent): void {
|
|
const panel = this.panel();
|
|
const key = event.key;
|
|
|
|
if (key === 'Tab') {
|
|
event.preventDefault();
|
|
this.panels.activeSide.set(this.panels.otherSide(this.side()));
|
|
return;
|
|
}
|
|
if ((event.metaKey || event.ctrlKey) && key.toLowerCase() === 'a') {
|
|
event.preventDefault();
|
|
panel.selectAll();
|
|
return;
|
|
}
|
|
|
|
switch (key) {
|
|
case 'ArrowDown':
|
|
panel.moveCursor(1);
|
|
break;
|
|
case 'ArrowUp':
|
|
panel.moveCursor(-1);
|
|
break;
|
|
case 'PageDown':
|
|
panel.moveCursor(20);
|
|
break;
|
|
case 'PageUp':
|
|
panel.moveCursor(-20);
|
|
break;
|
|
case 'Home':
|
|
panel.cursor.set(0);
|
|
break;
|
|
case 'End':
|
|
panel.cursor.set(Math.max(0, panel.sorted().length - 1));
|
|
break;
|
|
case ' ':
|
|
{
|
|
const entry = panel.sorted()[panel.cursor()];
|
|
if (entry) panel.toggleSelect(entry.name);
|
|
panel.moveCursor(1);
|
|
}
|
|
break;
|
|
case 'Enter':
|
|
this.openAt(panel.cursor());
|
|
return void event.preventDefault();
|
|
case 'Backspace':
|
|
panel.up();
|
|
break;
|
|
default:
|
|
return; // let unhandled keys through
|
|
}
|
|
event.preventDefault();
|
|
this.scrollCursorIntoView();
|
|
}
|
|
|
|
private scrollCursorIntoView(): void {
|
|
const row = this.listEl().nativeElement.querySelector(
|
|
`[data-index="${this.panel().cursor()}"]`,
|
|
);
|
|
row?.scrollIntoView({ block: 'nearest' });
|
|
}
|
|
}
|