@keith9681/dsh-sidebar 0.15.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +0 -0
- package/README_EN.md +0 -0
- package/cordis.patch.yml +49 -0
- package/lib/client-editor.js +34651 -0
- package/lib/client-mermaid.js +200629 -0
- package/lib/client-registry.js +13388 -0
- package/lib/client-terminal.js +8904 -0
- package/lib/client.js +13388 -0
- package/lib/index.js +3959 -0
- package/lib/invariant.js +22 -0
- package/lib/types/agent-pty.d.ts +226 -0
- package/lib/types/browser-probe.d.ts +15 -0
- package/lib/types/bundle-route.d.ts +12 -0
- package/lib/types/client/BrowserView.d.ts +22 -0
- package/lib/types/client/DiffTab.d.ts +6 -0
- package/lib/types/client/DiffView.d.ts +51 -0
- package/lib/types/client/EditorHost.d.ts +12 -0
- package/lib/types/client/FileTree.d.ts +36 -0
- package/lib/types/client/GitView.d.ts +8 -0
- package/lib/types/client/OrphanedTab.d.ts +11 -0
- package/lib/types/client/PdfView.d.ts +6 -0
- package/lib/types/client/RenderBoundary.d.ts +32 -0
- package/lib/types/client/SandboxStatusBar.d.ts +10 -0
- package/lib/types/client/SideCardSection.d.ts +74 -0
- package/lib/types/client/SideChatView.d.ts +16 -0
- package/lib/types/client/Sidebar.d.ts +6 -0
- package/lib/types/client/SubagentView.d.ts +16 -0
- package/lib/types/client/TabBar.d.ts +43 -0
- package/lib/types/client/TerminalView.d.ts +24 -0
- package/lib/types/client/TextEditor.d.ts +10 -0
- package/lib/types/client/TreePanel.d.ts +23 -0
- package/lib/types/client/UploadOverlay.d.ts +20 -0
- package/lib/types/client/add-plugin-modal.d.ts +15 -0
- package/lib/types/client/api.d.ts +268 -0
- package/lib/types/client/binary-download.d.ts +12 -0
- package/lib/types/client/breakpoints.d.ts +12 -0
- package/lib/types/client/browser.d.ts +50 -0
- package/lib/types/client/builtins/index.d.ts +18 -0
- package/lib/types/client/builtins/tabs.d.ts +11 -0
- package/lib/types/client/builtins/viewers.d.ts +3 -0
- package/lib/types/client/chunk-loader.d.ts +124 -0
- package/lib/types/client/chunks/editor.d.ts +9 -0
- package/lib/types/client/chunks/mermaid.d.ts +10 -0
- package/lib/types/client/chunks/terminal.d.ts +9 -0
- package/lib/types/client/cm-themes.d.ts +24 -0
- package/lib/types/client/conversation-draft.d.ts +14 -0
- package/lib/types/client/desktop-env.d.ts +35 -0
- package/lib/types/client/editable-state.d.ts +27 -0
- package/lib/types/client/editor-load.d.ts +66 -0
- package/lib/types/client/frame-batcher.d.ts +24 -0
- package/lib/types/client/icons.d.ts +57 -0
- package/lib/types/client/image-types.d.ts +3 -0
- package/lib/types/client/ime-guard.d.ts +36 -0
- package/lib/types/client/index.d.ts +19 -0
- package/lib/types/client/intercept.d.ts +33 -0
- package/lib/types/client/lang.d.ts +12 -0
- package/lib/types/client/lazy-chunk.d.ts +25 -0
- package/lib/types/client/link-intercept.d.ts +42 -0
- package/lib/types/client/locales.d.ts +366 -0
- package/lib/types/client/mermaid-blocks.d.ts +41 -0
- package/lib/types/client/mermaid-sanitize.d.ts +2 -0
- package/lib/types/client/mermaid.d.ts +12 -0
- package/lib/types/client/open-when-sized.d.ts +20 -0
- package/lib/types/client/open-with-settings.d.ts +4 -0
- package/lib/types/client/open-with.d.ts +87 -0
- package/lib/types/client/openpath-intercept.d.ts +41 -0
- package/lib/types/client/paths.d.ts +35 -0
- package/lib/types/client/pdf-types.d.ts +2 -0
- package/lib/types/client/plugin-settings.d.ts +8 -0
- package/lib/types/client/plugins-shared.d.ts +27 -0
- package/lib/types/client/plugins-tabs.d.ts +3 -0
- package/lib/types/client/plugins-viewers.d.ts +3 -0
- package/lib/types/client/prefs.d.ts +41 -0
- package/lib/types/client/produced-files.d.ts +23 -0
- package/lib/types/client/selection-payload.d.ts +27 -0
- package/lib/types/client/service.d.ts +440 -0
- package/lib/types/client/settings-nav-icon.d.ts +19 -0
- package/lib/types/client/shell-presets.d.ts +48 -0
- package/lib/types/client/sidechat-transcript.d.ts +103 -0
- package/lib/types/client/split-pane.d.ts +30 -0
- package/lib/types/client/state.d.ts +359 -0
- package/lib/types/client/subagent-detect.d.ts +54 -0
- package/lib/types/client/subagent-jobs.d.ts +63 -0
- package/lib/types/client/tab-content-memo.d.ts +33 -0
- package/lib/types/client/terminal-font.d.ts +20 -0
- package/lib/types/client/theme.d.ts +45 -0
- package/lib/types/client/titlebar-strip.d.ts +23 -0
- package/lib/types/client/upload.d.ts +69 -0
- package/lib/types/client/wco.d.ts +47 -0
- package/lib/types/config.d.ts +64 -0
- package/lib/types/context-types.d.ts +603 -0
- package/lib/types/fs-operations.d.ts +28 -0
- package/lib/types/fs-search.d.ts +24 -0
- package/lib/types/fs-tree.d.ts +51 -0
- package/lib/types/git.d.ts +82 -0
- package/lib/types/html-route.d.ts +59 -0
- package/lib/types/index.d.ts +47 -0
- package/lib/types/invariant.d.ts +15 -0
- package/lib/types/jobs-routes.d.ts +45 -0
- package/lib/types/open-external.d.ts +24 -0
- package/lib/types/prefs-shared.d.ts +225 -0
- package/lib/types/pty-deps.d.ts +78 -0
- package/lib/types/pty-manager.d.ts +168 -0
- package/lib/types/sidechat-core.d.ts +172 -0
- package/lib/types/sidechat-routes.d.ts +30 -0
- package/lib/types/subagent-activity.d.ts +44 -0
- package/lib/types/subagent-live-route.d.ts +44 -0
- package/lib/types/tools.d.ts +31 -0
- package/lib/types/trust-fence.d.ts +24 -0
- package/lib/types/wire.d.ts +38 -0
- package/package.json +173 -0
- package/scripts/install.ps1 +289 -0
- package/scripts/install.sh +287 -0
- package/src/agent-pty.ts +527 -0
- package/src/browser-probe.ts +26 -0
- package/src/bundle-route.ts +129 -0
- package/src/client/BrowserView.tsx +253 -0
- package/src/client/DiffTab.tsx +110 -0
- package/src/client/DiffView.tsx +305 -0
- package/src/client/EditorHost.tsx +525 -0
- package/src/client/FileTree.tsx +653 -0
- package/src/client/GitView.tsx +558 -0
- package/src/client/OrphanedTab.tsx +27 -0
- package/src/client/PdfView.tsx +110 -0
- package/src/client/RenderBoundary.tsx +49 -0
- package/src/client/SandboxStatusBar.tsx +60 -0
- package/src/client/SideCardSection.module.css +960 -0
- package/src/client/SideCardSection.tsx +1152 -0
- package/src/client/SideChatView.module.css +437 -0
- package/src/client/SideChatView.tsx +644 -0
- package/src/client/Sidebar.tsx +1148 -0
- package/src/client/SubagentView.module.css +547 -0
- package/src/client/SubagentView.tsx +883 -0
- package/src/client/TabBar.tsx +305 -0
- package/src/client/TerminalView.tsx +383 -0
- package/src/client/TextEditor.tsx +500 -0
- package/src/client/TreePanel.tsx +273 -0
- package/src/client/UploadOverlay.tsx +62 -0
- package/src/client/add-plugin-modal.tsx +199 -0
- package/src/client/api.ts +335 -0
- package/src/client/binary-download.tsx +23 -0
- package/src/client/breakpoints.ts +51 -0
- package/src/client/browser.ts +119 -0
- package/src/client/builtins/index.ts +37 -0
- package/src/client/builtins/tabs.tsx +331 -0
- package/src/client/builtins/viewers.tsx +125 -0
- package/src/client/chunk-loader.ts +351 -0
- package/src/client/chunks/editor.tsx +9 -0
- package/src/client/chunks/mermaid.tsx +10 -0
- package/src/client/chunks/terminal.tsx +9 -0
- package/src/client/cm-themes.ts +129 -0
- package/src/client/conversation-draft.ts +29 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/desktop-env.ts +72 -0
- package/src/client/editable-state.ts +70 -0
- package/src/client/editor-load.ts +92 -0
- package/src/client/frame-batcher.ts +56 -0
- package/src/client/icons.tsx +161 -0
- package/src/client/image-types.ts +8 -0
- package/src/client/ime-guard.ts +47 -0
- package/src/client/index.tsx +369 -0
- package/src/client/intercept.tsx +110 -0
- package/src/client/lang.ts +103 -0
- package/src/client/layout.css +94 -0
- package/src/client/lazy-chunk.tsx +89 -0
- package/src/client/link-intercept.ts +73 -0
- package/src/client/locales.ts +734 -0
- package/src/client/mermaid-blocks.ts +110 -0
- package/src/client/mermaid-sanitize.ts +93 -0
- package/src/client/mermaid.tsx +401 -0
- package/src/client/open-when-sized.ts +43 -0
- package/src/client/open-with-settings.tsx +135 -0
- package/src/client/open-with.ts +221 -0
- package/src/client/openpath-intercept.ts +62 -0
- package/src/client/paths.ts +47 -0
- package/src/client/pdf-types.ts +4 -0
- package/src/client/plugin-settings.ts +43 -0
- package/src/client/plugins-shared.ts +29 -0
- package/src/client/plugins-tabs.ts +87 -0
- package/src/client/plugins-viewers.ts +30 -0
- package/src/client/prefs.ts +207 -0
- package/src/client/produced-files.ts +91 -0
- package/src/client/selection-payload.ts +86 -0
- package/src/client/service.ts +838 -0
- package/src/client/settings-nav-icon.ts +45 -0
- package/src/client/shell-presets.ts +83 -0
- package/src/client/sidebar.module.css +2577 -0
- package/src/client/sidechat-transcript.ts +321 -0
- package/src/client/split-pane.tsx +322 -0
- package/src/client/state.ts +1175 -0
- package/src/client/subagent-detect.ts +137 -0
- package/src/client/subagent-jobs.ts +161 -0
- package/src/client/tab-content-memo.ts +46 -0
- package/src/client/terminal-font.ts +28 -0
- package/src/client/theme.ts +110 -0
- package/src/client/titlebar-strip.ts +37 -0
- package/src/client/upload.ts +187 -0
- package/src/client/wco.ts +120 -0
- package/src/config.ts +155 -0
- package/src/context-types.ts +610 -0
- package/src/fs-operations.ts +99 -0
- package/src/fs-search.ts +86 -0
- package/src/fs-tree.ts +158 -0
- package/src/git.ts +244 -0
- package/src/html-route.ts +106 -0
- package/src/index.ts +1132 -0
- package/src/invariant.ts +32 -0
- package/src/jobs-routes.ts +257 -0
- package/src/open-external.ts +90 -0
- package/src/prefs-shared.ts +268 -0
- package/src/pty-deps.ts +240 -0
- package/src/pty-manager.ts +376 -0
- package/src/sidechat-core.ts +468 -0
- package/src/sidechat-routes.ts +344 -0
- package/src/subagent-activity.ts +91 -0
- package/src/subagent-live-route.ts +93 -0
- package/src/tools.ts +479 -0
- package/src/trust-fence.ts +77 -0
- package/src/wire.ts +100 -0
|
@@ -0,0 +1,1152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Side card" settings section: the user-facing preferences for the sidebar
|
|
3
|
+
* panel, rendered natively in the DSH Settings shell (nav label "Side card").
|
|
4
|
+
*
|
|
5
|
+
* The section is DECLARATIVE — it renders the enable/disable inventory from
|
|
6
|
+
* the sidebar service's registries instead of hardcoding rows:
|
|
7
|
+
* - 常规: new conversations open the panel by default (a toggle row), the
|
|
8
|
+
* default panel width as a percent of the window (number input row), and
|
|
9
|
+
* the open-path interception toggle — the DSH settings-row recipe
|
|
10
|
+
* (title/desc left + control right, hairline separators).
|
|
11
|
+
* - 侧边栏内容: one SMALL CARD per REGISTERED tab type (built-ins and
|
|
12
|
+
* external plugins alike), laid out in a responsive grid that wraps
|
|
13
|
+
* several cards per row — icon chip + title + type id, clicked to toggle
|
|
14
|
+
* the switch persisted in `prefs.tabsEnabled[id]`.
|
|
15
|
+
* - 文件预览: one SMALL CARD per REGISTERED file viewer — icon chip + title
|
|
16
|
+
* + the extensions it covers, clicked to toggle `prefs.viewersEnabled[id]`.
|
|
17
|
+
*
|
|
18
|
+
* Every group lives in a container card (the DSH PluginCard recipe: l2
|
|
19
|
+
* hairline, 16px radius, layer-3 fill) with a heading and an inventory count
|
|
20
|
+
* badge (the settings catalogHeading recipe); the section opens with a
|
|
21
|
+
* one-line intro (the DSH section heading+intro recipe).
|
|
22
|
+
*
|
|
23
|
+
* A card's on/off state is its VISUAL STATE: enabled = highlighted (brand
|
|
24
|
+
* border + tinted fill + a compact switch knob at the card's far right),
|
|
25
|
+
* disabled = neutral and dimmed. Features that declare
|
|
26
|
+
* `settings.toggles` carry a labeled settings strip at the card's bottom
|
|
27
|
+
* edge that opens a native Modal (wider than the primitive default) with
|
|
28
|
+
* the related settings as title/desc + custom-switch rows and a Done
|
|
29
|
+
* footer; the popup body scrolls internally when a feature declares many
|
|
30
|
+
* rows (e.g. Terminal's six). The toggles themselves are custom
|
|
31
|
+
* switches: a real checkbox (native semantics and focus) driving a styled
|
|
32
|
+
* track/thumb.
|
|
33
|
+
*
|
|
34
|
+
* Writes ride the plugin's own fenced settings route (the host calls the
|
|
35
|
+
* settings seam in-process — the DSH settings RPC domain does not serve
|
|
36
|
+
* third-party namespaces to configuration clients); the shared SidebarStore
|
|
37
|
+
* is refreshed on success so the very next brand-new session seeds from the
|
|
38
|
+
* new values and the sidebar's consumption points (the + menu, derived
|
|
39
|
+
* flows) re-render immediately. Any failure reverts the optimistic UI and
|
|
40
|
+
* shows the wire error inline — a broken settings surface never crashes the
|
|
41
|
+
* shell.
|
|
42
|
+
*/
|
|
43
|
+
import { Fragment, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
|
44
|
+
import {
|
|
45
|
+
IconChevronDownOutline14,
|
|
46
|
+
IconPlusOutline16,
|
|
47
|
+
IconSettingsOutline16,
|
|
48
|
+
Input,
|
|
49
|
+
Menu,
|
|
50
|
+
Modal,
|
|
51
|
+
} from '@deepseek-ai/dsh-client-ui-primitives'
|
|
52
|
+
import clsx from 'clsx'
|
|
53
|
+
// Type-only: pulls the settings shell's SlotMap merges ('settings.section').
|
|
54
|
+
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
|
55
|
+
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
|
56
|
+
import {
|
|
57
|
+
clampWidthPercent,
|
|
58
|
+
TITLE_BAR_STRIP_MAX,
|
|
59
|
+
TITLE_BAR_STRIP_MIN,
|
|
60
|
+
WIDTH_PERCENT_MAX,
|
|
61
|
+
WIDTH_PERCENT_MIN,
|
|
62
|
+
type SidebarPrefs,
|
|
63
|
+
type TitleBarScheme,
|
|
64
|
+
} from '../prefs-shared.ts'
|
|
65
|
+
import { api } from './api.ts'
|
|
66
|
+
import { parsePrefs } from './prefs.ts'
|
|
67
|
+
import { AddPluginModal, type PluginKind } from './add-plugin-modal.tsx'
|
|
68
|
+
import { t } from './locales.ts'
|
|
69
|
+
import { parseDesktopEnv } from './desktop-env.ts'
|
|
70
|
+
import { getShellPreset, getShellPresets } from './shell-presets.ts'
|
|
71
|
+
import type { SidebarStore } from './state.ts'
|
|
72
|
+
import type {
|
|
73
|
+
BetterSidebarService,
|
|
74
|
+
FileViewerDescriptor,
|
|
75
|
+
SidebarSettingsRenderProps,
|
|
76
|
+
SidebarSettingToggle,
|
|
77
|
+
TabDescriptor,
|
|
78
|
+
} from './service.ts'
|
|
79
|
+
import css from './SideCardSection.module.css'
|
|
80
|
+
|
|
81
|
+
/** Injected business face: the shared store (prefs cache) + the sidebar service (registries). */
|
|
82
|
+
export interface SideCardSectionInjected {
|
|
83
|
+
store: SidebarStore
|
|
84
|
+
service: BetterSidebarService
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Full section props: the runtime share plus the injected face. */
|
|
88
|
+
export type SideCardSectionProps = PropsRuntime<'settings.section'> & SideCardSectionInjected
|
|
89
|
+
|
|
90
|
+
/** Map one wire failure to the inline message (the conflict gets friendly copy). */
|
|
91
|
+
function messageOf(error: unknown): string {
|
|
92
|
+
if (error instanceof Error && 'code' in error && (error as { code?: unknown }).code === 'settings-conflict') {
|
|
93
|
+
return `${t('settingsSaveFailed')} ${t('settingsConflict')}`
|
|
94
|
+
}
|
|
95
|
+
return `${t('settingsSaveFailed')} ${error instanceof Error ? error.message : String(error)}`
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Resolve an i18n-friendly string-or-function value. */
|
|
99
|
+
function textOf(value: string | (() => string) | undefined): string {
|
|
100
|
+
if (value === undefined) return ''
|
|
101
|
+
return typeof value === 'function' ? value() : value
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Resolve a descriptor icon (ReactNode or size function). */
|
|
105
|
+
function iconOf(icon: ReactNode | ((size: number) => ReactNode) | undefined, size: number): ReactNode {
|
|
106
|
+
if (icon === undefined) return null
|
|
107
|
+
return typeof icon === 'function' ? icon(size) : icon
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Tab inventory order: hidden types (editor/diff) last, then + menu order. */
|
|
111
|
+
function tabOrder(a: TabDescriptor, b: TabDescriptor): number {
|
|
112
|
+
if (a.hidden !== b.hidden) return a.hidden === true ? 1 : -1
|
|
113
|
+
return (a.order ?? 100) - (b.order ?? 100)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The scheme dropdown's current value: the plain scheme, or `preset:<id>`
|
|
118
|
+
* while a preset is active. Falls back to `auto` when the stored preset id
|
|
119
|
+
* is no longer registered (the strip resolves to 0 then anyway).
|
|
120
|
+
*/
|
|
121
|
+
function titleBarSchemeValue(prefs: SidebarPrefs): string {
|
|
122
|
+
if (prefs.titleBarScheme !== 'preset') return prefs.titleBarScheme
|
|
123
|
+
const preset = getShellPreset(prefs.titleBarPresetId)
|
|
124
|
+
return preset !== undefined ? `preset:${preset.id}` : 'auto'
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Viewer inventory order: priority desc (the catch-all `code` comes last). */
|
|
128
|
+
function viewerOrder(a: FileViewerDescriptor, b: FileViewerDescriptor): number {
|
|
129
|
+
return (b.priority ?? 0) - (a.priority ?? 0)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Whether a feature declares any secondary settings (gear button shows). */
|
|
133
|
+
function hasSettings(feature: TabDescriptor | FileViewerDescriptor): boolean {
|
|
134
|
+
const settings = feature.settings
|
|
135
|
+
return settings !== undefined && (
|
|
136
|
+
(settings.toggles?.length ?? 0) > 0
|
|
137
|
+
|| (settings.pluginToggles?.length ?? 0) > 0
|
|
138
|
+
|| settings.render !== undefined
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** A feature's display name (viewers fall back to their id). */
|
|
143
|
+
function featureNameOf(feature: TabDescriptor | FileViewerDescriptor): string {
|
|
144
|
+
return textOf('title' in feature ? feature.title : undefined) || feature.id
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Merge one plugin-owned setting into a pluginSettings map (pure, v0.12.0+).
|
|
149
|
+
* Sequential merges are additive: each call spreads the map it was GIVEN,
|
|
150
|
+
* so building from the latest optimistic map keeps earlier keys intact
|
|
151
|
+
* (two same-tick writes must not drop each other).
|
|
152
|
+
*/
|
|
153
|
+
export function mergePluginSetting(
|
|
154
|
+
pluginSettings: Record<string, Record<string, unknown>>,
|
|
155
|
+
descriptorId: string,
|
|
156
|
+
key: string,
|
|
157
|
+
value: unknown,
|
|
158
|
+
): Record<string, Record<string, unknown>> {
|
|
159
|
+
return {
|
|
160
|
+
...pluginSettings,
|
|
161
|
+
[descriptorId]: { ...(pluginSettings[descriptorId] ?? {}), [key]: value },
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Render a custom settings panel (`settings.render`) with error containment:
|
|
167
|
+
* a throwing panel shows an inline error line instead of breaking the whole
|
|
168
|
+
* settings page.
|
|
169
|
+
*/
|
|
170
|
+
function SettingsRender(props: {
|
|
171
|
+
render: (renderProps: SidebarSettingsRenderProps) => ReactNode
|
|
172
|
+
renderProps: SidebarSettingsRenderProps
|
|
173
|
+
}) {
|
|
174
|
+
let content: ReactNode
|
|
175
|
+
try {
|
|
176
|
+
content = props.render(props.renderProps)
|
|
177
|
+
} catch (error) {
|
|
178
|
+
content = (
|
|
179
|
+
<div className={css.error} role="alert">
|
|
180
|
+
{t('settingsSaveFailed')} {error instanceof Error ? error.message : String(error)}
|
|
181
|
+
</div>
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
return <>{content}</>
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The custom switch: a real checkbox (hidden, native semantics and focus)
|
|
189
|
+
* driving a styled track/thumb. Used by the general toggle rows and the
|
|
190
|
+
* secondary settings popup rows.
|
|
191
|
+
*/
|
|
192
|
+
function Switch(props: {
|
|
193
|
+
checked: boolean
|
|
194
|
+
onChange: (next: boolean) => void
|
|
195
|
+
label: string
|
|
196
|
+
}) {
|
|
197
|
+
const { checked, onChange, label } = props
|
|
198
|
+
return (
|
|
199
|
+
<label className={css.switch}>
|
|
200
|
+
<input
|
|
201
|
+
type="checkbox"
|
|
202
|
+
className={css.switchInput}
|
|
203
|
+
checked={checked}
|
|
204
|
+
aria-label={label}
|
|
205
|
+
onChange={event => { onChange(event.currentTarget.checked) }}
|
|
206
|
+
/>
|
|
207
|
+
<span className={css.switchTrack} aria-hidden="true">
|
|
208
|
+
<span className={css.switchThumb} />
|
|
209
|
+
</span>
|
|
210
|
+
</label>
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The body of a feature's secondary settings popup: one row (title/desc +
|
|
216
|
+
* control) per declared setting. Switches render the custom switch; text and
|
|
217
|
+
* number rows render a free-form / numeric input committed on blur/Enter
|
|
218
|
+
* (clamped to the declared min/max). Extracted so the rows are testable
|
|
219
|
+
* without opening the Modal (the Modal portal renders only while open).
|
|
220
|
+
*/
|
|
221
|
+
export function FeatureSettingsRows(props: {
|
|
222
|
+
toggles: readonly SidebarSettingToggle[]
|
|
223
|
+
prefs: SidebarPrefs
|
|
224
|
+
onToggle: (toggle: SidebarSettingToggle, next: boolean) => void
|
|
225
|
+
/** Commit one text/number row; returns the canonical value the row should
|
|
226
|
+
* display (clamped for numbers, the current pref when the input is
|
|
227
|
+
* invalid). Optional: rows with no handler keep their draft. */
|
|
228
|
+
onCommit?: (toggle: SidebarSettingToggle, raw: string) => string
|
|
229
|
+
/** Commit one select row: the picked option's value (single) or the array
|
|
230
|
+
* of picked values (`multi: true`). Optional: rows with no handler are
|
|
231
|
+
* display-only. */
|
|
232
|
+
onSelectValue?: (toggle: SidebarSettingToggle, next: unknown) => void
|
|
233
|
+
/** Explicit value source (v0.12.0+): when given, rows read their values
|
|
234
|
+
* from it instead of the `prefs` face — plugin-owned rows read their
|
|
235
|
+
* own blob, so a plugin key can never collide with (or silently read)
|
|
236
|
+
* a host pref of the same name. (Named `valueSource`, not `valueOf`:
|
|
237
|
+
* the latter collides with the inherited Object.prototype.valueOf.) */
|
|
238
|
+
valueSource?: (key: string) => unknown
|
|
239
|
+
}) {
|
|
240
|
+
const { toggles, prefs, onToggle, onCommit, onSelectValue, valueSource } = props
|
|
241
|
+
const read = valueSource ?? ((key: string): unknown => (prefs as unknown as Record<string, unknown>)[key])
|
|
242
|
+
return (
|
|
243
|
+
<div className={css.popupRows}>
|
|
244
|
+
{toggles.map(toggle => {
|
|
245
|
+
const title = textOf(toggle.title)
|
|
246
|
+
if (toggle.type === 'select') {
|
|
247
|
+
return (
|
|
248
|
+
<SelectRow
|
|
249
|
+
key={toggle.key}
|
|
250
|
+
toggle={toggle}
|
|
251
|
+
title={title}
|
|
252
|
+
value={read(toggle.key)}
|
|
253
|
+
onSelectValue={onSelectValue}
|
|
254
|
+
/>
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
if ((toggle.type ?? 'switch') === 'switch') {
|
|
258
|
+
return (
|
|
259
|
+
<div key={toggle.key} className={css.popupRow}>
|
|
260
|
+
<span className={css.rowText}>
|
|
261
|
+
<span className={css.title}>{title}</span>
|
|
262
|
+
{textOf(toggle.desc) !== '' && <span className={css.desc}>{textOf(toggle.desc)}</span>}
|
|
263
|
+
</span>
|
|
264
|
+
<Switch
|
|
265
|
+
label={title}
|
|
266
|
+
checked={read(toggle.key) === true}
|
|
267
|
+
onChange={(next) => { onToggle(toggle, next) }}
|
|
268
|
+
/>
|
|
269
|
+
</div>
|
|
270
|
+
)
|
|
271
|
+
}
|
|
272
|
+
const value = String(read(toggle.key) ?? '')
|
|
273
|
+
// Keyed by the committed value: a failed commit reverts prefs, the
|
|
274
|
+
// key changes, and the row remounts with the stored value (typing
|
|
275
|
+
// never changes the key, so mid-edit drafts survive re-renders).
|
|
276
|
+
return (
|
|
277
|
+
<TypedRow
|
|
278
|
+
key={`${toggle.key}:${value}`}
|
|
279
|
+
toggle={toggle}
|
|
280
|
+
title={title}
|
|
281
|
+
value={value}
|
|
282
|
+
onCommit={onCommit}
|
|
283
|
+
/>
|
|
284
|
+
)
|
|
285
|
+
})}
|
|
286
|
+
</div>
|
|
287
|
+
)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* One text/number row: a controlled input whose draft is local state,
|
|
292
|
+
* committed on blur/Enter through the parent's onCommit. The parent's
|
|
293
|
+
* canonical return is adopted (clamped numbers, stored value for invalid
|
|
294
|
+
* input); a `unit` suffix renders after the input (e.g. 'px').
|
|
295
|
+
*/
|
|
296
|
+
function TypedRow(props: {
|
|
297
|
+
toggle: SidebarSettingToggle
|
|
298
|
+
title: string
|
|
299
|
+
value: string
|
|
300
|
+
onCommit?: (toggle: SidebarSettingToggle, raw: string) => string
|
|
301
|
+
}) {
|
|
302
|
+
const { toggle, title, value, onCommit } = props
|
|
303
|
+
const [draft, setDraft] = useState(value)
|
|
304
|
+
const commit = (): void => {
|
|
305
|
+
const canonical = onCommit?.(toggle, draft) ?? draft
|
|
306
|
+
setDraft(canonical)
|
|
307
|
+
}
|
|
308
|
+
const number = toggle.type === 'number'
|
|
309
|
+
return (
|
|
310
|
+
<div className={css.popupRow}>
|
|
311
|
+
<span className={css.rowText}>
|
|
312
|
+
<span className={css.title}>{title}</span>
|
|
313
|
+
{textOf(toggle.desc) !== '' && <span className={css.desc}>{textOf(toggle.desc)}</span>}
|
|
314
|
+
</span>
|
|
315
|
+
<span className={css.control}>
|
|
316
|
+
<Input
|
|
317
|
+
type={number ? 'number' : 'text'}
|
|
318
|
+
className={number ? css.typedInputNumber : css.typedInput}
|
|
319
|
+
value={draft}
|
|
320
|
+
min={toggle.min}
|
|
321
|
+
max={toggle.max}
|
|
322
|
+
step={1}
|
|
323
|
+
placeholder={toggle.placeholder}
|
|
324
|
+
aria-label={title}
|
|
325
|
+
onChange={event => { setDraft(event.currentTarget.value) }}
|
|
326
|
+
onBlur={commit}
|
|
327
|
+
onKeyDown={event => {
|
|
328
|
+
if (event.key === 'Enter') event.currentTarget.blur()
|
|
329
|
+
}}
|
|
330
|
+
/>
|
|
331
|
+
{toggle.unit !== undefined && <span className={css.suffix}>{toggle.unit}</span>}
|
|
332
|
+
</span>
|
|
333
|
+
</div>
|
|
334
|
+
)
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* The multi-line custom-CSS input (scheme `custom`): a monospace textarea
|
|
338
|
+
* whose draft is local state, committed on blur or Cmd/Ctrl+Enter through
|
|
339
|
+
* the parent's handler. Keyed by the stored value so an external commit
|
|
340
|
+
* remounts it with the canonical text (same pattern as TypedRow).
|
|
341
|
+
*/
|
|
342
|
+
function CssDraft(props: {
|
|
343
|
+
value: string
|
|
344
|
+
onCommit: (raw: string) => void
|
|
345
|
+
label: string
|
|
346
|
+
placeholder?: string
|
|
347
|
+
}) {
|
|
348
|
+
const { value, onCommit, label, placeholder } = props
|
|
349
|
+
const [draft, setDraft] = useState(value)
|
|
350
|
+
return (
|
|
351
|
+
<textarea
|
|
352
|
+
className={css.cssTextArea}
|
|
353
|
+
rows={6}
|
|
354
|
+
value={draft}
|
|
355
|
+
placeholder={placeholder}
|
|
356
|
+
aria-label={label}
|
|
357
|
+
spellCheck={false}
|
|
358
|
+
onChange={event => { setDraft(event.currentTarget.value) }}
|
|
359
|
+
onBlur={() => { onCommit(draft) }}
|
|
360
|
+
onKeyDown={event => {
|
|
361
|
+
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) event.currentTarget.blur()
|
|
362
|
+
}}
|
|
363
|
+
/>
|
|
364
|
+
)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* The reusable dropdown — the primitives Menu, NOT a native <select>: a
|
|
369
|
+
* closed anchor button (picked option text + chevron) opening one Menu item
|
|
370
|
+
* per option (big-icon cards when any option carries an icon). Single-pick
|
|
371
|
+
* commits the option's value and closes; `multi` toggles membership and
|
|
372
|
+
* commits the picked values as an array (in options order), staying open.
|
|
373
|
+
* Shared by the declarative select rows (SelectRow) and the title-bar
|
|
374
|
+
* scheme dropdown on the General row.
|
|
375
|
+
*/
|
|
376
|
+
function SelectMenu(props: {
|
|
377
|
+
label: string
|
|
378
|
+
value: unknown
|
|
379
|
+
options: readonly {
|
|
380
|
+
value: string | number | boolean
|
|
381
|
+
title: string | (() => string)
|
|
382
|
+
desc?: string | (() => string)
|
|
383
|
+
icon?: ReactNode | ((size: number) => ReactNode)
|
|
384
|
+
}[]
|
|
385
|
+
multi?: boolean
|
|
386
|
+
onSelect: (next: unknown) => void
|
|
387
|
+
placeholder?: string
|
|
388
|
+
}) {
|
|
389
|
+
const { label, value, options, multi, onSelect, placeholder } = props
|
|
390
|
+
const [open, setOpen] = useState(false)
|
|
391
|
+
const hasIcons = options.some(option => option.icon !== undefined)
|
|
392
|
+
const picked: readonly unknown[] = multi ? (Array.isArray(value) ? value : []) : [value]
|
|
393
|
+
const selected = options.filter(option => picked.includes(option.value))
|
|
394
|
+
|
|
395
|
+
/** Commit one picked option (toggle semantics under multi). */
|
|
396
|
+
const pick = (index: number): void => {
|
|
397
|
+
const option = options[index]
|
|
398
|
+
if (option === undefined) return
|
|
399
|
+
if (!multi) {
|
|
400
|
+
onSelect(option.value)
|
|
401
|
+
setOpen(false)
|
|
402
|
+
return
|
|
403
|
+
}
|
|
404
|
+
const current = Array.isArray(value) ? [...value] : []
|
|
405
|
+
const at = current.indexOf(option.value)
|
|
406
|
+
if (at >= 0) current.splice(at, 1)
|
|
407
|
+
else current.push(option.value)
|
|
408
|
+
// Stable wire order: follow the declared options order, not pick order.
|
|
409
|
+
onSelect(options.filter(o => current.includes(o.value)).map(o => o.value))
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const anchor = (
|
|
413
|
+
<button
|
|
414
|
+
type="button"
|
|
415
|
+
className={css.selectAnchor}
|
|
416
|
+
aria-label={label}
|
|
417
|
+
aria-haspopup="listbox"
|
|
418
|
+
aria-expanded={open}
|
|
419
|
+
onClick={() => { setOpen(now => !now) }}
|
|
420
|
+
>
|
|
421
|
+
{!multi && hasIcons && selected[0] !== undefined && (
|
|
422
|
+
<span className={css.selectAnchorIcon}>{iconOf(selected[0].icon, 16)}</span>
|
|
423
|
+
)}
|
|
424
|
+
<span className={css.selectAnchorText}>
|
|
425
|
+
{selected.length === 0 ? (placeholder ?? '—') : selected.map(option => textOf(option.title)).join(', ')}
|
|
426
|
+
</span>
|
|
427
|
+
<IconChevronDownOutline14 size={12} />
|
|
428
|
+
</button>
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
return (
|
|
432
|
+
<Menu
|
|
433
|
+
open={open}
|
|
434
|
+
anchor={anchor}
|
|
435
|
+
items={options.map((option, index) => ({
|
|
436
|
+
id: String(index),
|
|
437
|
+
label: hasIcons
|
|
438
|
+
? (
|
|
439
|
+
<span className={css.selectOption}>
|
|
440
|
+
<span className={css.selectOptionIcon}>{iconOf(option.icon, 24)}</span>
|
|
441
|
+
<span className={css.selectOptionText}>
|
|
442
|
+
<span className={css.title}>{textOf(option.title)}</span>
|
|
443
|
+
{textOf(option.desc) !== '' && <span className={css.desc}>{textOf(option.desc)}</span>}
|
|
444
|
+
</span>
|
|
445
|
+
</span>
|
|
446
|
+
)
|
|
447
|
+
: textOf(option.title),
|
|
448
|
+
}))}
|
|
449
|
+
selectedId={!multi && selected[0] !== undefined ? String(options.indexOf(selected[0])) : undefined}
|
|
450
|
+
selectedIds={multi ? selected.map(option => String(options.indexOf(option))) : undefined}
|
|
451
|
+
onSelect={(id) => { pick(Number(id)) }}
|
|
452
|
+
onClose={() => { setOpen(false) }}
|
|
453
|
+
portal
|
|
454
|
+
/>
|
|
455
|
+
)
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* One select row: a dropdown over the toggle's declared `options` (the
|
|
460
|
+
* shared SelectMenu). When any option carries an icon, the dropdown renders
|
|
461
|
+
* big-icon option cards (icon + title + desc) and the closed anchor shows
|
|
462
|
+
* the selected option's icon as well; without icons both are a single line
|
|
463
|
+
* of text. Single-pick commits the option's value and closes; `multi`
|
|
464
|
+
* toggles membership, commits the picked values as an array (in options
|
|
465
|
+
* order), and stays open.
|
|
466
|
+
*/
|
|
467
|
+
function SelectRow(props: {
|
|
468
|
+
toggle: SidebarSettingToggle
|
|
469
|
+
title: string
|
|
470
|
+
value: unknown
|
|
471
|
+
onSelectValue?: (toggle: SidebarSettingToggle, next: unknown) => void
|
|
472
|
+
}) {
|
|
473
|
+
const { toggle, title, value, onSelectValue } = props
|
|
474
|
+
return (
|
|
475
|
+
<div className={css.popupRow}>
|
|
476
|
+
<span className={css.rowText}>
|
|
477
|
+
<span className={css.title}>{title}</span>
|
|
478
|
+
{textOf(toggle.desc) !== '' && <span className={css.desc}>{textOf(toggle.desc)}</span>}
|
|
479
|
+
</span>
|
|
480
|
+
<span className={css.control}>
|
|
481
|
+
<SelectMenu
|
|
482
|
+
label={title}
|
|
483
|
+
value={value}
|
|
484
|
+
options={toggle.options ?? []}
|
|
485
|
+
multi={toggle.multi === true}
|
|
486
|
+
onSelect={(next) => { onSelectValue?.(toggle, next) }}
|
|
487
|
+
/>
|
|
488
|
+
</span>
|
|
489
|
+
</div>
|
|
490
|
+
)
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* The secondary settings popup body of one feature (tab or viewer):
|
|
495
|
+
* - the host-prefs `toggles` rows, then the plugin-owned `pluginToggles`
|
|
496
|
+
* rows (their values live in `pluginSettings[feature.id]`, projected onto
|
|
497
|
+
* the prefs face so the shared row renderer reads them);
|
|
498
|
+
* - `settings.render` (custom panel) AFTER those rows when declared — the
|
|
499
|
+
* custom panel is an extension of the row list, not a replacement, so a
|
|
500
|
+
* feature can keep its declarative rows (e.g. the editor's
|
|
501
|
+
* open-behavior picker) and still ship a custom configuration area.
|
|
502
|
+
*/
|
|
503
|
+
export function SettingsBody(props: {
|
|
504
|
+
feature: TabDescriptor | FileViewerDescriptor
|
|
505
|
+
prefs: SidebarPrefs
|
|
506
|
+
store: SidebarStore
|
|
507
|
+
service: BetterSidebarService
|
|
508
|
+
onToggle: (toggle: SidebarSettingToggle, next: boolean) => void
|
|
509
|
+
onCommit: (toggle: SidebarSettingToggle, raw: string) => string
|
|
510
|
+
onSelectValue: (toggle: SidebarSettingToggle, next: unknown) => void
|
|
511
|
+
onPluginToggle: (toggle: SidebarSettingToggle, next: boolean) => void
|
|
512
|
+
onPluginCommit: (toggle: SidebarSettingToggle, raw: string) => string
|
|
513
|
+
onPluginSelectValue: (toggle: SidebarSettingToggle, next: unknown) => void
|
|
514
|
+
onPluginWrite: (key: string, value: unknown) => void
|
|
515
|
+
onClose: () => void
|
|
516
|
+
}) {
|
|
517
|
+
const { feature, prefs, store, service, onToggle, onCommit, onSelectValue, onPluginToggle, onPluginCommit, onPluginSelectValue, onPluginWrite, onClose } = props
|
|
518
|
+
const render = feature.settings?.render
|
|
519
|
+
const toggles = feature.settings?.toggles ?? []
|
|
520
|
+
const pluginToggles = feature.settings?.pluginToggles ?? []
|
|
521
|
+
if (render === undefined && toggles.length === 0 && pluginToggles.length === 0) return null
|
|
522
|
+
// Plugin rows read their values from the descriptor's OWN blob through
|
|
523
|
+
// an explicit value source — no projection onto the prefs face, so a
|
|
524
|
+
// plugin key can never collide with (or silently read) a host pref of
|
|
525
|
+
// the same name.
|
|
526
|
+
const pluginBlob = prefs.pluginSettings[feature.id] ?? {}
|
|
527
|
+
return (
|
|
528
|
+
<div>
|
|
529
|
+
{(toggles.length > 0 || pluginToggles.length > 0) && (
|
|
530
|
+
<div className={css.popupRows}>
|
|
531
|
+
{toggles.length > 0 && (
|
|
532
|
+
<FeatureSettingsRows
|
|
533
|
+
toggles={toggles}
|
|
534
|
+
prefs={prefs}
|
|
535
|
+
onToggle={onToggle}
|
|
536
|
+
onCommit={onCommit}
|
|
537
|
+
onSelectValue={onSelectValue}
|
|
538
|
+
/>
|
|
539
|
+
)}
|
|
540
|
+
{pluginToggles.length > 0 && (
|
|
541
|
+
<FeatureSettingsRows
|
|
542
|
+
toggles={pluginToggles}
|
|
543
|
+
prefs={prefs}
|
|
544
|
+
onToggle={onPluginToggle}
|
|
545
|
+
onCommit={onPluginCommit}
|
|
546
|
+
onSelectValue={onPluginSelectValue}
|
|
547
|
+
valueSource={(key) => pluginBlob[key]}
|
|
548
|
+
/>
|
|
549
|
+
)}
|
|
550
|
+
</div>
|
|
551
|
+
)}
|
|
552
|
+
{render !== undefined && (
|
|
553
|
+
<SettingsRender
|
|
554
|
+
render={render}
|
|
555
|
+
renderProps={{
|
|
556
|
+
store,
|
|
557
|
+
service,
|
|
558
|
+
prefs,
|
|
559
|
+
pluginSettings: prefs.pluginSettings[feature.id] ?? {},
|
|
560
|
+
updatePluginSetting: onPluginWrite,
|
|
561
|
+
close: onClose,
|
|
562
|
+
}}
|
|
563
|
+
/>
|
|
564
|
+
)}
|
|
565
|
+
</div>
|
|
566
|
+
)
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Render the Side card preferences section.
|
|
571
|
+
* @param props - composed slot props (runtime share + injected store/service).
|
|
572
|
+
* @returns the section element tree.
|
|
573
|
+
*/
|
|
574
|
+
export function SideCardSection({ store, service }: SideCardSectionProps) {
|
|
575
|
+
const [prefs, setPrefs] = useState<SidebarPrefs>(() => store.getPrefs())
|
|
576
|
+
const [widthDraft, setWidthDraft] = useState<string>(String(store.getPrefs().defaultWidthPercent))
|
|
577
|
+
const [error, setError] = useState<string | null>(null)
|
|
578
|
+
// Which feature's secondary settings popup is open (null = closed).
|
|
579
|
+
const [settingsFor, setSettingsFor] = useState<TabDescriptor | FileViewerDescriptor | null>(null)
|
|
580
|
+
// Whether the position-compat strip popup (the gear on the 常规 row) is open.
|
|
581
|
+
const [stripSettingsOpen, setStripSettingsOpen] = useState(false)
|
|
582
|
+
// The parsed desktop environment (URL stamps — see desktop-env.ts). Used
|
|
583
|
+
// ONLY to badge matching presets in the scheme dropdown ("已检测");
|
|
584
|
+
// nothing is auto-applied.
|
|
585
|
+
const detectedEnv = useMemo(() => parseDesktopEnv(), [])
|
|
586
|
+
// Whether the "add plugin" modal (a dashed card at the end of the
|
|
587
|
+
// 侧边栏内容 / 文件预览 grids) is open, and for which extension point
|
|
588
|
+
// (null = closed).
|
|
589
|
+
const [addPluginsOpen, setAddPluginsOpen] = useState<PluginKind | null>(null)
|
|
590
|
+
// The LATEST optimistic prefs, kept in sync with the state. Nested-map
|
|
591
|
+
// merges (tabsEnabled / viewersEnabled / pluginSettings) MUST build from
|
|
592
|
+
// this ref, not from the render-time `prefs`: two same-tick writes (e.g.
|
|
593
|
+
// a settings panel updating several plugin keys at once) would otherwise
|
|
594
|
+
// both spread the stale map and the later patch would drop the earlier
|
|
595
|
+
// key even though the commits are serialized.
|
|
596
|
+
const optimisticRef = useRef(prefs)
|
|
597
|
+
useEffect(() => { optimisticRef.current = prefs }, [prefs])
|
|
598
|
+
|
|
599
|
+
// The declarative inventory: the registered tab types and file viewers.
|
|
600
|
+
// Local state + service.subscribe (registry changes are rare — plugin
|
|
601
|
+
// load/unload — so a plain effect is enough; no external-store ceremony).
|
|
602
|
+
const [tabs, setTabs] = useState<TabDescriptor[]>(() => [...service.getTabs()].sort(tabOrder))
|
|
603
|
+
const [viewers, setViewers] = useState<FileViewerDescriptor[]>(() => [...service.getFileViewers()].sort(viewerOrder))
|
|
604
|
+
useEffect(() => service.subscribe(() => {
|
|
605
|
+
setTabs([...service.getTabs()].sort(tabOrder))
|
|
606
|
+
setViewers([...service.getFileViewers()].sort(viewerOrder))
|
|
607
|
+
}), [service])
|
|
608
|
+
|
|
609
|
+
// The settings document revision (guards concurrent writes). A ref: commits
|
|
610
|
+
// read the freshest value at execution time, no re-render needed.
|
|
611
|
+
const revisionRef = useRef<number | undefined>(undefined)
|
|
612
|
+
// Whether the user already wrote since mount: the mount read must not
|
|
613
|
+
// clobber a newer optimistic edit (the window is milliseconds, but a slow
|
|
614
|
+
// route must never silently revert a just-made change).
|
|
615
|
+
const dirtyRef = useRef(false)
|
|
616
|
+
// Serialize commits: a queued write must observe the previous write's
|
|
617
|
+
// revision; a failed write must not poison the queue for later ones.
|
|
618
|
+
const inFlightRef = useRef<Promise<unknown>>(Promise.resolve())
|
|
619
|
+
|
|
620
|
+
// Sync the persisted document once on mount: the revision and the current
|
|
621
|
+
// values (another tab may have changed them since the store hydrated).
|
|
622
|
+
useEffect(() => {
|
|
623
|
+
let cancelled = false
|
|
624
|
+
void api.settingsGet().then((view) => {
|
|
625
|
+
if (cancelled) return
|
|
626
|
+
revisionRef.current = view.revision
|
|
627
|
+
if (dirtyRef.current) return
|
|
628
|
+
const next = parsePrefs(view.value)
|
|
629
|
+
setPrefs(next)
|
|
630
|
+
setWidthDraft(String(next.defaultWidthPercent))
|
|
631
|
+
}).catch(() => { /* the store's defaults stay authoritative */ })
|
|
632
|
+
return () => { cancelled = true }
|
|
633
|
+
}, [])
|
|
634
|
+
|
|
635
|
+
/** Persist one patch through the settings route (serialized, revision-guarded). */
|
|
636
|
+
const commit = (patch: Record<string, unknown>): Promise<{ ok: boolean; prefs: SidebarPrefs }> => {
|
|
637
|
+
dirtyRef.current = true
|
|
638
|
+
const run = inFlightRef.current.then(async () => {
|
|
639
|
+
const view = await api.settingsUpdate(
|
|
640
|
+
{ ...patch },
|
|
641
|
+
revisionRef.current,
|
|
642
|
+
)
|
|
643
|
+
const next = parsePrefs(view.value)
|
|
644
|
+
revisionRef.current = view.revision
|
|
645
|
+
store.setPrefs(next)
|
|
646
|
+
return next
|
|
647
|
+
})
|
|
648
|
+
// A failed commit must not poison the queue: later writes still run.
|
|
649
|
+
inFlightRef.current = run.then(() => undefined, () => undefined)
|
|
650
|
+
return run.then(
|
|
651
|
+
(next) => ({ ok: true, prefs: next }),
|
|
652
|
+
(caught) => {
|
|
653
|
+
setError(messageOf(caught))
|
|
654
|
+
return { ok: false, prefs }
|
|
655
|
+
},
|
|
656
|
+
)
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/** Settle one commit: success adopts the server values, failure reverts. */
|
|
660
|
+
const applyOutcome = (previous: SidebarPrefs, outcome: { ok: boolean; prefs: SidebarPrefs }): void => {
|
|
661
|
+
const settled = outcome.ok ? outcome.prefs : previous
|
|
662
|
+
setPrefs(settled)
|
|
663
|
+
setWidthDraft(String(settled.defaultWidthPercent))
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/** Optimistically apply one pref patch, then commit (revert on failure). */
|
|
667
|
+
const applyPref = (patch: Record<string, unknown>): void => {
|
|
668
|
+
const previous = optimisticRef.current
|
|
669
|
+
const next = { ...previous, ...patch } as SidebarPrefs
|
|
670
|
+
optimisticRef.current = next
|
|
671
|
+
setPrefs(next)
|
|
672
|
+
setError(null)
|
|
673
|
+
void commit(patch).then(outcome => applyOutcome(previous, outcome))
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const onToggle = (next: boolean): void => {
|
|
677
|
+
applyPref({ openByDefault: next })
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/** Flip one per-tab enable switch (merge into the tabsEnabled map). */
|
|
681
|
+
const onToggleTab = (id: string, next: boolean): void => {
|
|
682
|
+
applyPref({ tabsEnabled: { ...optimisticRef.current.tabsEnabled, [id]: next } })
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/** Flip one per-viewer enable switch (merge into the viewersEnabled map). */
|
|
686
|
+
const onToggleViewer = (id: string, next: boolean): void => {
|
|
687
|
+
applyPref({ viewersEnabled: { ...optimisticRef.current.viewersEnabled, [id]: next } })
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/** Flip one declaratively-declared toggle (a SidebarPrefs boolean field). */
|
|
691
|
+
const onToggleSetting = (toggle: SidebarSettingToggle, next: boolean): void => {
|
|
692
|
+
applyPref({ [toggle.key]: next })
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/** Commit one declaratively-declared select row (the option's value, or an
|
|
696
|
+
* array of values under `multi`). */
|
|
697
|
+
const onSelectSetting = (toggle: SidebarSettingToggle, next: unknown): void => {
|
|
698
|
+
applyPref({ [toggle.key]: next })
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* Commit one declaratively-declared text/number row. Numbers are parsed
|
|
703
|
+
* and clamped to the toggle's declared min/max (an unparsable input falls
|
|
704
|
+
* back to the CURRENT stored value, mirroring the width row); text rows
|
|
705
|
+
* persist as-is (empty is meaningful, e.g. the theme-default font).
|
|
706
|
+
* Returns the canonical value the row should display.
|
|
707
|
+
*/
|
|
708
|
+
const onCommitSetting = (toggle: SidebarSettingToggle, raw: string): string => {
|
|
709
|
+
if (toggle.type === 'number') {
|
|
710
|
+
const parsed = Number(raw)
|
|
711
|
+
const fallback = String((prefs as unknown as Record<string, unknown>)[toggle.key] ?? '')
|
|
712
|
+
if (!Number.isFinite(parsed)) return fallback
|
|
713
|
+
let clamped = Math.round(parsed)
|
|
714
|
+
if (toggle.min !== undefined) clamped = Math.max(toggle.min, clamped)
|
|
715
|
+
if (toggle.max !== undefined) clamped = Math.min(toggle.max, clamped)
|
|
716
|
+
applyPref({ [toggle.key]: clamped })
|
|
717
|
+
return String(clamped)
|
|
718
|
+
}
|
|
719
|
+
applyPref({ [toggle.key]: raw })
|
|
720
|
+
return raw
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* Pick the title-bar / shell compatibility scheme. Mirrors the legacy
|
|
725
|
+
* `titleBarCompat` flag (true = anything but the conservative auto) so
|
|
726
|
+
* documents stay readable by older plugin versions.
|
|
727
|
+
*/
|
|
728
|
+
/**
|
|
729
|
+
* Pick the title-bar / shell compatibility scheme from the dropdown. The
|
|
730
|
+
* option values are `auto` | `web` | `custom` | `preset:<id>`; selecting
|
|
731
|
+
* a preset stores both the scheme and its id. Mirrors the legacy
|
|
732
|
+
* `titleBarCompat` flag (true for preset/custom) so documents stay
|
|
733
|
+
* readable by older plugin versions.
|
|
734
|
+
*/
|
|
735
|
+
const onSchemeSelect = (value: unknown): void => {
|
|
736
|
+
if (typeof value !== 'string') return
|
|
737
|
+
if (value === 'auto' || value === 'web' || value === 'custom') {
|
|
738
|
+
applyPref({ titleBarScheme: value, titleBarCompat: value === 'custom' })
|
|
739
|
+
return
|
|
740
|
+
}
|
|
741
|
+
if (value.startsWith('preset:') && getShellPreset(value.slice('preset:'.length)) !== undefined) {
|
|
742
|
+
applyPref({
|
|
743
|
+
titleBarScheme: 'preset',
|
|
744
|
+
titleBarPresetId: value.slice('preset:'.length),
|
|
745
|
+
titleBarCompat: true,
|
|
746
|
+
})
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/** Commit the free-form custom CSS (scheme `custom`). */
|
|
751
|
+
const commitCustomCss = (raw: string): void => {
|
|
752
|
+
applyPref({ customCss: raw })
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/** Persist one plugin-owned setting of one descriptor (merged into the pluginSettings blob). */
|
|
756
|
+
const applyPluginSetting = (descriptorId: string, key: string, value: unknown): void => {
|
|
757
|
+
applyPref({ pluginSettings: mergePluginSetting(optimisticRef.current.pluginSettings, descriptorId, key, value) })
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/** Flip one plugin-owned switch row (same row shape, plugin-scoped key). */
|
|
761
|
+
const onPluginToggle = (descriptorId: string, toggle: SidebarSettingToggle, next: boolean): void => {
|
|
762
|
+
applyPluginSetting(descriptorId, toggle.key, next)
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/** Commit one plugin-owned text/number row (clamped like the host rows). */
|
|
766
|
+
const onPluginCommitSetting = (descriptorId: string, toggle: SidebarSettingToggle, raw: string): string => {
|
|
767
|
+
if (toggle.type === 'number') {
|
|
768
|
+
const parsed = Number(raw)
|
|
769
|
+
const blob = prefs.pluginSettings[descriptorId] ?? {}
|
|
770
|
+
const fallback = String(blob[toggle.key] ?? '')
|
|
771
|
+
if (!Number.isFinite(parsed)) return fallback
|
|
772
|
+
let clamped = Math.round(parsed)
|
|
773
|
+
if (toggle.min !== undefined) clamped = Math.max(toggle.min, clamped)
|
|
774
|
+
if (toggle.max !== undefined) clamped = Math.min(toggle.max, clamped)
|
|
775
|
+
applyPluginSetting(descriptorId, toggle.key, clamped)
|
|
776
|
+
return String(clamped)
|
|
777
|
+
}
|
|
778
|
+
applyPluginSetting(descriptorId, toggle.key, raw)
|
|
779
|
+
return raw
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const commitWidth = (): void => {
|
|
783
|
+
const parsed = Number(widthDraft)
|
|
784
|
+
if (!Number.isFinite(parsed)) {
|
|
785
|
+
setWidthDraft(String(prefs.defaultWidthPercent))
|
|
786
|
+
return
|
|
787
|
+
}
|
|
788
|
+
const clamped = clampWidthPercent(parsed)
|
|
789
|
+
const previous = prefs
|
|
790
|
+
setPrefs({ ...previous, defaultWidthPercent: clamped })
|
|
791
|
+
setWidthDraft(String(clamped))
|
|
792
|
+
setError(null)
|
|
793
|
+
void commit({ defaultWidthPercent: clamped }).then(outcome => applyOutcome(previous, outcome))
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* One SMALL toggle card for the responsive inventory grid: the card's main
|
|
798
|
+
* area is the switch (click to flips, visual state IS the state), the icon
|
|
799
|
+
* sits in a rounded chip, the check badge pins to the far right, and a
|
|
800
|
+
* feature that declares related settings gets a labeled SETTINGS STRIP
|
|
801
|
+
* across the card's bottom edge (gear icon + text) opening its settings
|
|
802
|
+
* popup — discoverable at rest, not a hover-only ghost corner button.
|
|
803
|
+
*/
|
|
804
|
+
const renderCard = (props: {
|
|
805
|
+
title: string
|
|
806
|
+
desc: string
|
|
807
|
+
icon?: ReactNode
|
|
808
|
+
enabled: boolean
|
|
809
|
+
onToggle: (next: boolean) => void
|
|
810
|
+
/** A feature with declared related settings shows the settings strip. */
|
|
811
|
+
onOpenSettings?: () => void
|
|
812
|
+
}) => {
|
|
813
|
+
const hasSettings = props.onOpenSettings !== undefined
|
|
814
|
+
return (
|
|
815
|
+
<div
|
|
816
|
+
className={clsx(css.card, props.enabled && css.cardOn)}
|
|
817
|
+
>
|
|
818
|
+
<button
|
|
819
|
+
type="button"
|
|
820
|
+
className={css.cardMain}
|
|
821
|
+
aria-pressed={props.enabled}
|
|
822
|
+
title={props.desc}
|
|
823
|
+
onClick={() => { props.onToggle(!props.enabled) }}
|
|
824
|
+
>
|
|
825
|
+
<span className={css.cardTop}>
|
|
826
|
+
{props.icon !== null && props.icon !== undefined && (
|
|
827
|
+
<span className={css.cardIconChip}>{props.icon}</span>
|
|
828
|
+
)}
|
|
829
|
+
<span className={css.cardTitle}>{props.title}</span>
|
|
830
|
+
{props.enabled && (
|
|
831
|
+
<span className={css.cardSwitch} aria-hidden="true">
|
|
832
|
+
<span className={css.cardSwitchTrack}>
|
|
833
|
+
<span className={css.cardSwitchThumb} />
|
|
834
|
+
</span>
|
|
835
|
+
</span>
|
|
836
|
+
)}
|
|
837
|
+
</span>
|
|
838
|
+
<span className={css.cardDesc}>{props.desc}</span>
|
|
839
|
+
</button>
|
|
840
|
+
{hasSettings && (
|
|
841
|
+
<button
|
|
842
|
+
type="button"
|
|
843
|
+
className={css.cardSettings}
|
|
844
|
+
aria-label={`${props.title} ${t('settingsPopup')}`}
|
|
845
|
+
onClick={props.onOpenSettings}
|
|
846
|
+
>
|
|
847
|
+
<IconSettingsOutline16 size={12} />
|
|
848
|
+
<span>{t('settingsPopup')}</span>
|
|
849
|
+
</button>
|
|
850
|
+
)}
|
|
851
|
+
</div>
|
|
852
|
+
)
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
return (
|
|
856
|
+
<div className={css.section}>
|
|
857
|
+
<p className={css.intro}>{t('settingsIntro')}</p>
|
|
858
|
+
|
|
859
|
+
{/* The managing plugin's own identity: name + version badge, so the
|
|
860
|
+
section is attributable at a glance (the version is the service
|
|
861
|
+
instance's, kept in lockstep with package.json by
|
|
862
|
+
tests/service.spec.ts). */}
|
|
863
|
+
<div className={css.versionBadge}>
|
|
864
|
+
<span className={css.versionBadgeName}>DSH-better-sidebar</span>
|
|
865
|
+
<span className={css.versionBadgeTag}>v{service.version}</span>
|
|
866
|
+
</div>
|
|
867
|
+
|
|
868
|
+
{/* 常规: the DSH settings-row recipe — title/desc left, control right. */}
|
|
869
|
+
<div className={css.group}>
|
|
870
|
+
<div className={css.groupHeading}>{t('settingsGeneralTitle')}</div>
|
|
871
|
+
<div className={css.row}>
|
|
872
|
+
<span className={css.rowText}>
|
|
873
|
+
<span className={css.title}>{t('settingsOpenTitle')}</span>
|
|
874
|
+
<span className={css.desc}>{t('settingsOpenDesc')}</span>
|
|
875
|
+
</span>
|
|
876
|
+
<Switch
|
|
877
|
+
label={t('settingsOpenTitle')}
|
|
878
|
+
checked={prefs.openByDefault}
|
|
879
|
+
onChange={onToggle}
|
|
880
|
+
/>
|
|
881
|
+
</div>
|
|
882
|
+
<div className={css.row}>
|
|
883
|
+
<span className={css.rowText}>
|
|
884
|
+
<span className={css.title}>{t('settingsWidthTitle')}</span>
|
|
885
|
+
<span className={css.desc}>{t('settingsWidthDesc')}</span>
|
|
886
|
+
</span>
|
|
887
|
+
<span className={css.control}>
|
|
888
|
+
<Input
|
|
889
|
+
type="number"
|
|
890
|
+
className={css.percentInput}
|
|
891
|
+
value={widthDraft}
|
|
892
|
+
min={WIDTH_PERCENT_MIN}
|
|
893
|
+
max={WIDTH_PERCENT_MAX}
|
|
894
|
+
step={1}
|
|
895
|
+
aria-label={t('settingsWidthTitle')}
|
|
896
|
+
onChange={event => { setWidthDraft(event.currentTarget.value) }}
|
|
897
|
+
onBlur={commitWidth}
|
|
898
|
+
onKeyDown={event => {
|
|
899
|
+
if (event.key === 'Enter') event.currentTarget.blur()
|
|
900
|
+
}}
|
|
901
|
+
/>
|
|
902
|
+
<span className={css.suffix}>{t('settingsWidthSuffix')}</span>
|
|
903
|
+
</span>
|
|
904
|
+
</div>
|
|
905
|
+
<div className={css.row}>
|
|
906
|
+
<span className={css.rowText}>
|
|
907
|
+
<span className={css.title}>{t('settingsOpenPathTitle')}</span>
|
|
908
|
+
<span className={css.desc}>{t('settingsOpenPathDesc')}</span>
|
|
909
|
+
</span>
|
|
910
|
+
<Switch
|
|
911
|
+
label={t('settingsOpenPathTitle')}
|
|
912
|
+
checked={prefs.interceptOpenPath}
|
|
913
|
+
onChange={(next) => { applyPref({ interceptOpenPath: next }) }}
|
|
914
|
+
/>
|
|
915
|
+
</div>
|
|
916
|
+
<div className={css.row}>
|
|
917
|
+
<span className={css.rowText}>
|
|
918
|
+
<span className={css.title}>{t('settingsTitleBarTitle')}</span>
|
|
919
|
+
<span className={css.desc}>{t('settingsTitleBarDesc')}</span>
|
|
920
|
+
</span>
|
|
921
|
+
<span className={css.control}>
|
|
922
|
+
{/*
|
|
923
|
+
The scheme dropdown (the shared SelectMenu — NOT a native
|
|
924
|
+
select): 自动检测 (default) / DSH官方Web / 各壳兼容方案 /
|
|
925
|
+
自定义方案. Matching presets carry a 「已检测」 desc badge
|
|
926
|
+
(suggestion only). The 自定义方案 row keeps its gear (the
|
|
927
|
+
popup with the shift distance + custom CSS) — the other
|
|
928
|
+
schemes need no further settings.
|
|
929
|
+
*/}
|
|
930
|
+
<SelectMenu
|
|
931
|
+
label={t('settingsTitleBarTitle')}
|
|
932
|
+
value={titleBarSchemeValue(prefs)}
|
|
933
|
+
options={[
|
|
934
|
+
{ value: 'auto', title: t('settingsSchemeAutoTitle'), desc: t('settingsSchemeAutoDesc') },
|
|
935
|
+
{ value: 'web', title: t('settingsSchemeWebTitle'), desc: t('settingsSchemeWebDesc') },
|
|
936
|
+
...getShellPresets().map(preset => ({
|
|
937
|
+
value: `preset:${preset.id}`,
|
|
938
|
+
title: preset.title,
|
|
939
|
+
desc: preset.detect?.(detectedEnv) === true
|
|
940
|
+
? `${preset.desc}(${t('settingsSchemeDetectedSuffix')})`
|
|
941
|
+
: preset.desc,
|
|
942
|
+
})),
|
|
943
|
+
{ value: 'custom', title: t('settingsSchemeCustomTitle'), desc: t('settingsSchemeCustomDesc') },
|
|
944
|
+
]}
|
|
945
|
+
onSelect={onSchemeSelect}
|
|
946
|
+
/>
|
|
947
|
+
{prefs.titleBarScheme === 'custom' && (
|
|
948
|
+
<button
|
|
949
|
+
type="button"
|
|
950
|
+
className={css.rowGear}
|
|
951
|
+
aria-label={`${t('settingsTitleBarTitle')} ${t('settingsPopup')}`}
|
|
952
|
+
title={t('settingsPopup')}
|
|
953
|
+
onClick={() => { setStripSettingsOpen(true) }}
|
|
954
|
+
>
|
|
955
|
+
<IconSettingsOutline16 size={14} />
|
|
956
|
+
</button>
|
|
957
|
+
)}
|
|
958
|
+
</span>
|
|
959
|
+
</div>
|
|
960
|
+
</div>
|
|
961
|
+
|
|
962
|
+
{/* 侧边栏内容: one small card per registered tab type in a responsive
|
|
963
|
+
grid; features declaring `settings.toggles` open their settings in
|
|
964
|
+
the popup (gear corner button) instead of nested inline rows. */}
|
|
965
|
+
<div className={css.group}>
|
|
966
|
+
<div className={css.groupHeading}>
|
|
967
|
+
<span>{t('settingsTabsTitle')}</span>
|
|
968
|
+
<span className={css.count}>{tabs.length}</span>
|
|
969
|
+
</div>
|
|
970
|
+
<div className={css.grid}>
|
|
971
|
+
{tabs.map(tab => (
|
|
972
|
+
<Fragment key={tab.id}>
|
|
973
|
+
{renderCard({
|
|
974
|
+
title: textOf(tab.title),
|
|
975
|
+
desc: tab.id,
|
|
976
|
+
icon: iconOf(tab.icon, 16),
|
|
977
|
+
enabled: prefs.tabsEnabled[tab.id] !== false,
|
|
978
|
+
onToggle: (next) => { onToggleTab(tab.id, next) },
|
|
979
|
+
// The settings gear only while the feature is enabled: its
|
|
980
|
+
// related settings are dormant while the feature is off.
|
|
981
|
+
onOpenSettings: prefs.tabsEnabled[tab.id] !== false && hasSettings(tab)
|
|
982
|
+
? () => { setSettingsFor(tab) }
|
|
983
|
+
: undefined,
|
|
984
|
+
})}
|
|
985
|
+
</Fragment>
|
|
986
|
+
))}
|
|
987
|
+
{/* The "add tab plugin" entry: same card size as the inventory,
|
|
988
|
+
but a dashed border — it opens the TAB-registration plugin
|
|
989
|
+
modal instead of toggling a feature. */}
|
|
990
|
+
<button
|
|
991
|
+
type="button"
|
|
992
|
+
className={clsx(css.card, css.addCard)}
|
|
993
|
+
onClick={() => { setAddPluginsOpen('tab') }}
|
|
994
|
+
>
|
|
995
|
+
<span className={css.cardTop}>
|
|
996
|
+
<span className={css.cardIconChip}>
|
|
997
|
+
<IconPlusOutline16 size={16} />
|
|
998
|
+
</span>
|
|
999
|
+
<span className={css.cardTitle}>{t('addPluginsTabCard')}</span>
|
|
1000
|
+
</span>
|
|
1001
|
+
<span className={css.cardDesc}>{t('addPluginsTabCardDesc')}</span>
|
|
1002
|
+
</button>
|
|
1003
|
+
</div>
|
|
1004
|
+
</div>
|
|
1005
|
+
|
|
1006
|
+
{/* 文件预览: one small card per registered file viewer. */}
|
|
1007
|
+
<div className={css.group}>
|
|
1008
|
+
<div className={css.groupHeading}>
|
|
1009
|
+
<span>{t('settingsViewersTitle')}</span>
|
|
1010
|
+
<span className={css.count}>{viewers.length}</span>
|
|
1011
|
+
</div>
|
|
1012
|
+
<div className={css.grid}>
|
|
1013
|
+
{viewers.map(viewer => (
|
|
1014
|
+
<Fragment key={viewer.id}>
|
|
1015
|
+
{renderCard({
|
|
1016
|
+
title: textOf(viewer.title) || viewer.id,
|
|
1017
|
+
desc: viewer.exts.length === 0 ? t('settingsViewerCatchAll') : viewer.exts.join(' · '),
|
|
1018
|
+
icon: iconOf(viewer.icon, 16),
|
|
1019
|
+
enabled: prefs.viewersEnabled[viewer.id] !== false,
|
|
1020
|
+
onToggle: (next) => { onToggleViewer(viewer.id, next) },
|
|
1021
|
+
onOpenSettings: prefs.viewersEnabled[viewer.id] !== false && hasSettings(viewer)
|
|
1022
|
+
? () => { setSettingsFor(viewer) }
|
|
1023
|
+
: undefined,
|
|
1024
|
+
})}
|
|
1025
|
+
</Fragment>
|
|
1026
|
+
))}
|
|
1027
|
+
{/* The "add preview plugin" entry: dashed card opening the
|
|
1028
|
+
FILE-PREVIEWER registration modal. */}
|
|
1029
|
+
<button
|
|
1030
|
+
type="button"
|
|
1031
|
+
className={clsx(css.card, css.addCard)}
|
|
1032
|
+
onClick={() => { setAddPluginsOpen('viewer') }}
|
|
1033
|
+
>
|
|
1034
|
+
<span className={css.cardTop}>
|
|
1035
|
+
<span className={css.cardIconChip}>
|
|
1036
|
+
<IconPlusOutline16 size={16} />
|
|
1037
|
+
</span>
|
|
1038
|
+
<span className={css.cardTitle}>{t('addPluginsViewerCard')}</span>
|
|
1039
|
+
</span>
|
|
1040
|
+
<span className={css.cardDesc}>{t('addPluginsViewerCardDesc')}</span>
|
|
1041
|
+
</button>
|
|
1042
|
+
</div>
|
|
1043
|
+
</div>
|
|
1044
|
+
|
|
1045
|
+
{/* The secondary settings popup: a feature's declared related settings
|
|
1046
|
+
as title/desc + switch rows in a wider-than-default Modal with a
|
|
1047
|
+
Done footer (Modal chrome is the app's own). Mounted only while a
|
|
1048
|
+
feature is open — the Modal primitive runs hooks unconditionally,
|
|
1049
|
+
so a closed-but-mounted Modal would break SSR (and the
|
|
1050
|
+
renderToString spec) under the test dual-react split.
|
|
1051
|
+
Content: the host-prefs `toggles` rows, the plugin-owned
|
|
1052
|
+
`pluginToggles` rows (their values live in pluginSettings[id]),
|
|
1053
|
+
then the custom `settings.render` panel when declared. */}
|
|
1054
|
+
{settingsFor !== null && (
|
|
1055
|
+
<Modal
|
|
1056
|
+
open
|
|
1057
|
+
onClose={() => { setSettingsFor(null) }}
|
|
1058
|
+
title={featureNameOf(settingsFor)}
|
|
1059
|
+
description={t('settingsPopupDesc', { feature: featureNameOf(settingsFor) })}
|
|
1060
|
+
closeLabel={t('close')}
|
|
1061
|
+
className={css.popupDialog}
|
|
1062
|
+
footer={(
|
|
1063
|
+
<button type="button" className={css.done} onClick={() => { setSettingsFor(null) }}>
|
|
1064
|
+
{t('settingsDone')}
|
|
1065
|
+
</button>
|
|
1066
|
+
)}
|
|
1067
|
+
>
|
|
1068
|
+
<SettingsBody
|
|
1069
|
+
feature={settingsFor}
|
|
1070
|
+
prefs={prefs}
|
|
1071
|
+
onToggle={onToggleSetting}
|
|
1072
|
+
onCommit={onCommitSetting}
|
|
1073
|
+
onSelectValue={onSelectSetting}
|
|
1074
|
+
onPluginToggle={(toggle, next) => { onPluginToggle(settingsFor.id, toggle, next) }}
|
|
1075
|
+
onPluginCommit={(toggle, raw) => onPluginCommitSetting(settingsFor.id, toggle, raw)}
|
|
1076
|
+
onPluginSelectValue={(toggle, next) => { applyPluginSetting(settingsFor.id, toggle.key, next) }}
|
|
1077
|
+
onPluginWrite={(key, value) => { applyPluginSetting(settingsFor.id, key, value) }}
|
|
1078
|
+
onClose={() => { setSettingsFor(null) }}
|
|
1079
|
+
store={store}
|
|
1080
|
+
service={service}
|
|
1081
|
+
/>
|
|
1082
|
+
</Modal>
|
|
1083
|
+
)}
|
|
1084
|
+
|
|
1085
|
+
{/* The custom-scheme popup (opened by the gear next to the scheme
|
|
1086
|
+
dropdown when 自定义方案 is active): the shift distance in px and
|
|
1087
|
+
the free-form custom CSS. The OTHER schemes (自动检测 / DSH官方Web /
|
|
1088
|
+
壳预设) need no further settings — the scheme itself is chosen on
|
|
1089
|
+
the 常规 row. Mounted only while open (the Modal SSR rule above). */}
|
|
1090
|
+
{stripSettingsOpen && (
|
|
1091
|
+
<Modal
|
|
1092
|
+
open
|
|
1093
|
+
onClose={() => { setStripSettingsOpen(false) }}
|
|
1094
|
+
title={t('settingsTitleBarTitle')}
|
|
1095
|
+
description={t('settingsPopupDesc', { feature: t('settingsTitleBarTitle') })}
|
|
1096
|
+
closeLabel={t('close')}
|
|
1097
|
+
className={css.popupDialog}
|
|
1098
|
+
footer={(
|
|
1099
|
+
<button type="button" className={css.done} onClick={() => { setStripSettingsOpen(false) }}>
|
|
1100
|
+
{t('settingsDone')}
|
|
1101
|
+
</button>
|
|
1102
|
+
)}
|
|
1103
|
+
>
|
|
1104
|
+
<div className={css.popupRows}>
|
|
1105
|
+
<FeatureSettingsRows
|
|
1106
|
+
toggles={[{
|
|
1107
|
+
key: 'titleBarStripPx',
|
|
1108
|
+
type: 'number',
|
|
1109
|
+
title: () => t('settingsTitleBarStripTitle'),
|
|
1110
|
+
desc: () => t('settingsTitleBarStripDesc'),
|
|
1111
|
+
min: TITLE_BAR_STRIP_MIN,
|
|
1112
|
+
max: TITLE_BAR_STRIP_MAX,
|
|
1113
|
+
unit: 'px',
|
|
1114
|
+
}]}
|
|
1115
|
+
prefs={prefs}
|
|
1116
|
+
onToggle={onToggleSetting}
|
|
1117
|
+
onCommit={onCommitSetting}
|
|
1118
|
+
/>
|
|
1119
|
+
<CssDraft
|
|
1120
|
+
key={prefs.customCss}
|
|
1121
|
+
value={prefs.customCss}
|
|
1122
|
+
label={t('settingsCustomCssTitle')}
|
|
1123
|
+
placeholder={t('settingsCustomCssPlaceholder')}
|
|
1124
|
+
onCommit={commitCustomCss}
|
|
1125
|
+
/>
|
|
1126
|
+
</div>
|
|
1127
|
+
</Modal>
|
|
1128
|
+
)}
|
|
1129
|
+
|
|
1130
|
+
{/* The "add plugin" modal (opened by the dashed cards above): declares
|
|
1131
|
+
the extension point of the clicked kind, opens the GitHub topic,
|
|
1132
|
+
and lists the matching recommended plugin catalog with per-entry
|
|
1133
|
+
install buttons (the install flow opens a ~/.dsh terminal with
|
|
1134
|
+
the command pre-typed; failures render inline here, in settings
|
|
1135
|
+
only). Mounted only while open (Modal runs hooks unconditionally
|
|
1136
|
+
— same SSR rule as the settings popup above). */}
|
|
1137
|
+
{addPluginsOpen !== null && (
|
|
1138
|
+
<AddPluginModal
|
|
1139
|
+
service={service}
|
|
1140
|
+
onClose={() => { setAddPluginsOpen(null) }}
|
|
1141
|
+
kind={addPluginsOpen}
|
|
1142
|
+
/>
|
|
1143
|
+
)}
|
|
1144
|
+
|
|
1145
|
+
{error !== null && (
|
|
1146
|
+
<div className={css.error} role="alert">
|
|
1147
|
+
{error}
|
|
1148
|
+
</div>
|
|
1149
|
+
)}
|
|
1150
|
+
</div>
|
|
1151
|
+
)
|
|
1152
|
+
}
|