P1: typed RPC bridge + FileSystemProvider + dual-panel local browsing
- 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>
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
<!-- session strip -->
|
||||
<div class="flex items-center gap-1.5 border-b border-border px-2 py-1.5">
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium hover:bg-accent"
|
||||
title="Choose what this panel shows"
|
||||
>
|
||||
<ng-icon name="mynaHardDrive" class="text-muted-foreground" size="0.9rem" />
|
||||
<span>{{ panel().title() }}</span>
|
||||
<ng-icon name="mynaChevronDown" class="text-muted-foreground" size="0.8rem" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- path bar -->
|
||||
<div class="flex items-center gap-1 border-b border-border px-2 py-1.5">
|
||||
<button
|
||||
(click)="panel().up()"
|
||||
title="Parent directory (Backspace)"
|
||||
class="grid size-7 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="mynaArrowUp" size="1rem" />
|
||||
</button>
|
||||
<input
|
||||
[value]="pathDraft()"
|
||||
(input)="pathDraft.set($any($event.target).value)"
|
||||
(keydown.enter)="commitPath()"
|
||||
(keydown.escape)="resetPath()"
|
||||
(keydown)="$event.stopPropagation()"
|
||||
spellcheck="false"
|
||||
class="h-7 min-w-0 flex-1 rounded-md border border-input bg-transparent px-2 font-mono text-xs outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
<button
|
||||
(click)="panel().refresh()"
|
||||
title="Refresh"
|
||||
class="grid size-7 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
@if (panel().loading()) {
|
||||
<ng-icon name="mynaSpinner" size="1rem" class="animate-spin" />
|
||||
} @else {
|
||||
<ng-icon name="mynaRefresh" size="1rem" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- column headers -->
|
||||
<div
|
||||
class="grid grid-cols-[minmax(0,1fr)_5.5rem_8.5rem] border-b border-border text-[11px] font-medium text-muted-foreground"
|
||||
>
|
||||
<button class="px-2 py-1 text-left hover:text-foreground" (click)="panel().toggleSort('name')">
|
||||
Name {{ sortIndicator('name') }}
|
||||
</button>
|
||||
<button class="px-2 py-1 text-right hover:text-foreground" (click)="panel().toggleSort('size')">
|
||||
Size {{ sortIndicator('size') }}
|
||||
</button>
|
||||
<button class="px-2 py-1 text-left hover:text-foreground" (click)="panel().toggleSort('mtime')">
|
||||
Modified {{ sortIndicator('mtime') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- file list -->
|
||||
<div
|
||||
#list
|
||||
tabindex="0"
|
||||
(keydown)="onKeydown($event)"
|
||||
(mousedown)="activate()"
|
||||
class="flex-1 overflow-y-auto outline-none"
|
||||
[class.bg-accent]="false"
|
||||
>
|
||||
@for (entry of panel().sorted(); track entry.name; let i = $index) {
|
||||
<div
|
||||
[attr.data-index]="i"
|
||||
(click)="rowClick($event, i)"
|
||||
(dblclick)="openAt(i)"
|
||||
class="grid h-7 grid-cols-[minmax(0,1fr)_5.5rem_8.5rem] items-center text-[13px]"
|
||||
[class.bg-accent]="i === panel().cursor() && !isSelected(entry)"
|
||||
[class.bg-primary/15]="isSelected(entry)"
|
||||
[class.ring-1]="i === panel().cursor() && isActive()"
|
||||
[class.ring-inset]="i === panel().cursor() && isActive()"
|
||||
[class.ring-ring]="i === panel().cursor() && isActive()"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-1.5 px-2">
|
||||
<ng-icon
|
||||
[name]="iconFor(entry)"
|
||||
size="0.95rem"
|
||||
[class]="entry.type === 'dir' ? 'shrink-0 text-primary' : 'shrink-0 text-muted-foreground'"
|
||||
/>
|
||||
<span class="truncate" [class.italic]="entry.type === 'symlink'">{{ entry.name }}</span>
|
||||
</div>
|
||||
<span class="px-2 text-right font-mono text-xs tabular-nums text-muted-foreground">
|
||||
{{ entry.type === 'dir' ? '—' : formatSize(entry.size) }}
|
||||
</span>
|
||||
<span class="px-2 font-mono text-xs tabular-nums text-muted-foreground">
|
||||
{{ formatDate(entry.mtime) }}
|
||||
</span>
|
||||
</div>
|
||||
} @empty {
|
||||
<div class="p-6 text-center text-xs text-muted-foreground">Empty directory</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- footer -->
|
||||
<div class="flex items-center gap-2 border-t border-border px-2 py-1 text-[11px] text-muted-foreground">
|
||||
<span>{{ panel().sorted().length }} items</span>
|
||||
@if (panel().selected().size; as count) {
|
||||
<span>· {{ count }} selected</span>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,154 @@
|
||||
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' });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user