import { Component, ElementRef, computed, effect, inject, input, signal, viewChild, } from '@angular/core'; import { NgIcon } from '@ng-icons/core'; import type { ColumnKey, FileEntry, SavedConnection, SshConfigHost } from '@shared/types'; import { ConnectionsService } from '../connections.service'; import { DialogsService } from '../dialogs.service'; import { FileActionsService } from '../file-actions.service'; import { fileGlyph, fileIconClass, fileKind } from '../file-icons'; import { formatDate, formatMode, formatSize } from '../format'; import { PanelsService, type Side, type SortKey } from '../panels.service'; import { IS_WINDOWS } from '../platform'; import { RpcService } from '../rpc.service'; import { SettingsService } from '../settings.service'; import { TransfersService } from '../transfers.service'; /** Optional columns in fixed display order, with their track widths. */ const COLUMN_DEFS: { key: ColumnKey; label: string; width: string }[] = [ { key: 'size', label: 'Size', width: '5.5rem' }, { key: 'mtime', label: 'Modified', width: '8.5rem' }, { key: 'kind', label: 'Kind', width: '5.5rem' }, { key: 'rights', label: 'Rights', width: '6.5rem' }, { key: 'owner', label: 'Owner', width: '6rem' }, { key: 'group', label: 'Group', width: '6rem' }, ]; @Component({ selector: 'app-file-panel', imports: [NgIcon], templateUrl: './file-panel.html', host: { class: '@container flex min-w-0 flex-1 flex-col overflow-hidden' }, }) export class FilePanel { readonly side = input.required(); readonly panels = inject(PanelsService); readonly actions = inject(FileActionsService); readonly transfers = inject(TransfersService); readonly connections = inject(ConnectionsService); readonly dialogs = inject(DialogsService); private readonly settings = inject(SettingsService); private readonly rpc = inject(RpcService); readonly panel = computed(() => this.panels.get(this.side())); readonly isActive = computed(() => this.panels.activeSide() === this.side()); readonly isRemote = computed(() => this.panel().sessionId() !== 'local'); /** Input is ignored while the panel is connecting or a listing is in flight. */ readonly blocked = computed(() => this.panel().busy()); /** The copy button points toward the other panel. */ readonly copyIcon = computed(() => (this.side() === 'left' ? 'mynaArrowRight' : 'mynaArrowLeft')); readonly pathDraft = signal(''); readonly menuOpen = signal(false); /** Windows drive roots for the source picker; always empty elsewhere. */ readonly drives = signal([]); /** chmod on Windows local files only flips the read-only bit — hide it. */ readonly chmodAvailable = computed(() => !(IS_WINDOWS && !this.isRemote())); private readonly listEl = viewChild.required>('list'); private lastClickedIndex = 0; readonly formatSize = formatSize; readonly formatDate = formatDate; readonly formatMode = formatMode; readonly fileKind = fileKind; /** The optional columns currently shown, in canonical order. */ readonly visibleColumns = computed(() => { const visible = this.settings.columns(); return COLUMN_DEFS.filter((c) => visible.includes(c.key)); }); readonly gridTemplate = computed(() => ['minmax(0,1fr)', ...this.visibleColumns().map((c) => c.width)].join(' '), ); headerContextMenu(event: MouseEvent): void { event.preventDefault(); void this.rpc.request.showColumnsMenu({ visible: this.settings.columns() }); } pickDrive(drive: string): void { this.menuOpen.set(false); this.activate(); void this.panel().load(drive); } constructor() { if (IS_WINDOWS && this.rpc.available) { void this.rpc.request.listDrives({}).then((res) => { if (res.ok) this.drives.set(res.drives); }); } // 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, dialogs refocus on close) effect(() => { this.panels.focusTick(); if (this.isActive()) this.listEl().nativeElement.focus(); }); } activate(): void { this.panels.setActiveSide(this.side()); } cancelConnect(): void { this.panels.cancelConnect(this.side()); } copyToOther(): void { if (this.blocked()) return; this.activate(); void this.transfers.copyToOtherPanel(this.side()); } run(action: 'edit' | 'rename' | 'mkdir' | 'createFile' | 'chmod' | 'delete'): void { if (this.blocked()) return; this.activate(); void this.actions[action](this.panel()); } pickLocal(): void { this.menuOpen.set(false); void this.panels.disconnectPanel(this.side()); } pickConnection(connection: SavedConnection): void { this.menuOpen.set(false); this.activate(); void this.panels.connectPanel(this.side(), connection, connection.name, connection.localDir); } toggleMenu(): void { this.menuOpen.set(!this.menuOpen()); // re-read ~/.ssh/config on each open so external edits show up if (this.menuOpen()) void this.connections.reloadSshHosts(); } pickSshHost(host: SshConfigHost): void { this.menuOpen.set(false); this.activate(); void this.panels.connectPanel( this.side(), { host: host.hostname, port: host.port, username: host.username, auth: host.identityFile ? { kind: 'key', privateKeyPath: host.identityFile } : { kind: 'agent' }, initialPath: null, }, host.name, ); } sshHostTitle(host: SshConfigHost): string { if (host.unsupported === 'proxy') return 'Uses ProxyJump/ProxyCommand — not supported'; return `${host.username}@${host.hostname}:${host.port}`; } iconFor(entry: FileEntry): string { return fileGlyph(entry, this.settings.iconColors() === 'multi'); } iconClass(entry: FileEntry): string { return fileIconClass(entry, this.settings.iconColors() === 'multi'); } isSelected(entry: FileEntry): boolean { return this.panel().selected().has(entry.name); } clearFilter(): void { this.panel().setFilter(''); this.listEl().nativeElement.focus(); } filterMatches(): number { return this.panel() .sorted() .filter((e) => e.name !== '..').length; } sortIndicator(key: SortKey): string { if (this.panel().sortKey() !== key) return ''; return this.panel().sortAsc() ? '↑' : '↓'; } commitPath(): void { if (this.blocked()) return; 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 { if (this.blocked()) return; 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; } rowContextMenu(event: MouseEvent, index: number): void { event.preventDefault(); event.stopPropagation(); if (this.blocked()) return; this.activate(); const panel = this.panel(); const entry = panel.sorted()[index] ?? null; // standard behavior: keep the selection when right-clicking inside it if (entry && !panel.selected().has(entry.name)) panel.clearSelection(); panel.cursor.set(index); void this.actions.openContextMenu({ side: this.side(), panel, entry }); } listContextMenu(event: MouseEvent): void { event.preventDefault(); if (this.blocked()) return; this.activate(); void this.actions.openContextMenu({ side: this.side(), panel: this.panel(), entry: null }); } openAt(index: number): void { if (this.blocked()) return; const entry = this.panel().sorted()[index]; if (entry) this.panel().open(entry); } onKeydown(event: KeyboardEvent): void { if (this.blocked()) return; const panel = this.panel(); const key = event.key; if (key === 'Tab') { event.preventDefault(); this.panels.setActiveSide(this.panels.otherSide(this.side())); return; } if ((event.metaKey || event.ctrlKey) && key.toLowerCase() === 'a') { event.preventDefault(); panel.selectAll(); return; } // type-to-filter: printable characters build the query (Space only once a // query exists — an empty-filter Space keeps its toggle-select meaning) if ( key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey && (key !== ' ' || panel.filter() !== '') ) { event.preventDefault(); panel.setFilter(panel.filter() + key); this.scrollCursorIntoView(); return; } if (key === 'Backspace' && panel.filter() !== '') { event.preventDefault(); panel.setFilter(panel.filter().slice(0, -1)); return; } if (key === 'Escape' && panel.filter() !== '') { event.preventDefault(); panel.setFilter(''); 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; case 'F2': this.actions.rename(panel); break; case 'F4': void this.actions.edit(panel); break; case 'F5': void this.transfers.copyToOtherPanel(this.side()); break; case 'F7': this.actions.mkdir(panel); break; case 'F8': case 'Delete': this.actions.delete(panel); 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' }); } }