File-type icon colors with Plain/Multi setting (multi default)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
// File-list glyphs + per-category colors ("Multi" icon mode). Category colors
|
||||
// are fixed tokens (never follow the accent) so a folder is always folder-blue.
|
||||
// Only the icon glyph is colored; row text stays neutral. Docs and unknown
|
||||
// files stay muted on purpose — sparing color is what keeps it scannable.
|
||||
import type { FileEntry } from '@shared/types';
|
||||
|
||||
type Category = 'image' | 'audio' | 'video' | 'archive' | 'code' | 'doc';
|
||||
|
||||
// Class strings are literals so the Tailwind scanner picks them up.
|
||||
const MUTED = 'shrink-0 text-muted-foreground';
|
||||
const PRIMARY = 'shrink-0 text-primary';
|
||||
const FOLDER = 'shrink-0 text-icon-folder';
|
||||
const LINK = 'shrink-0 text-icon-link';
|
||||
|
||||
const CATEGORIES: Record<Category, { glyph: string; class: string; extensions: string[] }> = {
|
||||
image: {
|
||||
glyph: 'mynaImage',
|
||||
class: 'shrink-0 text-icon-image',
|
||||
extensions: 'png jpg jpeg gif webp avif svg ico icns bmp tiff tif heic heif raw'.split(' '),
|
||||
},
|
||||
audio: {
|
||||
glyph: 'mynaMusic',
|
||||
class: 'shrink-0 text-icon-media',
|
||||
extensions: 'mp3 wav flac aac ogg oga m4a opus wma aiff aif mid midi'.split(' '),
|
||||
},
|
||||
video: {
|
||||
glyph: 'mynaVideo',
|
||||
class: 'shrink-0 text-icon-media',
|
||||
extensions: 'mp4 mkv mov avi webm m4v mpg mpeg wmv flv 3gp'.split(' '),
|
||||
},
|
||||
archive: {
|
||||
glyph: 'mynaArchive',
|
||||
class: 'shrink-0 text-icon-archive',
|
||||
extensions: 'zip tar gz tgz bz2 xz zst 7z rar dmg iso jar deb rpm pkg apk'.split(' '),
|
||||
},
|
||||
code: {
|
||||
glyph: 'mynaCode',
|
||||
class: 'shrink-0 text-icon-code',
|
||||
// ".ts" is TypeScript here, not an MPEG transport stream (deliberate)
|
||||
extensions: (
|
||||
'js mjs cjs ts tsx jsx json jsonc yaml yml toml xml html htm css scss sass less ' +
|
||||
'py rb go rs java kt kts swift c h cpp hpp cc m mm cs php sh bash zsh fish ps1 bat ' +
|
||||
'sql lua pl r vue svelte astro ini conf cfg env plist gradle cmake nix proto graphql ' +
|
||||
'dart ex exs zig lock makefile dockerfile zshrc bashrc bash_profile profile ' +
|
||||
'gitignore gitconfig gitattributes editorconfig npmrc nvmrc'
|
||||
).split(' '),
|
||||
},
|
||||
doc: {
|
||||
glyph: 'mynaFileText',
|
||||
class: MUTED, // docs keep the neutral color; only the glyph changes
|
||||
extensions: 'md markdown txt rtf pdf doc docx xls xlsx ppt pptx csv tsv log odt ods odp epub'.split(' '),
|
||||
},
|
||||
};
|
||||
|
||||
const EXT_CATEGORY = new Map<string, Category>();
|
||||
for (const category of Object.keys(CATEGORIES) as Category[]) {
|
||||
for (const ext of CATEGORIES[category].extensions) EXT_CATEGORY.set(ext, category);
|
||||
}
|
||||
|
||||
/** "a.tar.gz" -> "gz"; ".zshrc" -> "zshrc"; "Makefile" (no dot) -> "makefile". */
|
||||
function categoryOf(name: string): Category | null {
|
||||
const dot = name.lastIndexOf('.');
|
||||
const key = (dot >= 0 ? name.slice(dot + 1) : name).toLowerCase();
|
||||
return EXT_CATEGORY.get(key) ?? null;
|
||||
}
|
||||
|
||||
export function fileGlyph(entry: FileEntry, multi: boolean): string {
|
||||
if (entry.name === '..') return 'mynaArrowUp';
|
||||
if (entry.type === 'dir') return 'mynaFolder';
|
||||
if (!multi) return 'mynaFile';
|
||||
if (entry.type === 'symlink') return 'mynaLink';
|
||||
const category = categoryOf(entry.name);
|
||||
return category ? CATEGORIES[category].glyph : 'mynaFile';
|
||||
}
|
||||
|
||||
export function fileIconClass(entry: FileEntry, multi: boolean): string {
|
||||
// plain mode: exactly the pre-color look ('..' has type 'dir' -> primary)
|
||||
if (!multi) return entry.type === 'dir' ? PRIMARY : MUTED;
|
||||
if (entry.name === '..') return MUTED;
|
||||
if (entry.type === 'dir') return FOLDER;
|
||||
if (entry.type === 'symlink') return LINK;
|
||||
const category = categoryOf(entry.name);
|
||||
return category ? CATEGORIES[category].class : MUTED;
|
||||
}
|
||||
@@ -227,11 +227,7 @@
|
||||
[class.ring-ring]="i === panel().cursor() && isActive()"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-1.5 px-2">
|
||||
<ng-icon
|
||||
[name]="iconFor(entry)"
|
||||
size="0.95rem"
|
||||
[class]="entry.type === 'dir' ? 'shrink-0 text-primary' : 'shrink-0 text-muted-foreground'"
|
||||
/>
|
||||
<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">
|
||||
|
||||
@@ -13,8 +13,10 @@ import type { FileEntry, SavedConnection } 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 { PanelsService, type Side, type SortKey } from '../panels.service';
|
||||
import { SettingsService } from '../settings.service';
|
||||
import { TransfersService } from '../transfers.service';
|
||||
|
||||
@Component({
|
||||
@@ -31,6 +33,7 @@ export class FilePanel {
|
||||
readonly transfers = inject(TransfersService);
|
||||
readonly connections = inject(ConnectionsService);
|
||||
readonly dialogs = inject(DialogsService);
|
||||
private readonly settings = inject(SettingsService);
|
||||
readonly panel = computed(() => this.panels.get(this.side()));
|
||||
readonly isActive = computed(() => this.panels.activeSide() === this.side());
|
||||
readonly isRemote = computed(() => this.panel().sessionId() !== 'local');
|
||||
@@ -92,8 +95,11 @@ export class FilePanel {
|
||||
}
|
||||
|
||||
iconFor(entry: FileEntry): string {
|
||||
if (entry.name === '..') return 'mynaArrowUp';
|
||||
return entry.type === 'dir' ? 'mynaFolder' : 'mynaFile';
|
||||
return fileGlyph(entry, this.settings.iconColors() === 'multi');
|
||||
}
|
||||
|
||||
iconClass(entry: FileEntry): string {
|
||||
return fileIconClass(entry, this.settings.iconColors() === 'multi');
|
||||
}
|
||||
|
||||
isSelected(entry: FileEntry): boolean {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// NOTE: @ng-icons/mynaui's package root is empty — always import from /outline
|
||||
// (or /solid, where names get a `Solid` suffix).
|
||||
import {
|
||||
mynaArchive,
|
||||
mynaArrowLeft,
|
||||
mynaArrowLeftRight,
|
||||
mynaArrowRight,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
mynaCheckCircle,
|
||||
mynaChevronDown,
|
||||
mynaChevronRight,
|
||||
mynaCode,
|
||||
mynaCog,
|
||||
mynaConfigVertical,
|
||||
mynaEdit,
|
||||
@@ -19,19 +21,24 @@ import {
|
||||
mynaFolder,
|
||||
mynaFolderPlus,
|
||||
mynaHardDrive,
|
||||
mynaImage,
|
||||
mynaLink,
|
||||
mynaLock,
|
||||
mynaLogout,
|
||||
mynaMusic,
|
||||
mynaPlus,
|
||||
mynaRefresh,
|
||||
mynaSearch,
|
||||
mynaServers,
|
||||
mynaSpinner,
|
||||
mynaTrash,
|
||||
mynaVideo,
|
||||
mynaX,
|
||||
mynaXCircle,
|
||||
} from "@ng-icons/mynaui/outline";
|
||||
|
||||
export const APP_ICONS = {
|
||||
mynaArchive,
|
||||
mynaArrowLeft,
|
||||
mynaArrowLeftRight,
|
||||
mynaArrowRight,
|
||||
@@ -40,6 +47,7 @@ export const APP_ICONS = {
|
||||
mynaCheckCircle,
|
||||
mynaChevronDown,
|
||||
mynaChevronRight,
|
||||
mynaCode,
|
||||
mynaCog,
|
||||
mynaConfigVertical,
|
||||
mynaEdit,
|
||||
@@ -49,14 +57,18 @@ export const APP_ICONS = {
|
||||
mynaFolder,
|
||||
mynaFolderPlus,
|
||||
mynaHardDrive,
|
||||
mynaImage,
|
||||
mynaLink,
|
||||
mynaLock,
|
||||
mynaLogout,
|
||||
mynaMusic,
|
||||
mynaPlus,
|
||||
mynaRefresh,
|
||||
mynaSearch,
|
||||
mynaServers,
|
||||
mynaSpinner,
|
||||
mynaTrash,
|
||||
mynaVideo,
|
||||
mynaX,
|
||||
mynaXCircle,
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import { RpcService } from './rpc.service';
|
||||
export class SettingsService {
|
||||
readonly theme = signal<AppSettings['theme']>(DEFAULT_SETTINGS.theme);
|
||||
readonly accent = signal<AppSettings['accent']>(DEFAULT_SETTINGS.accent);
|
||||
readonly iconColors = signal<AppSettings['iconColors']>(DEFAULT_SETTINGS.iconColors);
|
||||
readonly speedUnit = signal<AppSettings['speedUnit']>(DEFAULT_SETTINGS.speedUnit);
|
||||
readonly textSize = signal<AppSettings['textSize']>(DEFAULT_SETTINGS.textSize);
|
||||
readonly transferMode = signal<AppSettings['transferMode']>(DEFAULT_SETTINGS.transferMode);
|
||||
@@ -24,6 +25,7 @@ export class SettingsService {
|
||||
if (!res.ok) return;
|
||||
this.theme.set(res.settings.theme);
|
||||
this.accent.set(res.settings.accent);
|
||||
this.iconColors.set(res.settings.iconColors);
|
||||
this.speedUnit.set(res.settings.speedUnit);
|
||||
this.textSize.set(res.settings.textSize);
|
||||
this.transferMode.set(res.settings.transferMode);
|
||||
@@ -49,6 +51,12 @@ export class SettingsService {
|
||||
void this.persist();
|
||||
}
|
||||
|
||||
setIconColors(mode: AppSettings['iconColors']): void {
|
||||
// no apply step — the file panels read the signal reactively
|
||||
this.iconColors.set(mode);
|
||||
void this.persist();
|
||||
}
|
||||
|
||||
setSpeedUnit(unit: AppSettings['speedUnit']): void {
|
||||
this.speedUnit.set(unit);
|
||||
void this.persist();
|
||||
@@ -116,6 +124,7 @@ export class SettingsService {
|
||||
return {
|
||||
theme: this.theme(),
|
||||
accent: this.accent(),
|
||||
iconColors: this.iconColors(),
|
||||
speedUnit: this.speedUnit(),
|
||||
textSize: this.textSize(),
|
||||
transferMode: this.transferMode(),
|
||||
|
||||
@@ -24,6 +24,7 @@ const SPEED_UNITS = [
|
||||
{ value: 'Mbps', label: 'Mbps' },
|
||||
] as const;
|
||||
const TEXT_SIZES = ['compact', 'default', 'large'] as const;
|
||||
const ICON_COLOR_MODES = ['plain', 'multi'] as const;
|
||||
const TRANSFER_MODES = ['single', 'multi'] as const;
|
||||
const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
||||
|
||||
@@ -150,6 +151,23 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2.5 flex items-center justify-between gap-2">
|
||||
<span class="text-xs text-muted-foreground">File colors</span>
|
||||
<div class="flex rounded-md border border-border p-0.5">
|
||||
@for (mode of iconColorModes; track mode) {
|
||||
<button
|
||||
(click)="settings.setIconColors(mode)"
|
||||
class="rounded px-2 py-0.5 text-[11px] capitalize"
|
||||
[class.bg-accent]="settings.iconColors() === mode"
|
||||
[class.text-foreground]="settings.iconColors() === mode"
|
||||
[class.text-muted-foreground]="settings.iconColors() !== mode"
|
||||
>
|
||||
{{ mode }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2.5 flex items-center justify-between gap-2">
|
||||
<span class="text-xs text-muted-foreground">Text size</span>
|
||||
<div class="flex rounded-md border border-border p-0.5">
|
||||
@@ -305,6 +323,7 @@ export class TabBar {
|
||||
readonly isMacChrome = IS_MAC_CHROME;
|
||||
readonly themes = THEMES;
|
||||
readonly accents = ACCENTS;
|
||||
readonly iconColorModes = ICON_COLOR_MODES;
|
||||
readonly speedUnits = SPEED_UNITS;
|
||||
readonly textSizes = TEXT_SIZES;
|
||||
readonly transferModes = TRANSFER_MODES;
|
||||
|
||||
@@ -22,6 +22,13 @@
|
||||
--input: oklch(0.92 0.004 286.32);
|
||||
--ring: oklch(0.705 0.015 286.067);
|
||||
--radius: 0.5rem;
|
||||
/* file-type icon colors ("Multi" mode) — fixed category hues, muted chroma */
|
||||
--icon-folder: oklch(0.58 0.13 250);
|
||||
--icon-link: oklch(0.56 0.1 190);
|
||||
--icon-image: oklch(0.58 0.13 295);
|
||||
--icon-media: oklch(0.6 0.13 10);
|
||||
--icon-archive: oklch(0.62 0.13 60);
|
||||
--icon-code: oklch(0.56 0.12 155);
|
||||
}
|
||||
|
||||
/* Dark tokens apply when the user forces dark, or follows a dark system theme.
|
||||
@@ -45,6 +52,12 @@
|
||||
--border: oklch(1 0 0 / 12%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.552 0.016 285.938);
|
||||
--icon-folder: oklch(0.75 0.11 250);
|
||||
--icon-link: oklch(0.74 0.09 190);
|
||||
--icon-image: oklch(0.76 0.11 295);
|
||||
--icon-media: oklch(0.76 0.11 10);
|
||||
--icon-archive: oklch(0.77 0.11 65);
|
||||
--icon-code: oklch(0.75 0.1 155);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
@@ -67,6 +80,12 @@
|
||||
--border: oklch(1 0 0 / 12%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.552 0.016 285.938);
|
||||
--icon-folder: oklch(0.75 0.11 250);
|
||||
--icon-link: oklch(0.74 0.09 190);
|
||||
--icon-image: oklch(0.76 0.11 295);
|
||||
--icon-media: oklch(0.76 0.11 10);
|
||||
--icon-archive: oklch(0.77 0.11 65);
|
||||
--icon-code: oklch(0.75 0.1 155);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +115,12 @@
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-cursor-tint: var(--cursor-tint);
|
||||
--color-icon-folder: var(--icon-folder);
|
||||
--color-icon-link: var(--icon-link);
|
||||
--color-icon-image: var(--icon-image);
|
||||
--color-icon-media: var(--icon-media);
|
||||
--color-icon-archive: var(--icon-archive);
|
||||
--color-icon-code: var(--icon-code);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
|
||||
Reference in New Issue
Block a user