feat(icons): file icons and tab glyphs in color, from the host's own artwork

Issue #611 asked for file-type icons like the official explorer's, and #429
built a colored theme for them. DSH 0.1.5-rc.2 makes most of that work
unnecessary: `@deepseek-ai/dsh-client-ui-primitives` now exports
`FileTypeIcon` / `CodeFileIcon` with the host's own 48-type full-color code
artwork plus category-colored sheet glyphs (and its classifier behind them).
So the plugin renders the host's icons instead of shipping a color table, and
color becomes the only behaviour rather than an opt-in theme.

File icons (src/client/file-icons.tsx)
- `builtinFileIcon(path, size)` → `<FileTypeIcon path size />`; folders use the
  host's folder category. The plugin's own per-extension VSCodicon map is gone.
- The public `registerFileIcon` API from #531 is unchanged and still outranks
  the built-ins, so an external plugin can take over any row. One semantic
  consequence, now documented and tested: because the host classifier covers
  every path, a registered catch-all (`exts: []`) claims rows it used to lose
  to the built-in map. An unregistered catch-all (the old icon theme's shape)
  is unaffected.
- Nothing is lazy any more: the artwork lives in a platform module the client
  already holds, so `lib/client-file-icons.js` and its 563-entry dataset (255
  kB) are deleted outright — the core bundle grew by 10 kB (0.85 → 0.86 MB).
- `src/client/file-icon-theme.ts`, the `fileIconTheme` pref (prefs-shared /
  config / client prefs), the settings-page select row and the six
  `settingsFileIconTheme*` keys in all 20 dictionaries are removed with it.

Tab glyphs (src/client/builtins/tab-icons.tsx)
- Every built-in tab type now declares a colored glyph: files (the host's
  folder artwork), changes/diff (commit), tasks (checklist), side chat
  (discussion), terminal (shell), browser (globe).
- The color comes from a `--dsw-alias-*` token through a wrapper class, never
  from a literal — the skin contract (§12) holds and `tests/theme.spec.ts`
  enforces both halves (no literals in the module, every color declaration in
  the stylesheet resolves to a token).

Native right Sidebar chips (src/client/native/tab-adapter.tsx)
- The host's tab definition has no icon field, but the
  `sidebar.right.pane.tab.title` slot IS the chip's content, so the plugin
  draws the glyph itself: an editor tab with a path shows that file's icon,
  every other tab shows its descriptor's icon — the same `descriptor.icon` the
  workbench strip and the guide capsules already render, so the three cannot
  drift. The glyph is `aria-hidden`, which keeps the chip's accessible name
  exactly the title the mount lane matches on.

Gates: typecheck / lint / check:consumer-types green; pnpm test 127 files /
1342 passed / 9 skipped (3 new chip cases, 1 rewritten built-in case).
This commit is contained in:
Menghuan1918
2026-09-11 15:28:49 +08:00
parent a4e94c976e
commit 22e772ce45
46 changed files with 431 additions and 1381 deletions
+37
View File
@@ -0,0 +1,37 @@
/*
* Colors for the built-in tab glyphs (src/client/builtins/tab-icons.tsx).
*
* Every declaration resolves to a `--dsw-alias-*` token, so all skins stay in
* charge — including the token's own fallback chain, which keeps the glyph
* legible on a host that does not define the brand/state families. The plugin
* never writes a color literal here; `tests/theme.spec.ts` enforces both
* halves of that rule.
*
* The chips sit on the panel surface, so a token that is itself translucent
* would wash out at 14px; each rule therefore prefers an opaque state/brand
* token and falls back to the primary ink.
*/
.files {
color: var(--dsw-alias-brand-primary, var(--dsw-alias-label-primary));
}
.changes {
color: var(--dsw-alias-state-success-primary, var(--dsw-alias-brand-primary, var(--dsw-alias-label-primary)));
}
.tasks {
color: var(--dsw-alias-state-warn-primary, var(--dsw-alias-label-primary));
}
.sidechat {
color: var(--dsw-alias-state-business-primary, var(--dsw-alias-brand-primary, var(--dsw-alias-label-primary)));
}
.terminal {
color: var(--dsw-alias-label-primary);
}
.browser {
color: var(--dsw-alias-state-business-primary, var(--dsw-alias-brand-primary, var(--dsw-alias-label-primary)));
}
+63
View File
@@ -0,0 +1,63 @@
/**
* The built-in tab glyphs, in color.
*
* Every built-in tab type and the incoming 8th (changes) declare their icon
* here, so the three surfaces that draw a tab's glyph — the bottom workbench's
* tab strip, the native right Sidebar's guide capsules and its tab chips — all
* read the same colorful glyph from `descriptor.icon`.
*
* The color always arrives from a theme token, never from the plugin: the
* glyph is a VSCodicon drawn in `currentColor`, and the wrapper class supplies
* that color. A skin therefore keeps control of everything (the guide's §12);
* this module is what `tests/theme.spec.ts` checks for color literals.
*
* `files` is the one exception in kind rather than in color: it renders DSH's
* own folder artwork (`FileTypeIcon`), matching the file rows this tab shows.
*/
import type { ReactNode } from 'react'
import { FileTypeIcon } from '@deepseek-ai/dsh-client-ui-primitives'
import styles from './tab-icons.module.css'
import {
VscCommentDiscussion,
VscGitCommit,
VscGlobe,
VscTasklist,
VscTerminal,
} from 'react-icons/vsc'
/** The styled wrapper classes; typed so a renamed rule fails the build. */
const css = styles as Record<'files' | 'changes' | 'tasks' | 'sidechat' | 'terminal' | 'browser', string>
/** One tab type's glyph, sized by the caller's surface (14px in a strip). */
export type TabIcon = (size: number) => ReactNode
/** Surround a glyph with the class that hands it its token-driven color. */
function themed(className: string, glyph: ReactNode): ReactNode {
return <span className={className}>{glyph}</span>
}
/** The Files tab: the host's folder artwork, like the rows it opens. */
export const filesTabIcon: TabIcon = (size) => (
<span className={css.files}>
<FileTypeIcon kind="folder" size={size} />
</span>
)
/** Changes / diff: the commit glyph, green like the diff affordances. */
export const changesTabIcon: TabIcon = (size) =>
themed(css.changes, <VscGitCommit size={size} />)
/** Tasks (subagent) — the live-activity amber. */
export const tasksTabIcon: TabIcon = (size) =>
themed(css.tasks, <VscTasklist size={size} />)
/** Side chat — the conversational/secondary accent. */
export const sidechatTabIcon: TabIcon = (size) =>
themed(css.sidechat, <VscCommentDiscussion size={size} />)
/** Terminal — primary ink, the shell is text. */
export const terminalTabIcon: TabIcon = (size) =>
themed(css.terminal, <VscTerminal size={size} />)
/** Browser — the same secondary accent as the side chat's sibling surfaces. */
export const browserTabIcon: TabIcon = (size) =>
themed(css.browser, <VscGlobe size={size} />)
+11 -31
View File
@@ -8,8 +8,11 @@
* `browser:<n>` the same way (no quota). The editor IS the files window
* (the old standalone explorer merged into it).
*/
import { IconCodeOutline16, IconFolderOpen16, IconFolderOpenOutline16, IconNewChatOutline16, IconPanelLeftOutline16, IconSparkle16, IconThinkOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconCodeOutline16, IconPanelLeftOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context } from '../../context-types.ts'
import {
browserTabIcon, changesTabIcon, filesTabIcon, sidechatTabIcon, tasksTabIcon, terminalTabIcon,
} from './tab-icons.tsx'
import { allLeaves, isAgentTabId, type SidebarState } from '../state.ts'
import { t } from '../locales.ts'
import { openSidebarFile } from '../intercept.tsx'
@@ -22,7 +25,6 @@ import { SubagentView } from '../SubagentView.tsx'
import { consumeSidechatSeed, SideChatView, sidechatThreadIdOf } from '../SideChatView.tsx'
import { api } from '../api.ts'
import { BrowserView } from '../BrowserView.tsx'
import { IconTerminalOutline16, IconDiffOutline16, IconGlobeOutline16 } from '../icons.tsx'
import { TERMINAL_FONT_SIZE_MAX, TERMINAL_FONT_SIZE_MIN } from '../../prefs-shared.ts'
import type { ComponentType } from 'react'
import type { SessionScope } from '../api.ts'
@@ -87,7 +89,7 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
// explorer (empty hint + docked tree); with a path it previews/edits
// the file. Visible in the + menu in the explorer's old slot.
title: () => t('files'),
icon: (size: number) => <IconFolderOpen16 size={size} />,
icon: filesTabIcon,
order: 10,
hidden: false,
dedupeKey: (tab) => tab.path,
@@ -122,28 +124,6 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
key: 'workspaceFence',
title: () => t('settingsFenceTitle'),
desc: () => t('settingsFenceDesc'),
}, {
// The file-icon theme: monochrome built-in glyphs (default) vs the
// optional colored brand dataset, which is fetched from the lazy
// `file-icons` chunk only when picked (see file-icon-theme.ts).
key: 'fileIconTheme',
type: 'select',
title: () => t('settingsFileIconThemeTitle'),
desc: () => t('settingsFileIconThemeDesc'),
options: [
{
value: 'builtin',
icon: (size: number) => <IconFolderOpenOutline16 size={size} />,
title: () => t('settingsFileIconThemeBuiltin'),
desc: () => t('settingsFileIconThemeBuiltinDesc'),
},
{
value: 'colored',
icon: (size: number) => <IconSparkle16 size={size} />,
title: () => t('settingsFileIconThemeColored'),
desc: () => t('settingsFileIconThemeColoredDesc'),
},
],
}],
render: ({ pluginSettings, updatePluginSetting }) => (
<OpenWithSettings pluginSettings={pluginSettings} updatePluginSetting={updatePluginSetting} />
@@ -172,7 +152,7 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
id: 'git',
title: () => t('changes'),
description: () => t('guideDescGit'),
icon: (size: number) => <IconDiffOutline16 size={size} />,
icon: changesTabIcon,
order: 20,
single: true,
badge: (_ctx, scope) => {
@@ -195,7 +175,7 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
id: 'subagent',
title: () => t('subagent'),
description: () => t('guideDescSubagent'),
icon: (size: number) => <IconThinkOutline16 size={size} />,
icon: tasksTabIcon,
order: 30,
single: true,
// Declarative settings: the auto-open switches render under this row in
@@ -224,7 +204,7 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
id: 'sidechat',
title: () => t('sideChat'),
description: () => t('guideDescSidechat'),
icon: (size: number) => <IconNewChatOutline16 size={size} />,
icon: sidechatTabIcon,
order: 35,
// Codex-style: EVERY side conversation is its own tab. A plain open
// mints a fresh tab flagged `autoCreate` (the view creates the EMPTY
@@ -270,7 +250,7 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
id: 'terminal',
title: () => t('terminal'),
description: () => t('guideDescTerminal'),
icon: (size: number) => <IconTerminalOutline16 size={size} />,
icon: terminalTabIcon,
order: 40,
available: (_ctx, _scope, state) => uiTerminalCount(state) < TERMINAL_LIMIT,
// Declarative settings: the model-facing terminal tools switch, the
@@ -335,7 +315,7 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
id: 'browser',
title: () => t('browser'),
description: () => t('guideDescBrowser'),
icon: (size: number) => <IconGlobeOutline16 size={size} />,
icon: browserTabIcon,
order: 50,
// Declarative settings: the sandbox escape hatch, the link-takeover
// MASTER switch, and the per-protocol takeover switches (http on /
@@ -379,7 +359,7 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
{
id: 'diff',
title: () => t('changes'),
icon: (size: number) => <IconDiffOutline16 size={size} />,
icon: changesTabIcon,
order: -1,
hidden: true,
dedupeKey: (tab) => tab.id,