Skip to content

Commit 4a2b260

Browse files
author
root
committed
Improve settings UX and tests
1 parent 639e376 commit 4a2b260

5 files changed

Lines changed: 430 additions & 12 deletions

File tree

src/i18n/locales/en-us/setting.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@
180180

181181
"network-proxy": "Network Proxy",
182182
"network-proxy-description": "Configure a proxy server for network requests. This is useful if you need to access external APIs through a proxy.",
183+
"network-proxy-helper": "Leave empty to disable the proxy. When set, all outbound HTTP requests from Eigent will respect this proxy configuration where supported.",
183184
"proxy-placeholder": "http://127.0.0.1:7890",
184185
"proxy-saved-restart-required": "Proxy configuration saved. Restart the app to apply changes.",
185186
"proxy-save-failed": "Failed to save proxy configuration.",

src/pages/Setting/General.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ export default function SettingGeneral() {
7979
const [proxyUrl, setProxyUrl] = useState('');
8080
const [isProxySaving, setIsProxySaving] = useState(false);
8181
const [proxyNeedsRestart, setProxyNeedsRestart] = useState(false);
82+
const [initialProxyUrl, setInitialProxyUrl] = useState<string | null>(null);
83+
const hasProxyChanged = proxyUrl.trim() !== (initialProxyUrl ?? '').trim();
8284

8385
useEffect(() => {
8486
const platform = window.electronAPI.getPlatform();
@@ -165,6 +167,9 @@ export default function SettingGeneral() {
165167
const result = await window.electronAPI.readGlobalEnv('HTTP_PROXY');
166168
if (result?.value) {
167169
setProxyUrl(result.value);
170+
setInitialProxyUrl(result.value);
171+
} else {
172+
setInitialProxyUrl('');
168173
}
169174
} catch (_error) {
170175
console.log('No proxy configured');
@@ -219,6 +224,7 @@ export default function SettingGeneral() {
219224
);
220225
if (!result?.success) throw new Error('envRemove returned no success');
221226
}
227+
setInitialProxyUrl(trimmed);
222228
setProxyNeedsRestart(true);
223229
toast.success(t('setting.proxy-saved-restart-required'));
224230
} catch (error) {
@@ -358,9 +364,12 @@ export default function SettingGeneral() {
358364
<div className="text-body-base font-bold text-text-heading">
359365
{t('setting.network-proxy')}
360366
</div>
361-
<div className="mb-4 text-sm leading-13 text-text-secondary">
367+
<div className="mb-1 text-sm leading-13 text-text-secondary">
362368
{t('setting.network-proxy-description')}
363369
</div>
370+
<div className="mb-3 text-xs leading-13 text-text-secondary">
371+
{t('setting.network-proxy-helper')}
372+
</div>
364373
</div>
365374
<Input
366375
placeholder={t('setting.proxy-placeholder')}
@@ -383,7 +392,10 @@ export default function SettingGeneral() {
383392
? () => window.electronAPI?.restartApp()
384393
: handleSaveProxy
385394
}
386-
disabled={!proxyNeedsRestart && isProxySaving}
395+
disabled={
396+
(!proxyNeedsRestart && isProxySaving) ||
397+
(!proxyNeedsRestart && !hasProxyChanged)
398+
}
387399
>
388400
{proxyNeedsRestart
389401
? t('setting.restart-to-apply')

src/pages/Setting/Privacy.tsx

Lines changed: 54 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,24 +18,60 @@ import { Switch } from '@/components/ui/switch';
1818
import { ChevronDown } from 'lucide-react';
1919
import { useEffect, useState } from 'react';
2020
import { useTranslation } from 'react-i18next';
21+
import { toast } from 'sonner';
2122
export default function SettingPrivacy() {
2223
const [helpImprove, setHelpImprove] = useState(false);
24+
const [isLoading, setIsLoading] = useState(true);
25+
const [isSaving, setIsSaving] = useState(false);
2326
const { t } = useTranslation();
2427
const [isHowWeHandleOpen, setIsHowWeHandleOpen] = useState(false);
2528

2629
useEffect(() => {
27-
proxyFetchGet('/api/user/privacy')
28-
.then((res) => {
29-
setHelpImprove(res.help_improve || false);
30-
})
31-
.catch((err) => console.error('Failed to fetch settings:', err));
32-
}, []);
30+
let isCancelled = false;
3331

34-
const handleToggleHelpImprove = (checked: boolean) => {
32+
const loadPrivacySettings = async () => {
33+
try {
34+
const res = await proxyFetchGet('/api/user/privacy');
35+
if (!isCancelled) {
36+
setHelpImprove(Boolean(res?.help_improve));
37+
}
38+
} catch (err) {
39+
if (!isCancelled) {
40+
// Log to console for debugging while keeping user feedback localized
41+
// eslint-disable-next-line no-console
42+
console.error('Failed to fetch settings:', err);
43+
toast.error(t('setting.load-failed'));
44+
}
45+
} finally {
46+
if (!isCancelled) {
47+
setIsLoading(false);
48+
}
49+
}
50+
};
51+
52+
void loadPrivacySettings();
53+
54+
return () => {
55+
isCancelled = true;
56+
};
57+
}, [t]);
58+
59+
const handleToggleHelpImprove = async (checked: boolean) => {
60+
// Optimistically update UI but revert on failure
61+
const previousValue = helpImprove;
3562
setHelpImprove(checked);
36-
proxyFetchPut('/api/user/privacy', { help_improve: checked }).catch((err) =>
37-
console.error('Failed to update settings:', err)
38-
);
63+
setIsSaving(true);
64+
65+
try {
66+
await proxyFetchPut('/api/user/privacy', { help_improve: checked });
67+
} catch (err) {
68+
// eslint-disable-next-line no-console
69+
console.error('Failed to update settings:', err);
70+
setHelpImprove(previousValue);
71+
toast.error(t('setting.save-failed'));
72+
} finally {
73+
setIsSaving(false);
74+
}
3975
};
4076

4177
return (
@@ -121,11 +157,19 @@ export default function SettingPrivacy() {
121157
<div className="text-body-sm font-normal text-text-body">
122158
{t('setting.help-improve-eigent-description')}
123159
</div>
160+
{!isLoading && (
161+
<div className="text-body-xs text-text-secondary">
162+
{helpImprove
163+
? t('setting.enabled')
164+
: t('setting.disabled')}
165+
</div>
166+
)}
124167
</div>
125168
<div className="flex items-center justify-center">
126169
<Switch
127170
checked={helpImprove}
128171
onCheckedChange={handleToggleHelpImprove}
172+
disabled={isLoading || isSaving}
129173
/>
130174
</div>
131175
</div>
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
2+
// Licensed under the Apache License, Version 2.0 (the "License");
3+
// you may not use this file except in compliance with the License.
4+
// You may obtain a copy of the License at
5+
//
6+
// http://www.apache.org/licenses/LICENSE-2.0
7+
//
8+
// Unless required by applicable law or agreed to in writing, software
9+
// distributed under the License is distributed on an "AS IS" BASIS,
10+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
// See the License for the specific language governing permissions and
12+
// limitations under the License.
13+
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
14+
15+
import { render, screen, waitFor } from '@testing-library/react';
16+
import userEvent from '@testing-library/user-event';
17+
import { beforeEach, describe, expect, it, vi } from 'vitest';
18+
19+
import SettingGeneral from '@/pages/Setting/General';
20+
21+
vi.mock('react-i18next', () => ({
22+
Trans: ({ children }: { children: React.ReactNode }) => children,
23+
useTranslation: () => ({
24+
t: (key: string) => key,
25+
}),
26+
}));
27+
28+
const mockClearTasks = vi.fn();
29+
30+
vi.mock('@/hooks/useChatStoreAdapter', () => ({
31+
default: () => ({
32+
chatStore: {
33+
clearTasks: mockClearTasks,
34+
},
35+
}),
36+
}));
37+
38+
const mockResetInstallation = vi.fn();
39+
const mockSetNeedsBackendRestart = vi.fn();
40+
41+
vi.mock('@/store/installationStore', async () => {
42+
const actual =
43+
await vi.importActual<typeof import('@/store/installationStore')>(
44+
'@/store/installationStore'
45+
);
46+
return {
47+
...actual,
48+
useInstallationStore: (selector: any) =>
49+
selector({
50+
reset: mockResetInstallation,
51+
setNeedsBackendRestart: mockSetNeedsBackendRestart,
52+
}),
53+
};
54+
});
55+
56+
const mockLogout = vi.fn();
57+
58+
vi.mock('@/store/authStore', async () => {
59+
const actual = await vi.importActual<typeof import('@/store/authStore')>(
60+
'@/store/authStore'
61+
);
62+
return {
63+
...actual,
64+
useAuthStore: () => ({
65+
email: 'test@example.com',
66+
appearance: 'dark',
67+
language: 'system',
68+
setAppearance: vi.fn(),
69+
setLanguage: vi.fn(),
70+
logout: mockLogout,
71+
}),
72+
getAuthStore: () => ({
73+
token: 'token',
74+
}),
75+
};
76+
});
77+
78+
const toastErrorMock = vi.fn();
79+
const toastSuccessMock = vi.fn();
80+
81+
vi.mock('sonner', () => ({
82+
toast: {
83+
error: (msg: string) => toastErrorMock(msg),
84+
success: (msg: string) => toastSuccessMock(msg),
85+
},
86+
}));
87+
88+
const navigateMock = vi.fn();
89+
90+
vi.mock('react-router-dom', async () => {
91+
const actual = await vi.importActual<typeof import('react-router-dom')>(
92+
'react-router-dom'
93+
);
94+
return {
95+
...actual,
96+
useNavigate: () => navigateMock,
97+
};
98+
});
99+
100+
describe('SettingGeneral Network Proxy', () => {
101+
beforeEach(() => {
102+
vi.clearAllMocks();
103+
104+
(window as any).electronAPI = {
105+
getPlatform: () => 'linux',
106+
readGlobalEnv: vi.fn().mockResolvedValue({ value: 'http://proxy:8080' }),
107+
envWrite: vi.fn().mockResolvedValue({ success: true }),
108+
envRemove: vi.fn().mockResolvedValue({ success: true }),
109+
restartApp: vi.fn(),
110+
};
111+
});
112+
113+
it('loads existing proxy value and disables save button until changed', async () => {
114+
render(<SettingGeneral />);
115+
116+
const input = await screen.findByPlaceholderText(
117+
'setting.proxy-placeholder'
118+
);
119+
expect((input as HTMLInputElement).value).toBe('http://proxy:8080');
120+
121+
const button = screen.getByRole('button', { name: 'setting.save' });
122+
expect(button).toBeDisabled();
123+
124+
await userEvent.clear(input);
125+
await userEvent.type(input, 'http://proxy:9090');
126+
127+
expect(button).not.toBeDisabled();
128+
});
129+
130+
it('validates proxy URL and shows error for invalid protocol', async () => {
131+
render(<SettingGeneral />);
132+
133+
const input = await screen.findByPlaceholderText(
134+
'setting.proxy-placeholder'
135+
);
136+
137+
await userEvent.clear(input);
138+
await userEvent.type(input, 'ftp://invalid-proxy');
139+
140+
const button = screen.getByRole('button', { name: 'setting.save' });
141+
await userEvent.click(button);
142+
143+
await waitFor(() => {
144+
expect(toastErrorMock).toHaveBeenCalledWith('setting.proxy-invalid-url');
145+
});
146+
});
147+
148+
it('saves proxy URL and requires restart', async () => {
149+
render(<SettingGeneral />);
150+
151+
const input = await screen.findByPlaceholderText(
152+
'setting.proxy-placeholder'
153+
);
154+
155+
await userEvent.clear(input);
156+
await userEvent.type(input, 'http://proxy:9090');
157+
158+
const button = screen.getByRole('button', { name: 'setting.save' });
159+
await userEvent.click(button);
160+
161+
await waitFor(() => {
162+
expect((window as any).electronAPI.envWrite).toHaveBeenCalledWith(
163+
'test@example.com',
164+
{
165+
key: 'HTTP_PROXY',
166+
value: 'http://proxy:9090',
167+
}
168+
);
169+
expect(toastSuccessMock).toHaveBeenCalledWith(
170+
'setting.proxy-saved-restart-required'
171+
);
172+
});
173+
174+
// Button should now show restart label and trigger restart
175+
const restartButton = screen.getByRole('button', {
176+
name: 'setting.restart-to-apply',
177+
});
178+
await userEvent.click(restartButton);
179+
180+
expect((window as any).electronAPI.restartApp).toHaveBeenCalled();
181+
});
182+
it('removes proxy when input cleared', async () => {
183+
// Start with existing value
184+
render(<SettingGeneral />);
185+
186+
const input = await screen.findByPlaceholderText(
187+
'setting.proxy-placeholder'
188+
);
189+
190+
await userEvent.clear(input);
191+
192+
const button = screen.getByRole('button', { name: 'setting.save' });
193+
await userEvent.click(button);
194+
195+
await waitFor(() => {
196+
expect((window as any).electronAPI.envRemove).toHaveBeenCalledWith(
197+
'test@example.com',
198+
'HTTP_PROXY'
199+
);
200+
expect(toastSuccessMock).toHaveBeenCalledWith(
201+
'setting.proxy-saved-restart-required'
202+
);
203+
});
204+
});
205+
206+
it('shows error when electron env APIs are missing', async () => {
207+
(window as any).electronAPI = {
208+
getPlatform: () => 'linux',
209+
readGlobalEnv: vi.fn().mockResolvedValue({ value: '' }),
210+
// envWrite/envRemove intentionally omitted
211+
};
212+
213+
render(<SettingGeneral />);
214+
215+
const input = await screen.findByPlaceholderText(
216+
'setting.proxy-placeholder'
217+
);
218+
await userEvent.type(input, 'http://proxy:8080');
219+
220+
const button = screen.getByRole('button', { name: 'setting.save' });
221+
await userEvent.click(button);
222+
223+
await waitFor(() => {
224+
expect(toastErrorMock).toHaveBeenCalledWith('setting.proxy-save-failed');
225+
});
226+
});
227+
});
228+

0 commit comments

Comments
 (0)