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>
353 lines
11 KiB
TypeScript
353 lines
11 KiB
TypeScript
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<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);
|
|
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<string[]>([]);
|
|
/** 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<ElementRef<HTMLDivElement>>('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' });
|
|
}
|
|
}
|