File-type icon colors with Plain/Multi setting (multi default)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jokin
2026-07-12 01:31:10 +02:00
co-authored by Claude Fable 5
parent 2f4bc63ac8
commit 44f077380f
8 changed files with 161 additions and 7 deletions
+84
View File
@@ -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;
}
+1 -5
View File
@@ -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">
+8 -2
View File
@@ -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 {
+12
View File
@@ -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,
};
+9
View File
@@ -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(),
+19
View File
@@ -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;
+25
View File
@@ -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);