From ab59c785b50764e80dc67fb49985b72f0e6559dd Mon Sep 17 00:00:00 2001 From: Jokin Date: Mon, 13 Jul 2026 23:30:23 +0200 Subject: [PATCH] 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 --- src/bun/context-menu.ts | 30 ++++++++++++- src/bun/fs/local.ts | 38 ++++++++++++++++ src/bun/fs/sftp.ts | 13 ++++++ src/bun/rpc-handlers.ts | 6 +++ src/shared/rpc.ts | 3 ++ src/shared/types.ts | 8 ++++ ui/src/app/app.ts | 17 ++++++- ui/src/app/file-icons.ts | 18 ++++++++ ui/src/app/file-panel/file-panel.html | 65 ++++++++++++++++++++------- ui/src/app/file-panel/file-panel.ts | 34 ++++++++++++-- ui/src/app/panels.service.ts | 15 ++++++- ui/src/app/settings.service.ts | 12 ++++- 12 files changed, 236 insertions(+), 23 deletions(-) diff --git a/src/bun/context-menu.ts b/src/bun/context-menu.ts index f1b9dd8..647985e 100644 --- a/src/bun/context-menu.ts +++ b/src/bun/context-menu.ts @@ -1,4 +1,15 @@ import { ContextMenu, Utils, type ApplicationMenuItemConfig } from "electrobun/bun"; +import type { ColumnKey } from "../shared/types"; + +/** Optional list columns, in display order (Name is fixed). */ +const COLUMNS: { key: ColumnKey; label: string }[] = [ + { key: "size", label: "Size" }, + { key: "mtime", label: "Modified" }, + { key: "kind", label: "Kind" }, + { key: "rights", label: "Rights" }, + { key: "owner", label: "Owner" }, + { key: "group", label: "Group" }, +]; export interface RowMenuContext { path: string; @@ -22,8 +33,14 @@ export class PanelContextMenu { constructor() { ContextMenu.on("context-menu-clicked", (event) => { const action = (event as { data?: { action?: string } }).data?.action; + if (!action) return; + // column toggles carry no row context + if (action.startsWith("col:")) { + this.onAction(action); + return; + } const ctx = this.context; - if (!action || !ctx) return; + if (!ctx) return; switch (action) { case "ctx-reveal": Utils.showItemInFolder(ctx.path); @@ -45,6 +62,17 @@ export class PanelContextMenu { ContextMenu.showContextMenu(this.build(context)); } + /** Column picker for the list headers: checkable toggles per column. */ + showColumns(visible: ColumnKey[]): void { + ContextMenu.showContextMenu( + COLUMNS.map(({ key, label }) => ({ + label, + action: `col:${key}`, + checked: visible.includes(key), + })), + ); + } + private build(ctx: RowMenuContext): ApplicationMenuItemConfig[] { const items: ApplicationMenuItemConfig[] = []; diff --git a/src/bun/fs/local.ts b/src/bun/fs/local.ts index 9f96b8b..e0b036d 100644 --- a/src/bun/fs/local.ts +++ b/src/bun/fs/local.ts @@ -11,6 +11,42 @@ function typeOf(st: Stats): EntryType { return st.isDirectory() ? "dir" : "file"; } +/** + * uid/gid -> names, cached per process (a listing usually has 1-3 owners). + * + * PORTABILITY: POSIX-only lookups (`id -nu` works on macOS and Linux; group + * names use dscacheutil on macOS, getent elsewhere). On Windows these return + * undefined and the Owner/Group columns show "—". + */ +const idNameCache = new Map(); + +function resolveId(kind: "user" | "group", id: number): string | undefined { + if (process.platform === "win32") return undefined; + const cacheKey = `${kind}:${id}`; + if (idNameCache.has(cacheKey)) return idNameCache.get(cacheKey); + let name: string | undefined; + try { + const cmd = + kind === "user" + ? ["id", "-nu", String(id)] + : process.platform === "darwin" + ? ["dscacheutil", "-q", "group", "-a", "gid", String(id)] + : ["getent", "group", String(id)]; + const proc = Bun.spawnSync(cmd, { stdout: "pipe", stderr: "ignore" }); + if (proc.success) { + const out = proc.stdout.toString(); + if (kind === "user") name = out.trim() || undefined; + else if (process.platform === "darwin") name = /^name:\s*(.+)$/m.exec(out)?.[1]?.trim(); + else name = out.split(":")[0]?.trim() || undefined; + } + } catch { + // lookup tool missing — fall through to the numeric id + } + name ??= String(id); + idNameCache.set(cacheKey, name); + return name; +} + function toEntry(name: string, full: string, st: Stats): FileEntry { return { name, @@ -19,6 +55,8 @@ function toEntry(name: string, full: string, st: Stats): FileEntry { size: st.size, mtime: st.mtimeMs, mode: st.mode & 0o7777, + owner: resolveId("user", st.uid), + group: resolveId("group", st.gid), }; } diff --git a/src/bun/fs/sftp.ts b/src/bun/fs/sftp.ts index 02f3241..6ae4fef 100644 --- a/src/bun/fs/sftp.ts +++ b/src/bun/fs/sftp.ts @@ -10,6 +10,18 @@ const S_IFMT = 0o170000; const S_IFDIR = 0o040000; const S_IFLNK = 0o120000; +/** + * Owner/group from the ls-style longname openssh sends with each entry + * ("-rw-r--r-- 1 root wheel 1024 Jan 1 00:00 name"). Servers aren't + * required to use this format — bail to undefined when it doesn't parse. + */ +function ownerFromLongname(longname: string | undefined): { owner?: string; group?: string } { + if (!longname || !/^[-dlbcps]/.test(longname)) return {}; + const fields = longname.split(/\s+/); + if (fields.length < 5) return {}; + return { owner: fields[2], group: fields[3] }; +} + function typeFromMode(mode: number): EntryType { const fmt = mode & S_IFMT; if (fmt === S_IFDIR) return "dir"; @@ -132,6 +144,7 @@ export class SftpProvider implements FileSystemProvider { size: e.attrs.size, mtime: e.attrs.mtime * 1000, // sftp attrs are seconds mode: e.attrs.mode & 0o7777, + ...ownerFromLongname(e.longname), })), ); }); diff --git a/src/bun/rpc-handlers.ts b/src/bun/rpc-handlers.ts index 49fe370..01b12be 100644 --- a/src/bun/rpc-handlers.ts +++ b/src/bun/rpc-handlers.ts @@ -275,6 +275,12 @@ export function createAppRPC({ return {}; }), + showColumnsMenu: ({ visible }) => + wrap(async () => { + contextMenu.showColumns(visible); + return {}; + }), + // ---- editor windows ---- openEditor: (ctx) => wrap(async () => { diff --git a/src/shared/rpc.ts b/src/shared/rpc.ts index 0104e4f..c0f72f8 100644 --- a/src/shared/rpc.ts +++ b/src/shared/rpc.ts @@ -1,5 +1,6 @@ import type { AppSettings, + ColumnKey, ConnectionConfig, ConnectionsFile, EntryType, @@ -134,6 +135,8 @@ export type AppRPC = { response: Result; }; moveToTrash: { params: { path: string }; response: Result }; + /** Native column-picker menu (right-click on the list headers). */ + showColumnsMenu: { params: { visible: ColumnKey[] }; response: Result }; // editor windows /** Main window only: open (or focus) a native editor window. */ openEditor: { params: EditorOpenContext; response: Result }; diff --git a/src/shared/types.ts b/src/shared/types.ts index 05f2b6b..44c0ebd 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -7,8 +7,13 @@ export interface FileEntry { size: number; // bytes mtime: number; // epoch milliseconds mode: number; // permission bits only (masked & 0o7777) + owner?: string; // resolved name when the provider knows it + group?: string; } +/** Optional file-list columns (Name is always shown). */ +export type ColumnKey = "size" | "mtime" | "kind" | "rights" | "owner" | "group"; + /** Every RPC response uses this envelope; errors never cross the bridge as exceptions. */ export type Result = ({ ok: true } & T) | { ok: false; error: string }; @@ -75,6 +80,8 @@ export interface AppSettings { accent: AccentTone; /** File-list icons: plain neutral glyphs, or per-category glyphs and colors. */ iconColors: "plain" | "multi"; + /** Visible optional file-list columns (both panels; Name always shows). */ + columns: ColumnKey[]; speedUnit: "MBps" | "Mbps"; /** Size of the panel interface: file list, path bar, header controls. */ textSize: "compact" | "default" | "large"; @@ -95,6 +102,7 @@ export const DEFAULT_SETTINGS: AppSettings = { darkTone: "dark", accent: "blue", iconColors: "multi", + columns: ["size", "mtime"], speedUnit: "MBps", textSize: "default", transferMode: "single", diff --git a/ui/src/app/app.ts b/ui/src/app/app.ts index a688ed1..268e3b4 100644 --- a/ui/src/app/app.ts +++ b/ui/src/app/app.ts @@ -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 { 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); diff --git a/ui/src/app/file-icons.ts b/ui/src/app/file-icons.ts index 54205cf..8b8e7b9 100644 --- a/ui/src/app/file-icons.ts +++ b/ui/src/app/file-icons.ts @@ -64,6 +64,24 @@ function categoryOf(name: string): Category | null { return EXT_CATEGORY.get(key) ?? null; } +const KIND_LABELS: Record = { + 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'; diff --git a/ui/src/app/file-panel/file-panel.html b/ui/src/app/file-panel/file-panel.html index 30bd191..17ddda9 100644 --- a/ui/src/app/file-panel/file-panel.html +++ b/ui/src/app/file-panel/file-panel.html @@ -178,19 +178,25 @@ - +
- - + @for (col of visibleColumns(); track col.key) { + + }
@@ -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 @@ {{ entry.name }} - - {{ entry.type === 'dir' ? '—' : formatSize(entry.size) }} - - - {{ formatDate(entry.mtime) }} - + @for (col of visibleColumns(); track col.key) { + @switch (col.key) { + @case ('size') { + + {{ entry.type === 'dir' ? '—' : formatSize(entry.size) }} + + } + @case ('mtime') { + + {{ formatDate(entry.mtime) }} + + } + @case ('kind') { + + {{ fileKind(entry) }} + + } + @case ('rights') { + + {{ entry.name === '..' ? '' : formatMode(entry.mode) }} + + } + @case ('owner') { + + {{ entry.name === '..' ? '' : (entry.owner ?? '—') }} + + } + @case ('group') { + + {{ entry.name === '..' ? '' : (entry.group ?? '—') }} + + } + } + } } @empty {
Empty directory
diff --git a/ui/src/app/file-panel/file-panel.ts b/ui/src/app/file-panel/file-panel.ts index 196b2cc..b6e4186 100644 --- a/ui/src/app/file-panel/file-panel.ts +++ b/ui/src/app/file-panel/file-panel.ts @@ -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 diff --git a/ui/src/app/panels.service.ts b/ui/src/app/panels.service.ts index f7c9bef..55e0587 100644 --- a/ui/src/app/panels.service.ts +++ b/ui/src/app/panels.service.ts @@ -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; diff --git a/ui/src/app/settings.service.ts b/ui/src/app/settings.service.ts index 8e81d23..9be92e0 100644 --- a/ui/src/app/settings.service.ts +++ b/ui/src/app/settings.service.ts @@ -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(DEFAULT_SETTINGS.darkTone); readonly accent = signal(DEFAULT_SETTINGS.accent); readonly iconColors = signal(DEFAULT_SETTINGS.iconColors); + readonly columns = signal(DEFAULT_SETTINGS.columns); readonly speedUnit = signal(DEFAULT_SETTINGS.speedUnit); readonly textSize = signal(DEFAULT_SETTINGS.textSize); readonly transferMode = signal(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(),