@wong2kim/wmux 1.0.0
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/README.md +157 -0
- package/assets/icon.ico +0 -0
- package/assets/icon.svg +6 -0
- package/dist/cli/cli/client.js +102 -0
- package/dist/cli/cli/commands/browser.js +137 -0
- package/dist/cli/cli/commands/input.js +80 -0
- package/dist/cli/cli/commands/notify.js +28 -0
- package/dist/cli/cli/commands/pane.js +88 -0
- package/dist/cli/cli/commands/surface.js +98 -0
- package/dist/cli/cli/commands/system.js +98 -0
- package/dist/cli/cli/commands/workspace.js +117 -0
- package/dist/cli/cli/index.js +140 -0
- package/dist/cli/cli/utils.js +47 -0
- package/dist/cli/shared/constants.js +54 -0
- package/dist/cli/shared/rpc.js +33 -0
- package/dist/cli/shared/types.js +79 -0
- package/dist/mcp/mcp/index.js +60 -0
- package/dist/mcp/mcp/wmux-client.js +146 -0
- package/dist/mcp/shared/constants.js +54 -0
- package/dist/mcp/shared/rpc.js +33 -0
- package/dist/mcp/shared/types.js +79 -0
- package/forge.config.ts +61 -0
- package/index.html +12 -0
- package/package.json +84 -0
- package/postcss.config.js +6 -0
- package/src/cli/client.ts +76 -0
- package/src/cli/commands/browser.ts +128 -0
- package/src/cli/commands/input.ts +72 -0
- package/src/cli/commands/notify.ts +29 -0
- package/src/cli/commands/pane.ts +90 -0
- package/src/cli/commands/surface.ts +102 -0
- package/src/cli/commands/system.ts +95 -0
- package/src/cli/commands/workspace.ts +116 -0
- package/src/cli/index.ts +145 -0
- package/src/cli/utils.ts +44 -0
- package/src/main/index.ts +86 -0
- package/src/main/ipc/handlers/clipboard.handler.ts +20 -0
- package/src/main/ipc/handlers/metadata.handler.ts +56 -0
- package/src/main/ipc/handlers/pty.handler.ts +69 -0
- package/src/main/ipc/handlers/session.handler.ts +17 -0
- package/src/main/ipc/handlers/shell.handler.ts +11 -0
- package/src/main/ipc/registerHandlers.ts +31 -0
- package/src/main/mcp/McpRegistrar.ts +156 -0
- package/src/main/metadata/MetadataCollector.ts +58 -0
- package/src/main/notification/ToastManager.ts +32 -0
- package/src/main/pipe/PipeServer.ts +190 -0
- package/src/main/pipe/RpcRouter.ts +46 -0
- package/src/main/pipe/handlers/_bridge.ts +40 -0
- package/src/main/pipe/handlers/browser.rpc.ts +132 -0
- package/src/main/pipe/handlers/input.rpc.ts +120 -0
- package/src/main/pipe/handlers/meta.rpc.ts +59 -0
- package/src/main/pipe/handlers/notify.rpc.ts +53 -0
- package/src/main/pipe/handlers/pane.rpc.ts +39 -0
- package/src/main/pipe/handlers/surface.rpc.ts +43 -0
- package/src/main/pipe/handlers/system.rpc.ts +36 -0
- package/src/main/pipe/handlers/workspace.rpc.ts +52 -0
- package/src/main/pty/AgentDetector.ts +247 -0
- package/src/main/pty/OscParser.ts +81 -0
- package/src/main/pty/PTYBridge.ts +88 -0
- package/src/main/pty/PTYManager.ts +104 -0
- package/src/main/pty/ShellDetector.ts +63 -0
- package/src/main/session/SessionManager.ts +53 -0
- package/src/main/updater/AutoUpdater.ts +132 -0
- package/src/main/window/createWindow.ts +71 -0
- package/src/mcp/README.md +56 -0
- package/src/mcp/index.ts +153 -0
- package/src/mcp/wmux-client.ts +127 -0
- package/src/preload/index.ts +111 -0
- package/src/preload/preload.ts +108 -0
- package/src/renderer/App.tsx +5 -0
- package/src/renderer/components/Browser/BrowserPanel.tsx +219 -0
- package/src/renderer/components/Browser/BrowserToolbar.tsx +253 -0
- package/src/renderer/components/Company/ApprovalDialog.tsx +3 -0
- package/src/renderer/components/Company/CompanyView.tsx +7 -0
- package/src/renderer/components/Company/MessageFeedPanel.tsx +3 -0
- package/src/renderer/components/Layout/AppLayout.tsx +234 -0
- package/src/renderer/components/Notification/NotificationPanel.tsx +129 -0
- package/src/renderer/components/Palette/CommandPalette.tsx +409 -0
- package/src/renderer/components/Palette/PaletteItem.tsx +55 -0
- package/src/renderer/components/Pane/Pane.tsx +122 -0
- package/src/renderer/components/Pane/PaneContainer.tsx +41 -0
- package/src/renderer/components/Pane/SurfaceTabs.tsx +46 -0
- package/src/renderer/components/Settings/SettingsPanel.tsx +886 -0
- package/src/renderer/components/Sidebar/MiniSidebar.tsx +67 -0
- package/src/renderer/components/Sidebar/Sidebar.tsx +84 -0
- package/src/renderer/components/Sidebar/WorkspaceItem.tsx +241 -0
- package/src/renderer/components/StatusBar/StatusBar.tsx +93 -0
- package/src/renderer/components/Terminal/SearchBar.tsx +126 -0
- package/src/renderer/components/Terminal/Terminal.tsx +102 -0
- package/src/renderer/components/Terminal/ViCopyMode.tsx +104 -0
- package/src/renderer/hooks/useKeyboard.ts +310 -0
- package/src/renderer/hooks/useNotificationListener.ts +80 -0
- package/src/renderer/hooks/useNotificationSound.ts +75 -0
- package/src/renderer/hooks/useRpcBridge.ts +451 -0
- package/src/renderer/hooks/useT.ts +11 -0
- package/src/renderer/hooks/useTerminal.ts +349 -0
- package/src/renderer/hooks/useViCopyMode.ts +320 -0
- package/src/renderer/i18n/index.ts +69 -0
- package/src/renderer/i18n/locales/en.ts +157 -0
- package/src/renderer/i18n/locales/ja.ts +155 -0
- package/src/renderer/i18n/locales/ko.ts +155 -0
- package/src/renderer/i18n/locales/zh.ts +155 -0
- package/src/renderer/index.tsx +6 -0
- package/src/renderer/stores/index.ts +19 -0
- package/src/renderer/stores/slices/notificationSlice.ts +56 -0
- package/src/renderer/stores/slices/paneSlice.ts +141 -0
- package/src/renderer/stores/slices/surfaceSlice.ts +122 -0
- package/src/renderer/stores/slices/uiSlice.ts +247 -0
- package/src/renderer/stores/slices/workspaceSlice.ts +120 -0
- package/src/renderer/styles/globals.css +150 -0
- package/src/renderer/themes.ts +99 -0
- package/src/shared/constants.ts +53 -0
- package/src/shared/electron.d.ts +11 -0
- package/src/shared/rpc.ts +71 -0
- package/src/shared/types.ts +176 -0
- package/tailwind.config.js +11 -0
- package/tsconfig.cli.json +24 -0
- package/tsconfig.json +21 -0
- package/tsconfig.mcp.json +25 -0
- package/vite.main.config.ts +14 -0
- package/vite.preload.config.ts +9 -0
- package/vite.renderer.config.ts +6 -0
|
@@ -0,0 +1,886 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, useCallback } from 'react';
|
|
2
|
+
import { useStore } from '../../stores';
|
|
3
|
+
import { LOCALE_OPTIONS, type Locale } from '../../i18n';
|
|
4
|
+
import { useT } from '../../hooks/useT';
|
|
5
|
+
import { THEME_OPTIONS } from '../../themes';
|
|
6
|
+
|
|
7
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
type UpdateStatus = 'idle' | 'checking' | 'up-to-date' | 'available' | 'error';
|
|
10
|
+
type TabId = 'general' | 'appearance' | 'notifications' | 'shortcuts' | 'about';
|
|
11
|
+
|
|
12
|
+
// ─── Icon components ──────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
function IconX() {
|
|
15
|
+
return (
|
|
16
|
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
17
|
+
<line x1="2" y1="2" x2="12" y2="12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
|
18
|
+
<line x1="12" y1="2" x2="2" y2="12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
|
19
|
+
</svg>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function IconRefresh() {
|
|
24
|
+
return (
|
|
25
|
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
26
|
+
<path d="M1.5 7a5.5 5.5 0 1 0 1.1-3.3" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" />
|
|
27
|
+
<polyline points="1.5,2 1.5,4.5 4,4.5" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" />
|
|
28
|
+
</svg>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ─── Toggle switch ─────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
interface ToggleProps {
|
|
35
|
+
checked: boolean;
|
|
36
|
+
onChange: (checked: boolean) => void;
|
|
37
|
+
label: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function Toggle({ checked, onChange, label }: ToggleProps) {
|
|
41
|
+
return (
|
|
42
|
+
<button
|
|
43
|
+
role="switch"
|
|
44
|
+
aria-checked={checked}
|
|
45
|
+
aria-label={label}
|
|
46
|
+
onClick={() => onChange(!checked)}
|
|
47
|
+
className="relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none shrink-0"
|
|
48
|
+
style={{ backgroundColor: checked ? 'var(--accent-blue)' : 'var(--bg-overlay)' }}
|
|
49
|
+
>
|
|
50
|
+
<span
|
|
51
|
+
className="inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform"
|
|
52
|
+
style={{ transform: checked ? 'translateX(18px)' : 'translateX(2px)' }}
|
|
53
|
+
/>
|
|
54
|
+
</button>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ─── Row layout helper ────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
function SettingRow({
|
|
61
|
+
label,
|
|
62
|
+
description,
|
|
63
|
+
children,
|
|
64
|
+
}: {
|
|
65
|
+
label: string;
|
|
66
|
+
description?: string;
|
|
67
|
+
children: React.ReactNode;
|
|
68
|
+
}) {
|
|
69
|
+
return (
|
|
70
|
+
<div
|
|
71
|
+
className="flex items-center justify-between px-3 py-2.5 rounded-lg"
|
|
72
|
+
style={{ backgroundColor: 'var(--bg-mantle)', border: '1px solid var(--bg-surface)' }}
|
|
73
|
+
>
|
|
74
|
+
<div className="min-w-0 mr-3">
|
|
75
|
+
<p className="text-sm text-[color:var(--text-main)]">{label}</p>
|
|
76
|
+
{description && <p className="text-[11px] text-[color:var(--text-muted)] mt-0.5">{description}</p>}
|
|
77
|
+
</div>
|
|
78
|
+
{children}
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ─── Select dropdown ──────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
function SettingSelect({
|
|
86
|
+
value,
|
|
87
|
+
onChange,
|
|
88
|
+
options,
|
|
89
|
+
label,
|
|
90
|
+
}: {
|
|
91
|
+
value: string;
|
|
92
|
+
onChange: (v: string) => void;
|
|
93
|
+
options: { value: string; label: string }[];
|
|
94
|
+
label: string;
|
|
95
|
+
}) {
|
|
96
|
+
return (
|
|
97
|
+
<select
|
|
98
|
+
aria-label={label}
|
|
99
|
+
value={value}
|
|
100
|
+
onChange={(e) => onChange(e.target.value)}
|
|
101
|
+
className="text-xs rounded-md px-2 py-1 focus:outline-none focus:ring-1 focus:ring-[color:var(--accent-blue)] font-mono"
|
|
102
|
+
style={{
|
|
103
|
+
backgroundColor: 'var(--bg-surface)',
|
|
104
|
+
color: 'var(--text-main)',
|
|
105
|
+
border: '1px solid var(--bg-overlay)',
|
|
106
|
+
minWidth: 130,
|
|
107
|
+
}}
|
|
108
|
+
>
|
|
109
|
+
{options.map((o) => (
|
|
110
|
+
<option key={o.value} value={o.value}>
|
|
111
|
+
{o.label}
|
|
112
|
+
</option>
|
|
113
|
+
))}
|
|
114
|
+
</select>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ─── Number input ─────────────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
function SettingNumberInput({
|
|
121
|
+
value,
|
|
122
|
+
onChange,
|
|
123
|
+
min,
|
|
124
|
+
max,
|
|
125
|
+
label,
|
|
126
|
+
}: {
|
|
127
|
+
value: number;
|
|
128
|
+
onChange: (v: number) => void;
|
|
129
|
+
min: number;
|
|
130
|
+
max: number;
|
|
131
|
+
label: string;
|
|
132
|
+
}) {
|
|
133
|
+
return (
|
|
134
|
+
<input
|
|
135
|
+
type="number"
|
|
136
|
+
aria-label={label}
|
|
137
|
+
value={value}
|
|
138
|
+
min={min}
|
|
139
|
+
max={max}
|
|
140
|
+
onChange={(e) => {
|
|
141
|
+
const n = parseInt(e.target.value, 10);
|
|
142
|
+
if (!isNaN(n) && n >= min && n <= max) onChange(n);
|
|
143
|
+
}}
|
|
144
|
+
className="text-xs rounded-md px-2 py-1 focus:outline-none focus:ring-1 focus:ring-[color:var(--accent-blue)] font-mono text-center"
|
|
145
|
+
style={{
|
|
146
|
+
backgroundColor: 'var(--bg-surface)',
|
|
147
|
+
color: 'var(--text-main)',
|
|
148
|
+
border: '1px solid var(--bg-overlay)',
|
|
149
|
+
width: 64,
|
|
150
|
+
}}
|
|
151
|
+
/>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ─── Section divider label ────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
function SectionLabel({ label }: { label: string }) {
|
|
158
|
+
return (
|
|
159
|
+
<p className="text-[10px] font-semibold uppercase tracking-widest text-[color:var(--text-muted)] mb-2 mt-1 px-1">
|
|
160
|
+
{label}
|
|
161
|
+
</p>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ─── Keyboard shortcut badge ──────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
function KbdRow({ keys, description }: { keys: string; description: string }) {
|
|
168
|
+
return (
|
|
169
|
+
<div className="flex items-center justify-between py-1.5 px-3 rounded-lg hover:bg-[color:var(--bg-mantle)] transition-colors">
|
|
170
|
+
<span className="text-[12px] text-[color:var(--text-sub)]">{description}</span>
|
|
171
|
+
<span
|
|
172
|
+
className="text-[10px] font-mono px-2 py-0.5 rounded"
|
|
173
|
+
style={{ backgroundColor: 'var(--bg-surface)', color: 'var(--accent-blue)', border: '1px solid var(--bg-overlay)' }}
|
|
174
|
+
>
|
|
175
|
+
{keys}
|
|
176
|
+
</span>
|
|
177
|
+
</div>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ─── Static config (product names — no translation needed) ───────────────────
|
|
182
|
+
|
|
183
|
+
const SHELL_OPTIONS = [
|
|
184
|
+
{ value: 'powershell', label: 'PowerShell' },
|
|
185
|
+
{ value: 'cmd', label: 'Command Prompt' },
|
|
186
|
+
{ value: 'gitbash', label: 'Git Bash' },
|
|
187
|
+
{ value: 'wsl', label: 'WSL' },
|
|
188
|
+
];
|
|
189
|
+
|
|
190
|
+
const FONT_FAMILY_OPTIONS = [
|
|
191
|
+
{ value: 'Cascadia Code', label: 'Cascadia Code' },
|
|
192
|
+
{ value: 'Consolas', label: 'Consolas' },
|
|
193
|
+
{ value: 'Fira Code', label: 'Fira Code' },
|
|
194
|
+
{ value: 'JetBrains Mono', label: 'JetBrains Mono' },
|
|
195
|
+
];
|
|
196
|
+
|
|
197
|
+
// ─── Tab content components ───────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
function TabGeneral({
|
|
200
|
+
updateStatus,
|
|
201
|
+
updateMessage,
|
|
202
|
+
onCheckUpdate,
|
|
203
|
+
onInstallUpdate,
|
|
204
|
+
}: {
|
|
205
|
+
updateStatus: UpdateStatus;
|
|
206
|
+
updateMessage: string;
|
|
207
|
+
onCheckUpdate: () => void;
|
|
208
|
+
onInstallUpdate: () => void;
|
|
209
|
+
}) {
|
|
210
|
+
const t = useT();
|
|
211
|
+
const locale = useStore((s) => s.locale);
|
|
212
|
+
const setLocale = useStore((s) => s.setLocale);
|
|
213
|
+
|
|
214
|
+
const defaultShell = useStore((s) => s.defaultShell);
|
|
215
|
+
const setDefaultShell = useStore((s) => s.setDefaultShell);
|
|
216
|
+
const scrollbackLines = useStore((s) => s.scrollbackLines);
|
|
217
|
+
const setScrollbackLines = useStore((s) => s.setScrollbackLines);
|
|
218
|
+
|
|
219
|
+
const updateButtonLabel = () => {
|
|
220
|
+
switch (updateStatus) {
|
|
221
|
+
case 'checking': return t('settings.checking');
|
|
222
|
+
case 'up-to-date': return t('settings.upToDate');
|
|
223
|
+
case 'available': return t('settings.installUpdate');
|
|
224
|
+
case 'error': return t('settings.retryCheck');
|
|
225
|
+
default: return t('settings.checkUpdate');
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
return (
|
|
230
|
+
<div className="flex flex-col gap-4">
|
|
231
|
+
{/* Language */}
|
|
232
|
+
<div>
|
|
233
|
+
<SectionLabel label={t('settings.language')} />
|
|
234
|
+
<div className="grid grid-cols-2 gap-2">
|
|
235
|
+
{LOCALE_OPTIONS.map(({ value, label }) => (
|
|
236
|
+
<button
|
|
237
|
+
key={value}
|
|
238
|
+
onClick={() => setLocale(value as Locale)}
|
|
239
|
+
className="px-3 py-2 rounded-lg text-sm transition-colors text-left"
|
|
240
|
+
style={{
|
|
241
|
+
backgroundColor: locale === value ? 'var(--bg-surface)' : 'transparent',
|
|
242
|
+
color: locale === value ? 'var(--text-main)' : 'var(--text-subtle)',
|
|
243
|
+
border: `1px solid ${locale === value ? 'var(--accent-blue)' : 'var(--bg-surface)'}`,
|
|
244
|
+
}}
|
|
245
|
+
>
|
|
246
|
+
<span className="mr-2">{localeFlag(value as Locale)}</span>
|
|
247
|
+
{label}
|
|
248
|
+
</button>
|
|
249
|
+
))}
|
|
250
|
+
</div>
|
|
251
|
+
</div>
|
|
252
|
+
|
|
253
|
+
{/* Shell & scrollback */}
|
|
254
|
+
<div className="flex flex-col gap-2">
|
|
255
|
+
<SectionLabel label={t('settings.terminal')} />
|
|
256
|
+
<SettingRow label={t('settings.defaultShell')}>
|
|
257
|
+
<SettingSelect
|
|
258
|
+
label={t('settings.defaultShell')}
|
|
259
|
+
value={defaultShell}
|
|
260
|
+
onChange={setDefaultShell}
|
|
261
|
+
options={SHELL_OPTIONS}
|
|
262
|
+
/>
|
|
263
|
+
</SettingRow>
|
|
264
|
+
<SettingRow label={t('settings.scrollbackLines')} description={t('settings.scrollbackDesc')}>
|
|
265
|
+
<SettingNumberInput
|
|
266
|
+
label={t('settings.scrollbackLines')}
|
|
267
|
+
value={scrollbackLines}
|
|
268
|
+
onChange={setScrollbackLines}
|
|
269
|
+
min={1000}
|
|
270
|
+
max={100000}
|
|
271
|
+
/>
|
|
272
|
+
</SettingRow>
|
|
273
|
+
</div>
|
|
274
|
+
|
|
275
|
+
{/* Updates */}
|
|
276
|
+
<div>
|
|
277
|
+
<SectionLabel label={t('settings.updates')} />
|
|
278
|
+
<div
|
|
279
|
+
className="px-3 py-2.5 rounded-lg flex items-center justify-between"
|
|
280
|
+
style={{ backgroundColor: 'var(--bg-mantle)', border: '1px solid var(--bg-surface)' }}
|
|
281
|
+
>
|
|
282
|
+
<div>
|
|
283
|
+
<p className="text-sm text-[color:var(--text-main)]">{t('settings.wmuxUpdates')}</p>
|
|
284
|
+
{updateStatus === 'up-to-date' && (
|
|
285
|
+
<p className="text-[11px] text-[color:var(--accent-green)] mt-0.5">{t('settings.upToDate')}</p>
|
|
286
|
+
)}
|
|
287
|
+
{updateStatus === 'available' && (
|
|
288
|
+
<p className="text-[11px] text-[color:var(--accent-blue)] mt-0.5">{updateMessage || t('settings.updateAvailable')}</p>
|
|
289
|
+
)}
|
|
290
|
+
{updateStatus === 'error' && (
|
|
291
|
+
<p className="text-[11px] text-[color:var(--accent-red)] mt-0.5">{updateMessage || t('settings.updateFailed')}</p>
|
|
292
|
+
)}
|
|
293
|
+
{updateStatus === 'idle' && (
|
|
294
|
+
<p className="text-[11px] text-[color:var(--text-muted)] mt-0.5">{t('settings.lastCheckedNever')}</p>
|
|
295
|
+
)}
|
|
296
|
+
</div>
|
|
297
|
+
<button
|
|
298
|
+
onClick={updateStatus === 'available' ? onInstallUpdate : onCheckUpdate}
|
|
299
|
+
disabled={updateStatus === 'checking'}
|
|
300
|
+
className="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed shrink-0 ml-3"
|
|
301
|
+
style={{
|
|
302
|
+
backgroundColor: updateStatus === 'available' ? 'var(--accent-green)' : 'var(--bg-surface)',
|
|
303
|
+
color: updateStatus === 'available' ? 'var(--bg-base)' : 'var(--text-main)',
|
|
304
|
+
}}
|
|
305
|
+
>
|
|
306
|
+
{updateStatus === 'checking'
|
|
307
|
+
? (
|
|
308
|
+
<span className="flex items-center gap-1.5">
|
|
309
|
+
<span className="animate-spin inline-block w-3 h-3 border border-current border-t-transparent rounded-full" />
|
|
310
|
+
{t('settings.checking')}
|
|
311
|
+
</span>
|
|
312
|
+
)
|
|
313
|
+
: updateButtonLabel()
|
|
314
|
+
}
|
|
315
|
+
</button>
|
|
316
|
+
</div>
|
|
317
|
+
</div>
|
|
318
|
+
</div>
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function TabAppearance() {
|
|
323
|
+
const t = useT();
|
|
324
|
+
const terminalFontSize = useStore((s) => s.terminalFontSize);
|
|
325
|
+
const setTerminalFontSize = useStore((s) => s.setTerminalFontSize);
|
|
326
|
+
const terminalFontFamily = useStore((s) => s.terminalFontFamily);
|
|
327
|
+
const setTerminalFontFamily = useStore((s) => s.setTerminalFontFamily);
|
|
328
|
+
|
|
329
|
+
const sidebarPosition = useStore((s) => s.sidebarPosition);
|
|
330
|
+
const setSidebarPosition = useStore((s) => s.setSidebarPosition);
|
|
331
|
+
|
|
332
|
+
const currentTheme = useStore((s) => s.theme);
|
|
333
|
+
const setTheme = useStore((s) => s.setTheme);
|
|
334
|
+
|
|
335
|
+
return (
|
|
336
|
+
<div className="flex flex-col gap-4">
|
|
337
|
+
{/* Theme */}
|
|
338
|
+
<div className="flex flex-col gap-2">
|
|
339
|
+
<SectionLabel label="Theme" />
|
|
340
|
+
<div className="grid grid-cols-3 gap-2">
|
|
341
|
+
{THEME_OPTIONS.map(({ value, label }) => (
|
|
342
|
+
<button
|
|
343
|
+
key={value}
|
|
344
|
+
onClick={() => setTheme(value)}
|
|
345
|
+
className="px-3 py-3 rounded-lg text-xs transition-colors text-center"
|
|
346
|
+
style={{
|
|
347
|
+
backgroundColor: currentTheme === value ? 'var(--bg-surface)' : 'transparent',
|
|
348
|
+
color: currentTheme === value ? 'var(--text-main)' : 'var(--text-subtle)',
|
|
349
|
+
border: `1px solid ${currentTheme === value ? 'var(--accent-blue)' : 'var(--bg-surface)'}`,
|
|
350
|
+
}}
|
|
351
|
+
>
|
|
352
|
+
{label}
|
|
353
|
+
</button>
|
|
354
|
+
))}
|
|
355
|
+
</div>
|
|
356
|
+
</div>
|
|
357
|
+
|
|
358
|
+
<div className="flex flex-col gap-2">
|
|
359
|
+
<SectionLabel label={t('settings.terminal')} />
|
|
360
|
+
<SettingRow label={t('settings.fontSize')} description={`${terminalFontSize}px — ${t('settings.fontSizeRange')}`}>
|
|
361
|
+
<div className="flex items-center gap-2">
|
|
362
|
+
<input
|
|
363
|
+
type="range"
|
|
364
|
+
min={12}
|
|
365
|
+
max={24}
|
|
366
|
+
value={terminalFontSize}
|
|
367
|
+
onChange={(e) => setTerminalFontSize(Number(e.target.value))}
|
|
368
|
+
aria-label={t('settings.fontSize')}
|
|
369
|
+
className="w-24 accent-[color:var(--accent-blue)]"
|
|
370
|
+
/>
|
|
371
|
+
<span className="text-xs font-mono text-[color:var(--text-sub)] w-6 text-right">{terminalFontSize}</span>
|
|
372
|
+
</div>
|
|
373
|
+
</SettingRow>
|
|
374
|
+
<SettingRow label={t('settings.fontFamily')} description={t('settings.fontFamilyDesc')}>
|
|
375
|
+
<SettingSelect
|
|
376
|
+
label={t('settings.fontFamily')}
|
|
377
|
+
value={terminalFontFamily}
|
|
378
|
+
onChange={setTerminalFontFamily}
|
|
379
|
+
options={FONT_FAMILY_OPTIONS}
|
|
380
|
+
/>
|
|
381
|
+
</SettingRow>
|
|
382
|
+
</div>
|
|
383
|
+
|
|
384
|
+
<div className="flex flex-col gap-2">
|
|
385
|
+
<SectionLabel label={t('settings.layout')} />
|
|
386
|
+
<SettingRow label={t('settings.sidebarPosition')} description={t('settings.sidebarPositionDesc')}>
|
|
387
|
+
<div className="flex rounded-lg overflow-hidden" style={{ border: '1px solid var(--bg-overlay)' }}>
|
|
388
|
+
{(['left', 'right'] as const).map((pos) => (
|
|
389
|
+
<button
|
|
390
|
+
key={pos}
|
|
391
|
+
onClick={() => setSidebarPosition(pos)}
|
|
392
|
+
className="px-3 py-1 text-xs font-mono transition-colors"
|
|
393
|
+
style={{
|
|
394
|
+
backgroundColor: sidebarPosition === pos ? 'var(--accent-blue)' : 'var(--bg-surface)',
|
|
395
|
+
color: sidebarPosition === pos ? 'var(--bg-base)' : 'var(--text-subtle)',
|
|
396
|
+
}}
|
|
397
|
+
>
|
|
398
|
+
{pos === 'left' ? t('settings.sidebarLeft') : t('settings.sidebarRight')}
|
|
399
|
+
</button>
|
|
400
|
+
))}
|
|
401
|
+
</div>
|
|
402
|
+
</SettingRow>
|
|
403
|
+
</div>
|
|
404
|
+
</div>
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function TabNotifications() {
|
|
409
|
+
const t = useT();
|
|
410
|
+
const notificationSoundEnabled = useStore((s) => s.notificationSoundEnabled);
|
|
411
|
+
const toggleNotificationSound = useStore((s) => s.toggleNotificationSound);
|
|
412
|
+
const toastEnabled = useStore((s) => s.toastEnabled);
|
|
413
|
+
const setToastEnabled = useStore((s) => s.setToastEnabled);
|
|
414
|
+
const notificationRingEnabled = useStore((s) => s.notificationRingEnabled);
|
|
415
|
+
const setNotificationRingEnabled = useStore((s) => s.setNotificationRingEnabled);
|
|
416
|
+
|
|
417
|
+
return (
|
|
418
|
+
<div className="flex flex-col gap-2">
|
|
419
|
+
<SectionLabel label={t('settings.notificationBehavior')} />
|
|
420
|
+
<SettingRow label={t('settings.sound')} description={t('settings.soundDesc')}>
|
|
421
|
+
<Toggle
|
|
422
|
+
checked={notificationSoundEnabled}
|
|
423
|
+
onChange={() => toggleNotificationSound()}
|
|
424
|
+
label={t('settings.sound')}
|
|
425
|
+
/>
|
|
426
|
+
</SettingRow>
|
|
427
|
+
<SettingRow label={t('settings.toast')} description={t('settings.toastDesc')}>
|
|
428
|
+
<Toggle
|
|
429
|
+
checked={toastEnabled}
|
|
430
|
+
onChange={setToastEnabled}
|
|
431
|
+
label={t('settings.toast')}
|
|
432
|
+
/>
|
|
433
|
+
</SettingRow>
|
|
434
|
+
<SettingRow label={t('settings.ring')} description={t('settings.ringDesc')}>
|
|
435
|
+
<Toggle
|
|
436
|
+
checked={notificationRingEnabled}
|
|
437
|
+
onChange={setNotificationRingEnabled}
|
|
438
|
+
label={t('settings.ring')}
|
|
439
|
+
/>
|
|
440
|
+
</SettingRow>
|
|
441
|
+
</div>
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// ─── Key capture overlay ──────────────────────────────────────────────────────
|
|
446
|
+
|
|
447
|
+
function KeyCaptureOverlay({ label, onCapture, onCancel }: { label: string; onCapture: (key: string) => void; onCancel: () => void }) {
|
|
448
|
+
useEffect(() => {
|
|
449
|
+
const handler = (e: KeyboardEvent) => {
|
|
450
|
+
e.preventDefault();
|
|
451
|
+
e.stopPropagation();
|
|
452
|
+
if (e.key === 'Escape') { onCancel(); return; }
|
|
453
|
+
|
|
454
|
+
const parts: string[] = [];
|
|
455
|
+
if (e.ctrlKey) parts.push('Ctrl');
|
|
456
|
+
if (e.shiftKey) parts.push('Shift');
|
|
457
|
+
if (e.altKey) parts.push('Alt');
|
|
458
|
+
let k = e.key;
|
|
459
|
+
if (k.length === 1) k = k.toUpperCase();
|
|
460
|
+
if (!['Control', 'Shift', 'Alt', 'Meta'].includes(k)) {
|
|
461
|
+
parts.push(k);
|
|
462
|
+
onCapture(parts.join('+'));
|
|
463
|
+
}
|
|
464
|
+
};
|
|
465
|
+
window.addEventListener('keydown', handler, true);
|
|
466
|
+
return () => window.removeEventListener('keydown', handler, true);
|
|
467
|
+
}, [onCapture, onCancel]);
|
|
468
|
+
|
|
469
|
+
return (
|
|
470
|
+
<div
|
|
471
|
+
className="fixed inset-0 z-[60] flex items-center justify-center"
|
|
472
|
+
style={{ backgroundColor: 'rgba(0,0,0,0.7)' }}
|
|
473
|
+
onClick={onCancel}
|
|
474
|
+
>
|
|
475
|
+
<div
|
|
476
|
+
className="px-8 py-6 rounded-xl text-center"
|
|
477
|
+
style={{ backgroundColor: 'var(--bg-base)', border: '2px solid var(--accent-blue)', boxShadow: '0 0 30px rgba(137,180,250,0.3)' }}
|
|
478
|
+
onClick={(e) => e.stopPropagation()}
|
|
479
|
+
>
|
|
480
|
+
<p className="text-lg text-[color:var(--text-main)] font-mono mb-2">{label}</p>
|
|
481
|
+
<p className="text-xs text-[color:var(--text-muted)]">ESC to cancel</p>
|
|
482
|
+
</div>
|
|
483
|
+
</div>
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// ─── Shortcuts tab ────────────────────────────────────────────────────────────
|
|
488
|
+
|
|
489
|
+
function TabShortcuts() {
|
|
490
|
+
const t = useT();
|
|
491
|
+
|
|
492
|
+
const customKeybindings = useStore((s) => s.customKeybindings);
|
|
493
|
+
const addKeybinding = useStore((s) => s.addKeybinding);
|
|
494
|
+
const updateKeybinding = useStore((s) => s.updateKeybinding);
|
|
495
|
+
const removeKeybinding = useStore((s) => s.removeKeybinding);
|
|
496
|
+
const [capturingFor, setCapturingFor] = useState<string | null>(null);
|
|
497
|
+
|
|
498
|
+
const BUILTIN_KEYS = new Set([
|
|
499
|
+
'Ctrl+B', 'Ctrl+N', 'Ctrl+D', 'Ctrl+T', 'Ctrl+W', 'Ctrl+F',
|
|
500
|
+
'Ctrl+K', 'Ctrl+I', 'Ctrl+,',
|
|
501
|
+
'Ctrl+Shift+W', 'Ctrl+Shift+D', 'Ctrl+Shift+L', 'Ctrl+Shift+X',
|
|
502
|
+
'Ctrl+Shift+H', 'Ctrl+Shift+R', 'Ctrl+Shift+U', 'Ctrl+Shift+O',
|
|
503
|
+
'Ctrl+Shift+]', 'Ctrl+Shift+[', 'Ctrl+Shift+M',
|
|
504
|
+
]);
|
|
505
|
+
|
|
506
|
+
const shortcuts = [
|
|
507
|
+
{ keys: 'Ctrl+B', description: t('settings.sc.toggleSidebar') },
|
|
508
|
+
{ keys: 'Ctrl+D', description: t('settings.sc.splitHorizontal') },
|
|
509
|
+
{ keys: 'Ctrl+Shift+D', description: t('settings.sc.splitVertical') },
|
|
510
|
+
{ keys: 'Ctrl+T', description: t('settings.sc.newWorkspace') },
|
|
511
|
+
{ keys: 'Ctrl+W', description: t('settings.sc.closePane') },
|
|
512
|
+
{ keys: 'Ctrl+F', description: t('settings.sc.searchTerminal') },
|
|
513
|
+
{ keys: 'Ctrl+K', description: t('settings.sc.commandPalette') },
|
|
514
|
+
{ keys: 'Ctrl+I', description: t('settings.sc.toggleNotifications') },
|
|
515
|
+
{ keys: 'Ctrl+Shift+L', description: t('settings.sc.viCopyMode') },
|
|
516
|
+
{ keys: 'Ctrl+Shift+X', description: t('settings.sc.renameWorkspace') },
|
|
517
|
+
{ keys: 'Ctrl+Shift+H', description: t('settings.sc.highlightPane') },
|
|
518
|
+
];
|
|
519
|
+
|
|
520
|
+
return (
|
|
521
|
+
<div className="flex flex-col gap-1">
|
|
522
|
+
<SectionLabel label={t('settings.shortcuts')} />
|
|
523
|
+
<div
|
|
524
|
+
className="rounded-lg overflow-hidden py-1"
|
|
525
|
+
style={{ backgroundColor: 'var(--bg-mantle)', border: '1px solid var(--bg-surface)' }}
|
|
526
|
+
>
|
|
527
|
+
{shortcuts.map((s) => (
|
|
528
|
+
<KbdRow key={s.keys} keys={s.keys} description={s.description} />
|
|
529
|
+
))}
|
|
530
|
+
</div>
|
|
531
|
+
<p className="text-[10px] text-[color:var(--text-muted)] mt-2 px-1">
|
|
532
|
+
{t('settings.shortcutsNotAvailable')}
|
|
533
|
+
</p>
|
|
534
|
+
|
|
535
|
+
{/* Custom keybindings */}
|
|
536
|
+
<SectionLabel label={t('settings.customKeybindings')} />
|
|
537
|
+
|
|
538
|
+
{customKeybindings.length === 0 ? (
|
|
539
|
+
<p className="text-[11px] text-[color:var(--text-muted)] px-1">{t('settings.kb.noBindings')}</p>
|
|
540
|
+
) : (
|
|
541
|
+
<div className="flex flex-col gap-1.5">
|
|
542
|
+
{customKeybindings.map((kb) => (
|
|
543
|
+
<div
|
|
544
|
+
key={kb.id}
|
|
545
|
+
className="flex items-center gap-2 px-3 py-2 rounded-lg"
|
|
546
|
+
style={{ backgroundColor: 'var(--bg-mantle)', border: '1px solid var(--bg-surface)' }}
|
|
547
|
+
>
|
|
548
|
+
{/* Key badge */}
|
|
549
|
+
<button
|
|
550
|
+
className="text-[10px] font-mono px-2 py-0.5 rounded shrink-0"
|
|
551
|
+
style={{ backgroundColor: 'var(--bg-surface)', color: 'var(--accent-blue)', border: '1px solid var(--bg-overlay)', minWidth: 60, textAlign: 'center' }}
|
|
552
|
+
onClick={() => setCapturingFor(kb.id)}
|
|
553
|
+
>
|
|
554
|
+
{kb.key}
|
|
555
|
+
</button>
|
|
556
|
+
|
|
557
|
+
{/* Conflict warning */}
|
|
558
|
+
{BUILTIN_KEYS.has(kb.key) && (
|
|
559
|
+
<span className="text-[9px] text-[color:var(--accent-yellow)] shrink-0" title={t('settings.kb.conflict')}>!</span>
|
|
560
|
+
)}
|
|
561
|
+
|
|
562
|
+
{/* Label */}
|
|
563
|
+
<input
|
|
564
|
+
className="flex-1 bg-transparent text-xs text-[color:var(--text-main)] outline-none min-w-0 font-mono"
|
|
565
|
+
style={{ maxWidth: 100 }}
|
|
566
|
+
value={kb.label}
|
|
567
|
+
onChange={(e) => updateKeybinding(kb.id, { label: e.target.value })}
|
|
568
|
+
placeholder={t('settings.kb.label')}
|
|
569
|
+
onClick={(e) => e.stopPropagation()}
|
|
570
|
+
/>
|
|
571
|
+
|
|
572
|
+
{/* Command */}
|
|
573
|
+
<input
|
|
574
|
+
className="flex-[2] bg-transparent text-xs text-[color:var(--text-sub2)] outline-none min-w-0 font-mono"
|
|
575
|
+
value={kb.command}
|
|
576
|
+
onChange={(e) => updateKeybinding(kb.id, { command: e.target.value })}
|
|
577
|
+
placeholder={t('settings.kb.command')}
|
|
578
|
+
onClick={(e) => e.stopPropagation()}
|
|
579
|
+
/>
|
|
580
|
+
|
|
581
|
+
{/* Send Enter toggle */}
|
|
582
|
+
<Toggle
|
|
583
|
+
checked={kb.sendEnter}
|
|
584
|
+
onChange={(v) => updateKeybinding(kb.id, { sendEnter: v })}
|
|
585
|
+
label={t('settings.kb.sendEnter')}
|
|
586
|
+
/>
|
|
587
|
+
|
|
588
|
+
{/* Delete */}
|
|
589
|
+
<button
|
|
590
|
+
className="text-[color:var(--text-subtle)] hover:text-[color:var(--accent-red)] text-xs transition-colors shrink-0"
|
|
591
|
+
onClick={() => removeKeybinding(kb.id)}
|
|
592
|
+
title={t('settings.kb.delete')}
|
|
593
|
+
>
|
|
594
|
+
✕
|
|
595
|
+
</button>
|
|
596
|
+
</div>
|
|
597
|
+
))}
|
|
598
|
+
</div>
|
|
599
|
+
)}
|
|
600
|
+
|
|
601
|
+
{/* Add button */}
|
|
602
|
+
<button
|
|
603
|
+
className="mt-2 px-3 py-1.5 rounded-lg text-xs font-mono transition-colors"
|
|
604
|
+
style={{ backgroundColor: 'var(--bg-surface)', color: 'var(--accent-green)', border: '1px solid var(--bg-overlay)' }}
|
|
605
|
+
onClick={() => setCapturingFor('new')}
|
|
606
|
+
>
|
|
607
|
+
+ {t('settings.kb.add')}
|
|
608
|
+
</button>
|
|
609
|
+
|
|
610
|
+
{/* Key capture overlay */}
|
|
611
|
+
{capturingFor && (
|
|
612
|
+
<KeyCaptureOverlay
|
|
613
|
+
label={t('settings.kb.pressKey')}
|
|
614
|
+
onCapture={(key) => {
|
|
615
|
+
if (capturingFor === 'new') {
|
|
616
|
+
addKeybinding({ key, label: '', command: '', sendEnter: true });
|
|
617
|
+
} else {
|
|
618
|
+
updateKeybinding(capturingFor, { key });
|
|
619
|
+
}
|
|
620
|
+
setCapturingFor(null);
|
|
621
|
+
}}
|
|
622
|
+
onCancel={() => setCapturingFor(null)}
|
|
623
|
+
/>
|
|
624
|
+
)}
|
|
625
|
+
</div>
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function TabAbout() {
|
|
630
|
+
const t = useT();
|
|
631
|
+
|
|
632
|
+
return (
|
|
633
|
+
<div className="flex flex-col gap-4">
|
|
634
|
+
<div
|
|
635
|
+
className="flex flex-col items-center gap-3 py-6 rounded-lg"
|
|
636
|
+
style={{ backgroundColor: 'var(--bg-mantle)', border: '1px solid var(--bg-surface)' }}
|
|
637
|
+
>
|
|
638
|
+
<span className="text-3xl font-bold font-mono tracking-widest text-[color:var(--text-main)]">WMUX</span>
|
|
639
|
+
<div className="flex flex-col items-center gap-1">
|
|
640
|
+
<span
|
|
641
|
+
className="text-xs font-mono px-2 py-0.5 rounded"
|
|
642
|
+
style={{ backgroundColor: 'var(--bg-surface)', color: 'var(--accent-blue)', border: '1px solid var(--bg-overlay)' }}
|
|
643
|
+
>
|
|
644
|
+
v1.0.0
|
|
645
|
+
</span>
|
|
646
|
+
<p className="text-[11px] text-[color:var(--text-muted)] mt-1">
|
|
647
|
+
{t('settings.aboutTagline')}
|
|
648
|
+
</p>
|
|
649
|
+
</div>
|
|
650
|
+
</div>
|
|
651
|
+
|
|
652
|
+
<div className="flex flex-col gap-2">
|
|
653
|
+
<SectionLabel label={t('settings.builtWith')} />
|
|
654
|
+
<div
|
|
655
|
+
className="px-3 py-2.5 rounded-lg flex flex-col gap-1.5"
|
|
656
|
+
style={{ backgroundColor: 'var(--bg-mantle)', border: '1px solid var(--bg-surface)' }}
|
|
657
|
+
>
|
|
658
|
+
{[
|
|
659
|
+
'Electron 41',
|
|
660
|
+
'React 19 + TypeScript 5.9',
|
|
661
|
+
'xterm.js 6 + node-pty',
|
|
662
|
+
'Vite 5 + Tailwind CSS 3',
|
|
663
|
+
'Zustand 5 + Immer',
|
|
664
|
+
].map((item) => (
|
|
665
|
+
<div key={item} className="flex items-center gap-2">
|
|
666
|
+
<span className="text-[color:var(--accent-green)] text-[10px]">▸</span>
|
|
667
|
+
<span className="text-[12px] text-[color:var(--text-sub)] font-mono">{item}</span>
|
|
668
|
+
</div>
|
|
669
|
+
))}
|
|
670
|
+
</div>
|
|
671
|
+
</div>
|
|
672
|
+
|
|
673
|
+
<div>
|
|
674
|
+
<SectionLabel label={t('settings.links')} />
|
|
675
|
+
<a
|
|
676
|
+
href="https://github.com"
|
|
677
|
+
target="_blank"
|
|
678
|
+
rel="noopener noreferrer"
|
|
679
|
+
className="flex items-center gap-2 px-3 py-2.5 rounded-lg text-sm text-[color:var(--accent-blue)] hover:text-[color:var(--text-main)] transition-colors"
|
|
680
|
+
style={{ backgroundColor: 'var(--bg-mantle)', border: '1px solid var(--bg-surface)' }}
|
|
681
|
+
>
|
|
682
|
+
<span>⎋</span>
|
|
683
|
+
<span>{t('settings.githubRepo')}</span>
|
|
684
|
+
</a>
|
|
685
|
+
</div>
|
|
686
|
+
</div>
|
|
687
|
+
);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// ─── SettingsPanel ─────────────────────────────────────────────────────────────
|
|
691
|
+
|
|
692
|
+
export default function SettingsPanel() {
|
|
693
|
+
const t = useT();
|
|
694
|
+
const visible = useStore((s) => s.settingsPanelVisible);
|
|
695
|
+
const setVisible = useStore((s) => s.setSettingsPanelVisible);
|
|
696
|
+
|
|
697
|
+
const [activeTab, setActiveTab] = useState<TabId>('general');
|
|
698
|
+
const [updateStatus, setUpdateStatus] = useState<UpdateStatus>('idle');
|
|
699
|
+
const [updateMessage, setUpdateMessage] = useState('');
|
|
700
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
701
|
+
|
|
702
|
+
const tabs: { id: TabId; label: string; icon: string }[] = [
|
|
703
|
+
{ id: 'general', label: t('settings.tabGeneral'), icon: '⚙' },
|
|
704
|
+
{ id: 'appearance', label: t('settings.tabAppearance'), icon: '◑' },
|
|
705
|
+
{ id: 'notifications', label: t('settings.tabNotifications'), icon: '◎' },
|
|
706
|
+
{ id: 'shortcuts', label: t('settings.tabShortcuts'), icon: '⌨' },
|
|
707
|
+
{ id: 'about', label: t('settings.tabAbout'), icon: 'ℹ' },
|
|
708
|
+
];
|
|
709
|
+
|
|
710
|
+
// Listen for update events from main process
|
|
711
|
+
useEffect(() => {
|
|
712
|
+
const unsubAvailable = window.electronAPI.updater.onUpdateAvailable((data) => {
|
|
713
|
+
if (data.status === 'downloaded') {
|
|
714
|
+
setUpdateStatus('available');
|
|
715
|
+
setUpdateMessage(data.releaseName ?? t('settings.updateReady'));
|
|
716
|
+
} else {
|
|
717
|
+
setUpdateStatus('available');
|
|
718
|
+
setUpdateMessage(data.releaseName ?? t('settings.updateAvailable'));
|
|
719
|
+
}
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
const unsubNotAvail = window.electronAPI.updater.onUpdateNotAvailable(() => {
|
|
723
|
+
setUpdateStatus('up-to-date');
|
|
724
|
+
setUpdateMessage('');
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
const unsubError = window.electronAPI.updater.onUpdateError((data) => {
|
|
728
|
+
setUpdateStatus('error');
|
|
729
|
+
setUpdateMessage(data.message ?? t('settings.unknownError'));
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
return () => {
|
|
733
|
+
unsubAvailable();
|
|
734
|
+
unsubNotAvail();
|
|
735
|
+
unsubError();
|
|
736
|
+
};
|
|
737
|
+
}, []);
|
|
738
|
+
|
|
739
|
+
// Close on Escape
|
|
740
|
+
useEffect(() => {
|
|
741
|
+
if (!visible) return;
|
|
742
|
+
const handler = (e: KeyboardEvent) => {
|
|
743
|
+
if (e.key === 'Escape') {
|
|
744
|
+
e.stopPropagation();
|
|
745
|
+
setVisible(false);
|
|
746
|
+
}
|
|
747
|
+
};
|
|
748
|
+
window.addEventListener('keydown', handler, { capture: true });
|
|
749
|
+
return () => window.removeEventListener('keydown', handler, { capture: true });
|
|
750
|
+
}, [visible, setVisible]);
|
|
751
|
+
|
|
752
|
+
const handleCheckUpdate = useCallback(async () => {
|
|
753
|
+
setUpdateStatus('checking');
|
|
754
|
+
setUpdateMessage('');
|
|
755
|
+
try {
|
|
756
|
+
const result = await window.electronAPI.updater.checkForUpdates();
|
|
757
|
+
if (result.status === 'not-available') {
|
|
758
|
+
setUpdateStatus('up-to-date');
|
|
759
|
+
}
|
|
760
|
+
} catch {
|
|
761
|
+
setUpdateStatus('error');
|
|
762
|
+
setUpdateMessage(t('settings.checkFailed'));
|
|
763
|
+
}
|
|
764
|
+
}, []);
|
|
765
|
+
|
|
766
|
+
const handleInstallUpdate = useCallback(async () => {
|
|
767
|
+
await window.electronAPI.updater.installUpdate();
|
|
768
|
+
}, []);
|
|
769
|
+
|
|
770
|
+
if (!visible) return null;
|
|
771
|
+
|
|
772
|
+
return (
|
|
773
|
+
// Backdrop
|
|
774
|
+
<div
|
|
775
|
+
className="fixed inset-0 z-50 flex items-start justify-center pt-[8vh]"
|
|
776
|
+
style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
|
|
777
|
+
onMouseDown={(e) => {
|
|
778
|
+
if (e.target === e.currentTarget) setVisible(false);
|
|
779
|
+
}}
|
|
780
|
+
>
|
|
781
|
+
{/* Panel — 600x450 */}
|
|
782
|
+
<div
|
|
783
|
+
ref={panelRef}
|
|
784
|
+
className="flex flex-col rounded-xl overflow-hidden shadow-2xl"
|
|
785
|
+
style={{
|
|
786
|
+
width: 600,
|
|
787
|
+
height: 450,
|
|
788
|
+
backgroundColor: 'var(--bg-base)',
|
|
789
|
+
border: '1px solid var(--bg-surface)',
|
|
790
|
+
boxShadow: '0 25px 60px rgba(0,0,0,0.75)',
|
|
791
|
+
}}
|
|
792
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
793
|
+
>
|
|
794
|
+
{/* Header */}
|
|
795
|
+
<div
|
|
796
|
+
className="flex items-center justify-between px-5 py-3 shrink-0"
|
|
797
|
+
style={{ borderBottom: '1px solid var(--bg-surface)' }}
|
|
798
|
+
>
|
|
799
|
+
<span className="text-sm font-semibold text-[color:var(--text-main)] font-mono tracking-wide">{t('settings.title')}</span>
|
|
800
|
+
<button
|
|
801
|
+
className="text-[color:var(--text-subtle)] hover:text-[color:var(--text-main)] transition-colors"
|
|
802
|
+
onClick={() => setVisible(false)}
|
|
803
|
+
aria-label={t('settings.close')}
|
|
804
|
+
>
|
|
805
|
+
<IconX />
|
|
806
|
+
</button>
|
|
807
|
+
</div>
|
|
808
|
+
|
|
809
|
+
{/* Body: left nav + right content */}
|
|
810
|
+
<div className="flex flex-1 min-h-0">
|
|
811
|
+
{/* Left tab navigation */}
|
|
812
|
+
<nav
|
|
813
|
+
className="flex flex-col gap-0.5 py-3 px-2 shrink-0"
|
|
814
|
+
style={{
|
|
815
|
+
width: 140,
|
|
816
|
+
borderRight: '1px solid var(--bg-surface)',
|
|
817
|
+
backgroundColor: 'var(--bg-mantle)',
|
|
818
|
+
}}
|
|
819
|
+
>
|
|
820
|
+
{tabs.map((tab) => {
|
|
821
|
+
const isActive = activeTab === tab.id;
|
|
822
|
+
return (
|
|
823
|
+
<button
|
|
824
|
+
key={tab.id}
|
|
825
|
+
onClick={() => setActiveTab(tab.id)}
|
|
826
|
+
className="flex items-center gap-2.5 px-3 py-2 rounded-lg text-left transition-colors text-[12px]"
|
|
827
|
+
style={{
|
|
828
|
+
backgroundColor: isActive ? 'var(--bg-surface)' : 'transparent',
|
|
829
|
+
color: isActive ? 'var(--text-main)' : 'var(--text-subtle)',
|
|
830
|
+
fontWeight: isActive ? 600 : 400,
|
|
831
|
+
}}
|
|
832
|
+
>
|
|
833
|
+
<span className="text-[13px] leading-none" style={{ color: isActive ? 'var(--accent-blue)' : 'var(--text-muted)' }}>
|
|
834
|
+
{tab.icon}
|
|
835
|
+
</span>
|
|
836
|
+
{tab.label}
|
|
837
|
+
</button>
|
|
838
|
+
);
|
|
839
|
+
})}
|
|
840
|
+
</nav>
|
|
841
|
+
|
|
842
|
+
{/* Right content */}
|
|
843
|
+
<div className="flex-1 overflow-y-auto px-5 py-4">
|
|
844
|
+
{activeTab === 'general' && (
|
|
845
|
+
<TabGeneral
|
|
846
|
+
updateStatus={updateStatus}
|
|
847
|
+
updateMessage={updateMessage}
|
|
848
|
+
onCheckUpdate={handleCheckUpdate}
|
|
849
|
+
onInstallUpdate={handleInstallUpdate}
|
|
850
|
+
/>
|
|
851
|
+
)}
|
|
852
|
+
{activeTab === 'appearance' && <TabAppearance />}
|
|
853
|
+
{activeTab === 'notifications' && <TabNotifications />}
|
|
854
|
+
{activeTab === 'shortcuts' && <TabShortcuts />}
|
|
855
|
+
{activeTab === 'about' && <TabAbout />}
|
|
856
|
+
</div>
|
|
857
|
+
</div>
|
|
858
|
+
|
|
859
|
+
{/* Footer */}
|
|
860
|
+
<div
|
|
861
|
+
className="flex items-center justify-between px-5 py-2.5 shrink-0"
|
|
862
|
+
style={{ borderTop: '1px solid var(--bg-surface)', backgroundColor: 'var(--bg-mantle)' }}
|
|
863
|
+
>
|
|
864
|
+
<span className="text-[10px] text-[color:var(--text-muted)] font-mono">{t('settings.toggleHint')}</span>
|
|
865
|
+
<button
|
|
866
|
+
className="text-xs text-[color:var(--text-subtle)] hover:text-[color:var(--text-main)] transition-colors"
|
|
867
|
+
onClick={() => setVisible(false)}
|
|
868
|
+
>
|
|
869
|
+
{t('settings.close')}
|
|
870
|
+
</button>
|
|
871
|
+
</div>
|
|
872
|
+
</div>
|
|
873
|
+
</div>
|
|
874
|
+
);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// ─── Locale flag helper ───────────────────────────────────────────────────────
|
|
878
|
+
|
|
879
|
+
function localeFlag(locale: Locale): string {
|
|
880
|
+
switch (locale) {
|
|
881
|
+
case 'en': return '🇺🇸';
|
|
882
|
+
case 'ko': return '🇰🇷';
|
|
883
|
+
case 'ja': return '🇯🇵';
|
|
884
|
+
case 'zh': return '🇨🇳';
|
|
885
|
+
}
|
|
886
|
+
}
|