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
+29 -1
View File
@@ -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[] = [];
+38
View File
@@ -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<string, string | undefined>();
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),
};
}
+13
View File
@@ -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),
})),
);
});
+6
View File
@@ -275,6 +275,12 @@ export function createAppRPC({
return {};
}),
showColumnsMenu: ({ visible }) =>
wrap(async () => {
contextMenu.showColumns(visible);
return {};
}),
// ---- editor windows ----
openEditor: (ctx) =>
wrap(async () => {