Compare commits
2
Commits
485603fd30
...
14b92b33a4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14b92b33a4 | ||
|
|
ab59c785b5 |
+29
-1
@@ -1,4 +1,15 @@
|
|||||||
import { ContextMenu, Utils, type ApplicationMenuItemConfig } from "electrobun/bun";
|
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 {
|
export interface RowMenuContext {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -22,8 +33,14 @@ export class PanelContextMenu {
|
|||||||
constructor() {
|
constructor() {
|
||||||
ContextMenu.on("context-menu-clicked", (event) => {
|
ContextMenu.on("context-menu-clicked", (event) => {
|
||||||
const action = (event as { data?: { action?: string } }).data?.action;
|
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;
|
const ctx = this.context;
|
||||||
if (!action || !ctx) return;
|
if (!ctx) return;
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case "ctx-reveal":
|
case "ctx-reveal":
|
||||||
Utils.showItemInFolder(ctx.path);
|
Utils.showItemInFolder(ctx.path);
|
||||||
@@ -45,6 +62,17 @@ export class PanelContextMenu {
|
|||||||
ContextMenu.showContextMenu(this.build(context));
|
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[] {
|
private build(ctx: RowMenuContext): ApplicationMenuItemConfig[] {
|
||||||
const items: ApplicationMenuItemConfig[] = [];
|
const items: ApplicationMenuItemConfig[] = [];
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,42 @@ function typeOf(st: Stats): EntryType {
|
|||||||
return st.isDirectory() ? "dir" : "file";
|
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 {
|
function toEntry(name: string, full: string, st: Stats): FileEntry {
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
@@ -19,6 +55,8 @@ function toEntry(name: string, full: string, st: Stats): FileEntry {
|
|||||||
size: st.size,
|
size: st.size,
|
||||||
mtime: st.mtimeMs,
|
mtime: st.mtimeMs,
|
||||||
mode: st.mode & 0o7777,
|
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_IFDIR = 0o040000;
|
||||||
const S_IFLNK = 0o120000;
|
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 {
|
function typeFromMode(mode: number): EntryType {
|
||||||
const fmt = mode & S_IFMT;
|
const fmt = mode & S_IFMT;
|
||||||
if (fmt === S_IFDIR) return "dir";
|
if (fmt === S_IFDIR) return "dir";
|
||||||
@@ -132,6 +144,7 @@ export class SftpProvider implements FileSystemProvider {
|
|||||||
size: e.attrs.size,
|
size: e.attrs.size,
|
||||||
mtime: e.attrs.mtime * 1000, // sftp attrs are seconds
|
mtime: e.attrs.mtime * 1000, // sftp attrs are seconds
|
||||||
mode: e.attrs.mode & 0o7777,
|
mode: e.attrs.mode & 0o7777,
|
||||||
|
...ownerFromLongname(e.longname),
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -275,6 +275,12 @@ export function createAppRPC({
|
|||||||
return {};
|
return {};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
showColumnsMenu: ({ visible }) =>
|
||||||
|
wrap(async () => {
|
||||||
|
contextMenu.showColumns(visible);
|
||||||
|
return {};
|
||||||
|
}),
|
||||||
|
|
||||||
// ---- editor windows ----
|
// ---- editor windows ----
|
||||||
openEditor: (ctx) =>
|
openEditor: (ctx) =>
|
||||||
wrap(async () => {
|
wrap(async () => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
AppSettings,
|
AppSettings,
|
||||||
|
ColumnKey,
|
||||||
ConnectionConfig,
|
ConnectionConfig,
|
||||||
ConnectionsFile,
|
ConnectionsFile,
|
||||||
EntryType,
|
EntryType,
|
||||||
@@ -134,6 +135,8 @@ export type AppRPC = {
|
|||||||
response: Result;
|
response: Result;
|
||||||
};
|
};
|
||||||
moveToTrash: { params: { path: string }; 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
|
// editor windows
|
||||||
/** Main window only: open (or focus) a native editor window. */
|
/** Main window only: open (or focus) a native editor window. */
|
||||||
openEditor: { params: EditorOpenContext; response: Result };
|
openEditor: { params: EditorOpenContext; response: Result };
|
||||||
|
|||||||
@@ -7,8 +7,13 @@ export interface FileEntry {
|
|||||||
size: number; // bytes
|
size: number; // bytes
|
||||||
mtime: number; // epoch milliseconds
|
mtime: number; // epoch milliseconds
|
||||||
mode: number; // permission bits only (masked & 0o7777)
|
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. */
|
/** 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 };
|
export type Result<T = object> = ({ ok: true } & T) | { ok: false; error: string };
|
||||||
|
|
||||||
@@ -75,6 +80,8 @@ export interface AppSettings {
|
|||||||
accent: AccentTone;
|
accent: AccentTone;
|
||||||
/** File-list icons: plain neutral glyphs, or per-category glyphs and colors. */
|
/** File-list icons: plain neutral glyphs, or per-category glyphs and colors. */
|
||||||
iconColors: "plain" | "multi";
|
iconColors: "plain" | "multi";
|
||||||
|
/** Visible optional file-list columns (both panels; Name always shows). */
|
||||||
|
columns: ColumnKey[];
|
||||||
speedUnit: "MBps" | "Mbps";
|
speedUnit: "MBps" | "Mbps";
|
||||||
/** Size of the panel interface: file list, path bar, header controls. */
|
/** Size of the panel interface: file list, path bar, header controls. */
|
||||||
textSize: "compact" | "default" | "large";
|
textSize: "compact" | "default" | "large";
|
||||||
@@ -95,6 +102,7 @@ export const DEFAULT_SETTINGS: AppSettings = {
|
|||||||
darkTone: "dark",
|
darkTone: "dark",
|
||||||
accent: "blue",
|
accent: "blue",
|
||||||
iconColors: "multi",
|
iconColors: "multi",
|
||||||
|
columns: ["size", "mtime"],
|
||||||
speedUnit: "MBps",
|
speedUnit: "MBps",
|
||||||
textSize: "default",
|
textSize: "default",
|
||||||
transferMode: "single",
|
transferMode: "single",
|
||||||
|
|||||||
+16
-1
@@ -1,6 +1,7 @@
|
|||||||
import { Component, inject, signal } from '@angular/core';
|
import { Component, inject, signal } from '@angular/core';
|
||||||
import { NgIcon } from '@ng-icons/core';
|
import { NgIcon } from '@ng-icons/core';
|
||||||
import { BRANDING } from '@shared/branding';
|
import { BRANDING } from '@shared/branding';
|
||||||
|
import type { ColumnKey } from '@shared/types';
|
||||||
import { ConnectionsService } from './connections.service';
|
import { ConnectionsService } from './connections.service';
|
||||||
import { ChmodDialog } from './dialogs/chmod-dialog';
|
import { ChmodDialog } from './dialogs/chmod-dialog';
|
||||||
import { ConfirmDialog } from './dialogs/confirm-dialog';
|
import { ConfirmDialog } from './dialogs/confirm-dialog';
|
||||||
@@ -10,7 +11,9 @@ import { DialogsService } from './dialogs.service';
|
|||||||
import { FileActionsService } from './file-actions.service';
|
import { FileActionsService } from './file-actions.service';
|
||||||
import { FilePanel } from './file-panel/file-panel';
|
import { FilePanel } from './file-panel/file-panel';
|
||||||
import { PanelsService } from './panels.service';
|
import { PanelsService } from './panels.service';
|
||||||
|
import { blockNativeContextMenu } from './platform';
|
||||||
import { RpcService } from './rpc.service';
|
import { RpcService } from './rpc.service';
|
||||||
|
import { SettingsService } from './settings.service';
|
||||||
import { StatusBar } from './status-bar/status-bar';
|
import { StatusBar } from './status-bar/status-bar';
|
||||||
import { TabBar } from './tab-bar/tab-bar';
|
import { TabBar } from './tab-bar/tab-bar';
|
||||||
import { ToastService } from './toast.service';
|
import { ToastService } from './toast.service';
|
||||||
@@ -33,7 +36,10 @@ import { TransfersService } from './transfers.service';
|
|||||||
TransferBar,
|
TransferBar,
|
||||||
],
|
],
|
||||||
templateUrl: './app.html',
|
templateUrl: './app.html',
|
||||||
host: { '(window:keydown)': 'onWindowKeydown($event)' },
|
host: {
|
||||||
|
'(window:keydown)': 'onWindowKeydown($event)',
|
||||||
|
'(window:contextmenu)': 'blockContextMenu($event)',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
export class App {
|
export class App {
|
||||||
readonly rpc = inject(RpcService);
|
readonly rpc = inject(RpcService);
|
||||||
@@ -43,6 +49,7 @@ export class App {
|
|||||||
readonly actions = inject(FileActionsService);
|
readonly actions = inject(FileActionsService);
|
||||||
readonly connections = inject(ConnectionsService);
|
readonly connections = inject(ConnectionsService);
|
||||||
private readonly transfers = inject(TransfersService);
|
private readonly transfers = inject(TransfersService);
|
||||||
|
private readonly settings = inject(SettingsService);
|
||||||
|
|
||||||
/** The connection-manager window is open — this window is modal-blocked. */
|
/** The connection-manager window is open — this window is modal-blocked. */
|
||||||
readonly managerOpen = signal(false);
|
readonly managerOpen = signal(false);
|
||||||
@@ -60,6 +67,10 @@ export class App {
|
|||||||
void this.rpc.request.openConnectionManager({});
|
void this.rpc.request.openConnectionManager({});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
blockContextMenu(event: Event): void {
|
||||||
|
blockNativeContextMenu(event);
|
||||||
|
}
|
||||||
|
|
||||||
/** Temp files from external edits in a previous run (crash / quit mid-edit). */
|
/** Temp files from external edits in a previous run (crash / quit mid-edit). */
|
||||||
private async promptForEditLeftovers(): Promise<void> {
|
private async promptForEditLeftovers(): Promise<void> {
|
||||||
const res = await this.rpc.request.getEditLeftovers({});
|
const res = await this.rpc.request.getEditLeftovers({});
|
||||||
@@ -97,6 +108,10 @@ export class App {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (action.startsWith('col:')) {
|
||||||
|
this.settings.toggleColumn(action.slice('col:'.length) as ColumnKey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (action.startsWith('open-stack:')) {
|
if (action.startsWith('open-stack:')) {
|
||||||
const id = action.slice('open-stack:'.length);
|
const id = action.slice('open-stack:'.length);
|
||||||
let stackConnections = this.connections.connections().filter((c) => c.stackId === id);
|
let stackConnections = this.connections.connections().filter((c) => c.stackId === id);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { ConnectionsService } from '../connections.service';
|
|||||||
import { ConfirmDialog } from '../dialogs/confirm-dialog';
|
import { ConfirmDialog } from '../dialogs/confirm-dialog';
|
||||||
import { PromptDialog } from '../dialogs/prompt-dialog';
|
import { PromptDialog } from '../dialogs/prompt-dialog';
|
||||||
import { DialogsService } from '../dialogs.service';
|
import { DialogsService } from '../dialogs.service';
|
||||||
import { IS_MAC_CHROME } from '../platform';
|
import { blockNativeContextMenu, IS_MAC_CHROME } from '../platform';
|
||||||
import { RpcService } from '../rpc.service';
|
import { RpcService } from '../rpc.service';
|
||||||
import { SettingsService } from '../settings.service';
|
import { SettingsService } from '../settings.service';
|
||||||
import { Toasts } from '../toasts/toasts';
|
import { Toasts } from '../toasts/toasts';
|
||||||
@@ -73,7 +73,10 @@ function fromDraft(d: Draft): SavedConnection {
|
|||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
imports: [NgIcon, ConfirmDialog, PromptDialog, Toasts],
|
imports: [NgIcon, ConfirmDialog, PromptDialog, Toasts],
|
||||||
templateUrl: './connection-manager.html',
|
templateUrl: './connection-manager.html',
|
||||||
host: { '(window:keydown.escape)': 'onEscape()' },
|
host: {
|
||||||
|
'(window:keydown.escape)': 'onEscape()',
|
||||||
|
'(window:contextmenu)': 'blockContextMenu($event)',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
export class ConnectionManager {
|
export class ConnectionManager {
|
||||||
readonly store = inject(ConnectionsService);
|
readonly store = inject(ConnectionsService);
|
||||||
@@ -138,6 +141,10 @@ export class ConnectionManager {
|
|||||||
if (!this.dialogs.anyOpen) this.close();
|
if (!this.dialogs.anyOpen) this.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
blockContextMenu(event: Event): void {
|
||||||
|
blockNativeContextMenu(event);
|
||||||
|
}
|
||||||
|
|
||||||
async save(): Promise<string | null> {
|
async save(): Promise<string | null> {
|
||||||
const d = this.draft();
|
const d = this.draft();
|
||||||
if (!d || !this.valid()) return null;
|
if (!d || !this.valid()) return null;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
viewChild,
|
viewChild,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { NgIcon } from '@ng-icons/core';
|
import { NgIcon } from '@ng-icons/core';
|
||||||
import { EDITOR_CONTEXT, IS_MAC_CHROME } from '../platform';
|
import { blockNativeContextMenu, EDITOR_CONTEXT, IS_MAC_CHROME } from '../platform';
|
||||||
import { RpcService } from '../rpc.service';
|
import { RpcService } from '../rpc.service';
|
||||||
import { SettingsService } from '../settings.service';
|
import { SettingsService } from '../settings.service';
|
||||||
import { ToastService } from '../toast.service';
|
import { ToastService } from '../toast.service';
|
||||||
@@ -31,7 +31,11 @@ const DRAFT_DEBOUNCE_MS = 400;
|
|||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
imports: [NgIcon, Toasts],
|
imports: [NgIcon, Toasts],
|
||||||
templateUrl: './editor-window.html',
|
templateUrl: './editor-window.html',
|
||||||
host: { '(window:keydown)': 'onKeydown($event)', '(window:blur)': 'flushDraft()' },
|
host: {
|
||||||
|
'(window:keydown)': 'onKeydown($event)',
|
||||||
|
'(window:blur)': 'flushDraft()',
|
||||||
|
'(window:contextmenu)': 'blockContextMenu($event)',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
export class EditorWindow {
|
export class EditorWindow {
|
||||||
readonly ctx = EDITOR_CONTEXT;
|
readonly ctx = EDITOR_CONTEXT;
|
||||||
@@ -126,6 +130,10 @@ export class EditorWindow {
|
|||||||
queueMicrotask(() => this.box()?.nativeElement.focus());
|
queueMicrotask(() => this.box()?.nativeElement.focus());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
blockContextMenu(event: Event): void {
|
||||||
|
blockNativeContextMenu(event);
|
||||||
|
}
|
||||||
|
|
||||||
onInput(value: string): void {
|
onInput(value: string): void {
|
||||||
this.text.set(value);
|
this.text.set(value);
|
||||||
this.scheduleDraft();
|
this.scheduleDraft();
|
||||||
|
|||||||
@@ -64,6 +64,24 @@ function categoryOf(name: string): Category | null {
|
|||||||
return EXT_CATEGORY.get(key) ?? 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 {
|
export function fileGlyph(entry: FileEntry, multi: boolean): string {
|
||||||
if (entry.name === '..') return 'mynaArrowUp';
|
if (entry.name === '..') return 'mynaArrowUp';
|
||||||
if (entry.type === 'dir') return 'mynaFolder';
|
if (entry.type === 'dir') return 'mynaFolder';
|
||||||
|
|||||||
@@ -178,19 +178,25 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- column headers -->
|
<!-- column headers (right-click to add/remove columns) -->
|
||||||
<div
|
<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')">
|
<button class="px-2 py-1 text-left hover:text-foreground" (click)="panel().toggleSort('name')">
|
||||||
Name {{ sortIndicator('name') }}
|
Name {{ sortIndicator('name') }}
|
||||||
</button>
|
</button>
|
||||||
<button class="px-2 py-1 text-right hover:text-foreground" (click)="panel().toggleSort('size')">
|
@for (col of visibleColumns(); track col.key) {
|
||||||
Size {{ sortIndicator('size') }}
|
<button
|
||||||
</button>
|
class="px-2 py-1 hover:text-foreground"
|
||||||
<button class="px-2 py-1 text-left hover:text-foreground" (click)="panel().toggleSort('mtime')">
|
[class.text-right]="col.key === 'size'"
|
||||||
Modified {{ sortIndicator('mtime') }}
|
[class.text-left]="col.key !== 'size'"
|
||||||
</button>
|
(click)="panel().toggleSort(col.key)"
|
||||||
|
>
|
||||||
|
{{ col.label }} {{ sortIndicator(col.key) }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- type-to-filter bar (appears while a query is active) -->
|
<!-- type-to-filter bar (appears while a query is active) -->
|
||||||
@@ -243,7 +249,8 @@
|
|||||||
(click)="rowClick($event, i)"
|
(click)="rowClick($event, i)"
|
||||||
(dblclick)="openAt(i)"
|
(dblclick)="openAt(i)"
|
||||||
(contextmenu)="rowContextMenu($event, 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-cursor-tint]="i === panel().cursor() && !isSelected(entry)"
|
||||||
[class.bg-primary/15]="isSelected(entry)"
|
[class.bg-primary/15]="isSelected(entry)"
|
||||||
[class.ring-1]="i === panel().cursor() && isActive()"
|
[class.ring-1]="i === panel().cursor() && isActive()"
|
||||||
@@ -254,12 +261,40 @@
|
|||||||
<ng-icon [name]="iconFor(entry)" size="0.95rem" [class]="iconClass(entry)" />
|
<ng-icon [name]="iconFor(entry)" size="0.95rem" [class]="iconClass(entry)" />
|
||||||
<span class="truncate" [class.italic]="entry.type === 'symlink'">{{ entry.name }}</span>
|
<span class="truncate" [class.italic]="entry.type === 'symlink'">{{ entry.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="px-2 text-right font-mono text-(length:--list-meta-font) tabular-nums text-muted-foreground">
|
@for (col of visibleColumns(); track col.key) {
|
||||||
{{ entry.type === 'dir' ? '—' : formatSize(entry.size) }}
|
@switch (col.key) {
|
||||||
</span>
|
@case ('size') {
|
||||||
<span class="px-2 font-mono text-(length:--list-meta-font) tabular-nums text-muted-foreground">
|
<span class="px-2 text-right font-mono text-(length:--list-meta-font) tabular-nums text-muted-foreground">
|
||||||
{{ formatDate(entry.mtime) }}
|
{{ entry.type === 'dir' ? '—' : formatSize(entry.size) }}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
} @empty {
|
} @empty {
|
||||||
<div class="p-6 text-center text-xs text-muted-foreground">Empty directory</div>
|
<div class="p-6 text-center text-xs text-muted-foreground">Empty directory</div>
|
||||||
|
|||||||
@@ -9,16 +9,27 @@ import {
|
|||||||
viewChild,
|
viewChild,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { NgIcon } from '@ng-icons/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 { ConnectionsService } from '../connections.service';
|
||||||
import { DialogsService } from '../dialogs.service';
|
import { DialogsService } from '../dialogs.service';
|
||||||
import { FileActionsService } from '../file-actions.service';
|
import { FileActionsService } from '../file-actions.service';
|
||||||
import { fileGlyph, fileIconClass } from '../file-icons';
|
import { fileGlyph, fileIconClass, fileKind } from '../file-icons';
|
||||||
import { formatDate, formatSize } from '../format';
|
import { formatDate, formatMode, formatSize } from '../format';
|
||||||
import { PanelsService, type Side, type SortKey } from '../panels.service';
|
import { PanelsService, type Side, type SortKey } from '../panels.service';
|
||||||
|
import { RpcService } from '../rpc.service';
|
||||||
import { SettingsService } from '../settings.service';
|
import { SettingsService } from '../settings.service';
|
||||||
import { TransfersService } from '../transfers.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({
|
@Component({
|
||||||
selector: 'app-file-panel',
|
selector: 'app-file-panel',
|
||||||
imports: [NgIcon],
|
imports: [NgIcon],
|
||||||
@@ -34,6 +45,7 @@ export class FilePanel {
|
|||||||
readonly connections = inject(ConnectionsService);
|
readonly connections = inject(ConnectionsService);
|
||||||
readonly dialogs = inject(DialogsService);
|
readonly dialogs = inject(DialogsService);
|
||||||
private readonly settings = inject(SettingsService);
|
private readonly settings = inject(SettingsService);
|
||||||
|
private readonly rpc = inject(RpcService);
|
||||||
readonly panel = computed(() => this.panels.get(this.side()));
|
readonly panel = computed(() => this.panels.get(this.side()));
|
||||||
readonly isActive = computed(() => this.panels.activeSide() === this.side());
|
readonly isActive = computed(() => this.panels.activeSide() === this.side());
|
||||||
readonly isRemote = computed(() => this.panel().sessionId() !== 'local');
|
readonly isRemote = computed(() => this.panel().sessionId() !== 'local');
|
||||||
@@ -50,6 +62,22 @@ export class FilePanel {
|
|||||||
|
|
||||||
readonly formatSize = formatSize;
|
readonly formatSize = formatSize;
|
||||||
readonly formatDate = formatDate;
|
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() {
|
constructor() {
|
||||||
// keep the editable path bar in sync with the actual cwd
|
// 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 { 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 { FocusService } from './focus.service';
|
||||||
|
import { formatMode } from './format';
|
||||||
import { RpcService } from './rpc.service';
|
import { RpcService } from './rpc.service';
|
||||||
import { ToastService } from './toast.service';
|
import { ToastService } from './toast.service';
|
||||||
|
|
||||||
export type Side = 'left' | 'right';
|
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 {
|
interface PanelDeps {
|
||||||
rpc: RpcService;
|
rpc: RpcService;
|
||||||
@@ -55,6 +65,7 @@ export class PanelState {
|
|||||||
let cmp = 0;
|
let cmp = 0;
|
||||||
if (key === 'size') cmp = a.size - b.size;
|
if (key === 'size') cmp = a.size - b.size;
|
||||||
else if (key === 'mtime') cmp = a.mtime - b.mtime;
|
else if (key === 'mtime') cmp = a.mtime - b.mtime;
|
||||||
|
else if (key !== 'name') cmp = sortText(a, key).localeCompare(sortText(b, key));
|
||||||
if (cmp === 0) {
|
if (cmp === 0) {
|
||||||
cmp = a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
|
cmp = a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
|
||||||
if (key === 'name') return cmp * dir;
|
if (key === 'name') return cmp * dir;
|
||||||
|
|||||||
@@ -23,3 +23,14 @@ export const APP_MODE: 'main' | 'editor' | 'manager' =
|
|||||||
|
|
||||||
export const EDITOR_CONTEXT =
|
export const EDITOR_CONTEXT =
|
||||||
typeof window !== 'undefined' ? (window.__APP_CTX as EditorOpenContext | undefined) : undefined;
|
typeof window !== 'undefined' ? (window.__APP_CTX as EditorOpenContext | undefined) : undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kill the webview's default right-click menu (Reload, Inspect Element…)
|
||||||
|
* everywhere the app doesn't show its own native menu. Text fields keep the
|
||||||
|
* system text menu (cut/copy/paste, spelling).
|
||||||
|
*/
|
||||||
|
export function blockNativeContextMenu(event: Event): void {
|
||||||
|
const target = event.target as HTMLElement | null;
|
||||||
|
if (target?.closest('input, textarea, [contenteditable="true"]')) return;
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
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';
|
import { RpcService } from './rpc.service';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
@@ -8,6 +8,7 @@ export class SettingsService {
|
|||||||
readonly darkTone = signal<AppSettings['darkTone']>(DEFAULT_SETTINGS.darkTone);
|
readonly darkTone = signal<AppSettings['darkTone']>(DEFAULT_SETTINGS.darkTone);
|
||||||
readonly accent = signal<AppSettings['accent']>(DEFAULT_SETTINGS.accent);
|
readonly accent = signal<AppSettings['accent']>(DEFAULT_SETTINGS.accent);
|
||||||
readonly iconColors = signal<AppSettings['iconColors']>(DEFAULT_SETTINGS.iconColors);
|
readonly iconColors = signal<AppSettings['iconColors']>(DEFAULT_SETTINGS.iconColors);
|
||||||
|
readonly columns = signal<ColumnKey[]>(DEFAULT_SETTINGS.columns);
|
||||||
readonly speedUnit = signal<AppSettings['speedUnit']>(DEFAULT_SETTINGS.speedUnit);
|
readonly speedUnit = signal<AppSettings['speedUnit']>(DEFAULT_SETTINGS.speedUnit);
|
||||||
readonly textSize = signal<AppSettings['textSize']>(DEFAULT_SETTINGS.textSize);
|
readonly textSize = signal<AppSettings['textSize']>(DEFAULT_SETTINGS.textSize);
|
||||||
readonly transferMode = signal<AppSettings['transferMode']>(DEFAULT_SETTINGS.transferMode);
|
readonly transferMode = signal<AppSettings['transferMode']>(DEFAULT_SETTINGS.transferMode);
|
||||||
@@ -43,6 +44,7 @@ export class SettingsService {
|
|||||||
this.darkTone.set(settings.darkTone);
|
this.darkTone.set(settings.darkTone);
|
||||||
this.accent.set(settings.accent);
|
this.accent.set(settings.accent);
|
||||||
this.iconColors.set(settings.iconColors);
|
this.iconColors.set(settings.iconColors);
|
||||||
|
this.columns.set(settings.columns);
|
||||||
this.speedUnit.set(settings.speedUnit);
|
this.speedUnit.set(settings.speedUnit);
|
||||||
this.textSize.set(settings.textSize);
|
this.textSize.set(settings.textSize);
|
||||||
this.transferMode.set(settings.transferMode);
|
this.transferMode.set(settings.transferMode);
|
||||||
@@ -80,6 +82,13 @@ export class SettingsService {
|
|||||||
void this.persist();
|
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 {
|
setSpeedUnit(unit: AppSettings['speedUnit']): void {
|
||||||
this.speedUnit.set(unit);
|
this.speedUnit.set(unit);
|
||||||
void this.persist();
|
void this.persist();
|
||||||
@@ -158,6 +167,7 @@ export class SettingsService {
|
|||||||
darkTone: this.darkTone(),
|
darkTone: this.darkTone(),
|
||||||
accent: this.accent(),
|
accent: this.accent(),
|
||||||
iconColors: this.iconColors(),
|
iconColors: this.iconColors(),
|
||||||
|
columns: this.columns(),
|
||||||
speedUnit: this.speedUnit(),
|
speedUnit: this.speedUnit(),
|
||||||
textSize: this.textSize(),
|
textSize: this.textSize(),
|
||||||
transferMode: this.transferMode(),
|
transferMode: this.transferMode(),
|
||||||
|
|||||||
Reference in New Issue
Block a user