Configurable list columns via header right-click

Native checkable menu on the column headers toggles Size, Modified,
Kind, Rights, Owner and Group (persisted, both panels). Owner/group
come from the ls-style SFTP longname remotely and cached uid/gid
lookups locally (POSIX; undefined on Windows). All columns sort.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jokin
2026-07-13 23:30:23 +02:00
co-authored by Claude Fable 5
parent 485603fd30
commit ab59c785b5
12 changed files with 236 additions and 23 deletions
+50 -15
View File
@@ -178,19 +178,25 @@
</button>
</div>
<!-- column headers -->
<!-- column headers (right-click to add/remove columns) -->
<div
class="grid grid-cols-[minmax(0,1fr)_5.5rem_8.5rem] border-b border-border text-[11px] font-medium text-muted-foreground"
(contextmenu)="headerContextMenu($event)"
[style.grid-template-columns]="gridTemplate()"
class="grid 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>
@for (col of visibleColumns(); track col.key) {
<button
class="px-2 py-1 hover:text-foreground"
[class.text-right]="col.key === 'size'"
[class.text-left]="col.key !== 'size'"
(click)="panel().toggleSort(col.key)"
>
{{ col.label }} {{ sortIndicator(col.key) }}
</button>
}
</div>
<!-- type-to-filter bar (appears while a query is active) -->
@@ -243,7 +249,8 @@
(click)="rowClick($event, i)"
(dblclick)="openAt(i)"
(contextmenu)="rowContextMenu($event, i)"
class="grid h-(--row-h) grid-cols-[minmax(0,1fr)_5.5rem_8.5rem] items-center text-(length:--list-font)"
[style.grid-template-columns]="gridTemplate()"
class="grid h-(--row-h) items-center text-(length:--list-font)"
[class.bg-cursor-tint]="i === panel().cursor() && !isSelected(entry)"
[class.bg-primary/15]="isSelected(entry)"
[class.ring-1]="i === panel().cursor() && isActive()"
@@ -254,12 +261,40 @@
<ng-icon [name]="iconFor(entry)" size="0.95rem" [class]="iconClass(entry)" />
<span class="truncate" [class.italic]="entry.type === 'symlink'">{{ entry.name }}</span>
</div>
<span class="px-2 text-right font-mono text-(length:--list-meta-font) tabular-nums text-muted-foreground">
{{ entry.type === 'dir' ? '—' : formatSize(entry.size) }}
</span>
<span class="px-2 font-mono text-(length:--list-meta-font) tabular-nums text-muted-foreground">
{{ formatDate(entry.mtime) }}
</span>
@for (col of visibleColumns(); track col.key) {
@switch (col.key) {
@case ('size') {
<span class="px-2 text-right font-mono text-(length:--list-meta-font) tabular-nums text-muted-foreground">
{{ entry.type === 'dir' ? '—' : formatSize(entry.size) }}
</span>
}
@case ('mtime') {
<span class="px-2 font-mono text-(length:--list-meta-font) tabular-nums text-muted-foreground">
{{ formatDate(entry.mtime) }}
</span>
}
@case ('kind') {
<span class="truncate px-2 text-(length:--list-meta-font) text-muted-foreground">
{{ fileKind(entry) }}
</span>
}
@case ('rights') {
<span class="px-2 font-mono text-(length:--list-meta-font) text-muted-foreground">
{{ entry.name === '..' ? '' : formatMode(entry.mode) }}
</span>
}
@case ('owner') {
<span class="truncate px-2 font-mono text-(length:--list-meta-font) text-muted-foreground">
{{ entry.name === '..' ? '' : (entry.owner ?? '—') }}
</span>
}
@case ('group') {
<span class="truncate px-2 font-mono text-(length:--list-meta-font) text-muted-foreground">
{{ entry.name === '..' ? '' : (entry.group ?? '—') }}
</span>
}
}
}
</div>
} @empty {
<div class="p-6 text-center text-xs text-muted-foreground">Empty directory</div>
+31 -3
View File
@@ -9,16 +9,27 @@ import {
viewChild,
} from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import type { FileEntry, SavedConnection, SshConfigHost } from '@shared/types';
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 } from '../file-icons';
import { formatDate, formatSize } from '../format';
import { fileGlyph, fileIconClass, fileKind } from '../file-icons';
import { formatDate, formatMode, formatSize } from '../format';
import { PanelsService, type Side, type SortKey } from '../panels.service';
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],
@@ -34,6 +45,7 @@ export class FilePanel {
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');
@@ -50,6 +62,22 @@ export class FilePanel {
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() });
}
constructor() {
// keep the editable path bar in sync with the actual cwd