From 44f077380f06458428c61bc77d59b17d0b78bd47 Mon Sep 17 00:00:00 2001 From: Jokin Date: Sun, 12 Jul 2026 01:31:10 +0200 Subject: [PATCH] File-type icon colors with Plain/Multi setting (multi default) Co-Authored-By: Claude Fable 5 --- src/shared/types.ts | 3 + ui/src/app/file-icons.ts | 84 +++++++++++++++++++++++++++ ui/src/app/file-panel/file-panel.html | 6 +- ui/src/app/file-panel/file-panel.ts | 10 +++- ui/src/app/icons.ts | 12 ++++ ui/src/app/settings.service.ts | 9 +++ ui/src/app/tab-bar/tab-bar.ts | 19 ++++++ ui/src/styles.css | 25 ++++++++ 8 files changed, 161 insertions(+), 7 deletions(-) create mode 100644 ui/src/app/file-icons.ts diff --git a/src/shared/types.ts b/src/shared/types.ts index 9b931fe..5694459 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -55,6 +55,8 @@ export interface AppSettings { theme: "system" | "light" | "dark"; /** Accent tone for chrome and selection tint; graphite = neutral zinc. */ accent: AccentTone; + /** File-list icons: plain neutral glyphs, or per-category glyphs and colors. */ + iconColors: "plain" | "multi"; speedUnit: "MBps" | "Mbps"; /** Font size of the path bar and file list. */ textSize: "compact" | "default" | "large"; @@ -73,6 +75,7 @@ export interface AppSettings { export const DEFAULT_SETTINGS: AppSettings = { theme: "system", accent: "indigo", + iconColors: "multi", speedUnit: "MBps", textSize: "default", transferMode: "single", diff --git a/ui/src/app/file-icons.ts b/ui/src/app/file-icons.ts new file mode 100644 index 0000000..54205cf --- /dev/null +++ b/ui/src/app/file-icons.ts @@ -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 = { + 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(); +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; +} diff --git a/ui/src/app/file-panel/file-panel.html b/ui/src/app/file-panel/file-panel.html index 0340172..79365b9 100644 --- a/ui/src/app/file-panel/file-panel.html +++ b/ui/src/app/file-panel/file-panel.html @@ -227,11 +227,7 @@ [class.ring-ring]="i === panel().cursor() && isActive()" >
- + {{ entry.name }}
diff --git a/ui/src/app/file-panel/file-panel.ts b/ui/src/app/file-panel/file-panel.ts index 374bf6d..5ad6c18 100644 --- a/ui/src/app/file-panel/file-panel.ts +++ b/ui/src/app/file-panel/file-panel.ts @@ -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 { diff --git a/ui/src/app/icons.ts b/ui/src/app/icons.ts index c2190ff..ea9ad27 100644 --- a/ui/src/app/icons.ts +++ b/ui/src/app/icons.ts @@ -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, }; diff --git a/ui/src/app/settings.service.ts b/ui/src/app/settings.service.ts index 9e2d512..6e9f0b9 100644 --- a/ui/src/app/settings.service.ts +++ b/ui/src/app/settings.service.ts @@ -6,6 +6,7 @@ import { RpcService } from './rpc.service'; export class SettingsService { readonly theme = signal(DEFAULT_SETTINGS.theme); readonly accent = signal(DEFAULT_SETTINGS.accent); + readonly iconColors = signal(DEFAULT_SETTINGS.iconColors); readonly speedUnit = signal(DEFAULT_SETTINGS.speedUnit); readonly textSize = signal(DEFAULT_SETTINGS.textSize); readonly transferMode = signal(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(), diff --git a/ui/src/app/tab-bar/tab-bar.ts b/ui/src/app/tab-bar/tab-bar.ts index 82d5d6e..3def1ca 100644 --- a/ui/src/app/tab-bar/tab-bar.ts +++ b/ui/src/app/tab-bar/tab-bar.ts @@ -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; +
+ File colors +
+ @for (mode of iconColorModes; track mode) { + + } +
+
+
Text size
@@ -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; diff --git a/ui/src/styles.css b/ui/src/styles.css index c9045c7..a8e6d77 100644 --- a/ui/src/styles.css +++ b/ui/src/styles.css @@ -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);