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:
+16
-1
@@ -1,6 +1,7 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { NgIcon } from '@ng-icons/core';
|
||||
import { BRANDING } from '@shared/branding';
|
||||
import type { ColumnKey } from '@shared/types';
|
||||
import { ConnectionsService } from './connections.service';
|
||||
import { ChmodDialog } from './dialogs/chmod-dialog';
|
||||
import { ConfirmDialog } from './dialogs/confirm-dialog';
|
||||
@@ -10,7 +11,9 @@ import { DialogsService } from './dialogs.service';
|
||||
import { FileActionsService } from './file-actions.service';
|
||||
import { FilePanel } from './file-panel/file-panel';
|
||||
import { PanelsService } from './panels.service';
|
||||
import { blockNativeContextMenu } from './platform';
|
||||
import { RpcService } from './rpc.service';
|
||||
import { SettingsService } from './settings.service';
|
||||
import { StatusBar } from './status-bar/status-bar';
|
||||
import { TabBar } from './tab-bar/tab-bar';
|
||||
import { ToastService } from './toast.service';
|
||||
@@ -33,7 +36,10 @@ import { TransfersService } from './transfers.service';
|
||||
TransferBar,
|
||||
],
|
||||
templateUrl: './app.html',
|
||||
host: { '(window:keydown)': 'onWindowKeydown($event)' },
|
||||
host: {
|
||||
'(window:keydown)': 'onWindowKeydown($event)',
|
||||
'(window:contextmenu)': 'blockContextMenu($event)',
|
||||
},
|
||||
})
|
||||
export class App {
|
||||
readonly rpc = inject(RpcService);
|
||||
@@ -43,6 +49,7 @@ export class App {
|
||||
readonly actions = inject(FileActionsService);
|
||||
readonly connections = inject(ConnectionsService);
|
||||
private readonly transfers = inject(TransfersService);
|
||||
private readonly settings = inject(SettingsService);
|
||||
|
||||
/** The connection-manager window is open — this window is modal-blocked. */
|
||||
readonly managerOpen = signal(false);
|
||||
@@ -60,6 +67,10 @@ export class App {
|
||||
void this.rpc.request.openConnectionManager({});
|
||||
}
|
||||
|
||||
blockContextMenu(event: Event): void {
|
||||
blockNativeContextMenu(event);
|
||||
}
|
||||
|
||||
/** Temp files from external edits in a previous run (crash / quit mid-edit). */
|
||||
private async promptForEditLeftovers(): Promise<void> {
|
||||
const res = await this.rpc.request.getEditLeftovers({});
|
||||
@@ -97,6 +108,10 @@ export class App {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (action.startsWith('col:')) {
|
||||
this.settings.toggleColumn(action.slice('col:'.length) as ColumnKey);
|
||||
return;
|
||||
}
|
||||
if (action.startsWith('open-stack:')) {
|
||||
const id = action.slice('open-stack:'.length);
|
||||
let stackConnections = this.connections.connections().filter((c) => c.stackId === id);
|
||||
|
||||
@@ -64,6 +64,24 @@ function categoryOf(name: string): Category | null {
|
||||
return EXT_CATEGORY.get(key) ?? null;
|
||||
}
|
||||
|
||||
const KIND_LABELS: Record<Category, string> = {
|
||||
image: 'Image',
|
||||
audio: 'Audio',
|
||||
video: 'Video',
|
||||
archive: 'Archive',
|
||||
code: 'Code',
|
||||
doc: 'Document',
|
||||
};
|
||||
|
||||
/** Human label for the optional Kind column (category-based, like the icons). */
|
||||
export function fileKind(entry: FileEntry): string {
|
||||
if (entry.name === '..') return '';
|
||||
if (entry.type === 'dir') return 'Folder';
|
||||
if (entry.type === 'symlink') return 'Symlink';
|
||||
const category = categoryOf(entry.name);
|
||||
return category ? KIND_LABELS[category] : 'File';
|
||||
}
|
||||
|
||||
export function fileGlyph(entry: FileEntry, multi: boolean): string {
|
||||
if (entry.name === '..') return 'mynaArrowUp';
|
||||
if (entry.type === 'dir') return 'mynaFolder';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import { Injectable, computed, inject, signal, type WritableSignal } from '@angular/core';
|
||||
import type { ConnectionConfig, FileEntry, SavedConnection } from '@shared/types';
|
||||
import type { ColumnKey, ConnectionConfig, FileEntry, SavedConnection } from '@shared/types';
|
||||
import { fileKind } from './file-icons';
|
||||
import { FocusService } from './focus.service';
|
||||
import { formatMode } from './format';
|
||||
import { RpcService } from './rpc.service';
|
||||
import { ToastService } from './toast.service';
|
||||
|
||||
export type Side = 'left' | 'right';
|
||||
export type SortKey = 'name' | 'size' | 'mtime';
|
||||
/** Any visible column sorts; Name is always available. */
|
||||
export type SortKey = 'name' | ColumnKey;
|
||||
|
||||
/** Display string the text-based columns sort by. */
|
||||
function sortText(entry: FileEntry, key: 'kind' | 'rights' | 'owner' | 'group'): string {
|
||||
if (key === 'kind') return fileKind(entry);
|
||||
if (key === 'rights') return formatMode(entry.mode);
|
||||
return (key === 'owner' ? entry.owner : entry.group) ?? '';
|
||||
}
|
||||
|
||||
interface PanelDeps {
|
||||
rpc: RpcService;
|
||||
@@ -55,6 +65,7 @@ export class PanelState {
|
||||
let cmp = 0;
|
||||
if (key === 'size') cmp = a.size - b.size;
|
||||
else if (key === 'mtime') cmp = a.mtime - b.mtime;
|
||||
else if (key !== 'name') cmp = sortText(a, key).localeCompare(sortText(b, key));
|
||||
if (cmp === 0) {
|
||||
cmp = a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
|
||||
if (key === 'name') return cmp * dir;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import { DEFAULT_SETTINGS, type AppSettings } from '@shared/types';
|
||||
import { DEFAULT_SETTINGS, type AppSettings, type ColumnKey } from '@shared/types';
|
||||
import { RpcService } from './rpc.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
@@ -8,6 +8,7 @@ export class SettingsService {
|
||||
readonly darkTone = signal<AppSettings['darkTone']>(DEFAULT_SETTINGS.darkTone);
|
||||
readonly accent = signal<AppSettings['accent']>(DEFAULT_SETTINGS.accent);
|
||||
readonly iconColors = signal<AppSettings['iconColors']>(DEFAULT_SETTINGS.iconColors);
|
||||
readonly columns = signal<ColumnKey[]>(DEFAULT_SETTINGS.columns);
|
||||
readonly speedUnit = signal<AppSettings['speedUnit']>(DEFAULT_SETTINGS.speedUnit);
|
||||
readonly textSize = signal<AppSettings['textSize']>(DEFAULT_SETTINGS.textSize);
|
||||
readonly transferMode = signal<AppSettings['transferMode']>(DEFAULT_SETTINGS.transferMode);
|
||||
@@ -43,6 +44,7 @@ export class SettingsService {
|
||||
this.darkTone.set(settings.darkTone);
|
||||
this.accent.set(settings.accent);
|
||||
this.iconColors.set(settings.iconColors);
|
||||
this.columns.set(settings.columns);
|
||||
this.speedUnit.set(settings.speedUnit);
|
||||
this.textSize.set(settings.textSize);
|
||||
this.transferMode.set(settings.transferMode);
|
||||
@@ -80,6 +82,13 @@ export class SettingsService {
|
||||
void this.persist();
|
||||
}
|
||||
|
||||
toggleColumn(key: ColumnKey): void {
|
||||
this.columns.update((list) =>
|
||||
list.includes(key) ? list.filter((c) => c !== key) : [...list, key],
|
||||
);
|
||||
void this.persist();
|
||||
}
|
||||
|
||||
setSpeedUnit(unit: AppSettings['speedUnit']): void {
|
||||
this.speedUnit.set(unit);
|
||||
void this.persist();
|
||||
@@ -158,6 +167,7 @@ export class SettingsService {
|
||||
darkTone: this.darkTone(),
|
||||
accent: this.accent(),
|
||||
iconColors: this.iconColors(),
|
||||
columns: this.columns(),
|
||||
speedUnit: this.speedUnit(),
|
||||
textSize: this.textSize(),
|
||||
transferMode: this.transferMode(),
|
||||
|
||||
Reference in New Issue
Block a user