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:
+29
-1
@@ -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[] = [];
|
||||
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -275,6 +275,12 @@ export function createAppRPC({
|
||||
return {};
|
||||
}),
|
||||
|
||||
showColumnsMenu: ({ visible }) =>
|
||||
wrap(async () => {
|
||||
contextMenu.showColumns(visible);
|
||||
return {};
|
||||
}),
|
||||
|
||||
// ---- editor windows ----
|
||||
openEditor: (ctx) =>
|
||||
wrap(async () => {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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<T = object> = ({ 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",
|
||||
|
||||
Reference in New Issue
Block a user