Files
miniSCP/ui/src/app/file-panel/file-panel.ts
T

276 lines
7.8 KiB
TypeScript

import {
Component,
ElementRef,
computed,
effect,
inject,
input,
signal,
viewChild,
} from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import type { FileEntry, SavedConnection } from '@shared/types';
import { ConnectionsService } from '../connections.service';
import { DialogsService } from '../dialogs.service';
import { FileActionsService } from '../file-actions.service';
import { fileGlyph, fileIconClass } from '../file-icons';
import { formatDate, formatSize } from '../format';
import { PanelsService, type Side, type SortKey } from '../panels.service';
import { SettingsService } from '../settings.service';
import { TransfersService } from '../transfers.service';
@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<Side>();
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);
readonly panel = computed(() => this.panels.get(this.side()));
readonly isActive = computed(() => this.panels.activeSide() === this.side());
readonly isRemote = computed(() => this.panel().sessionId() !== 'local');
/** The copy button points toward the other panel. */
readonly copyIcon = computed(() => (this.side() === 'left' ? 'mynaArrowRight' : 'mynaArrowLeft'));
readonly pathDraft = signal('');
readonly menuOpen = signal(false);
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, dialogs refocus on close)
effect(() => {
this.panels.focusTick();
if (this.isActive()) this.listEl().nativeElement.focus();
});
}
activate(): void {
this.panels.setActiveSide(this.side());
}
copyToOther(): void {
this.activate();
void this.transfers.copyToOtherPanel(this.side());
}
run(action: 'edit' | 'rename' | 'mkdir' | 'createFile' | 'chmod' | 'delete'): void {
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);
}
openManager(): void {
this.menuOpen.set(false);
this.activate();
this.dialogs.manager.set(true);
}
disconnect(): void {
void this.panels.disconnectPanel(this.side());
}
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 {
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;
}
rowContextMenu(event: MouseEvent, index: number): void {
event.preventDefault();
event.stopPropagation();
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();
this.activate();
void this.actions.openContextMenu({ side: this.side(), panel: this.panel(), entry: null });
}
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.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' });
}
}