mirror of
https://github.com/lobehub/lobehub
synced 2026-04-21 09:37:28 +00:00
✨ feat(electron): add + button to TabBar for new topic in active context (#13972)
* ✨ feat(electron): add + button to TabBar to open new topic in active context Introduce a pluggable `createNewTabAction` extension on RecentlyViewed plugins so each page type can decide whether (and how) to spawn a new tab from the active tab. Implemented for agent / agent-topic / group / group-topic — clicking `+` creates a fresh topic under the current agent/group and opens it as a new tab; other page types hide the button by default. * ✨ feat(electron): support new tab from page context Page plugin now implements `createNewTabAction`, creating a fresh untitled document via `usePageStore().createPage` and opening it as a new `page` tab. * 🐛 fix(electron): refresh page list after creating a new page via TabBar + `createPage` only hits the service; without refreshing the documents list, the sidebar / PageExplorer wouldn't show the freshly-created page until the next full reload. * 🐛 fix(electron): highlight new page in sidebar when opened via TabBar + Switch to `createNewPage`, which runs the full optimistic flow — dispatches the new document into the sidebar list and sets `selectedPageId` — so the nav item active state stays in sync with the freshly-opened page tab. * 🐛 fix(electron): dispatch real page doc into sidebar list for TabBar + The earlier `createNewPage` approach relied on an optimistic temp document that SWR revalidation can clobber before the real doc replaces it, leaving the new page absent from the sidebar. Create the page via `createPage` first, then synthesize a `LobeDocument` from the server response and dispatch it into the list alongside setting `selectedPageId` — the nav item now appears and highlights in sync with the new tab.
This commit is contained in:
parent
6b6915d147
commit
730169e6b6
13 changed files with 309 additions and 8 deletions
|
|
@ -106,6 +106,7 @@
|
|||
"tab.closeLeftTabs": "Close Tabs to the Left",
|
||||
"tab.closeOtherTabs": "Close Other Tabs",
|
||||
"tab.closeRightTabs": "Close Tabs to the Right",
|
||||
"tab.newTab": "New Tab",
|
||||
"tab.running": "Agent is running",
|
||||
"updater.checkingUpdate": "Checking for updates",
|
||||
"updater.checkingUpdateDesc": "Retrieving version information...",
|
||||
|
|
|
|||
|
|
@ -106,6 +106,7 @@
|
|||
"tab.closeLeftTabs": "关闭左侧标签页",
|
||||
"tab.closeOtherTabs": "关闭其他标签页",
|
||||
"tab.closeRightTabs": "关闭右侧标签页",
|
||||
"tab.newTab": "新建标签页",
|
||||
"tab.running": "智能体运行中",
|
||||
"updater.checkingUpdate": "检查新版本",
|
||||
"updater.checkingUpdateDesc": "正在获取版本信息…",
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@ import { MessageSquare } from 'lucide-react';
|
|||
import { useChatStore } from '@/store/chat';
|
||||
|
||||
import { type AgentParams, type PageReference, type ResolvedPageData } from '../types';
|
||||
import { type PluginContext, type RecentlyViewedPlugin } from './types';
|
||||
import { buildAgentNewTopicAction } from './newTabHelpers';
|
||||
import { type NewTabAction, type PluginContext, type RecentlyViewedPlugin } from './types';
|
||||
import { createPageReference } from './types';
|
||||
|
||||
const AGENT_PATH_REGEX = /^\/agent\/([^/?]+)$/;
|
||||
|
|
@ -13,6 +14,11 @@ export const agentPlugin: RecentlyViewedPlugin<'agent'> = {
|
|||
const meta = ctx.getAgentMeta(reference.params.agentId);
|
||||
return meta !== undefined && Object.keys(meta).length > 0;
|
||||
},
|
||||
|
||||
createNewTabAction(reference: PageReference<'agent'>, ctx: PluginContext): NewTabAction | null {
|
||||
return buildAgentNewTopicAction(reference.params.agentId, ctx);
|
||||
},
|
||||
|
||||
generateId(reference: PageReference<'agent'>): string {
|
||||
return `agent:${reference.params.agentId}`;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@ import { MessageSquare } from 'lucide-react';
|
|||
import { useChatStore } from '@/store/chat';
|
||||
|
||||
import { type AgentTopicParams, type PageReference, type ResolvedPageData } from '../types';
|
||||
import { type PluginContext, type RecentlyViewedPlugin } from './types';
|
||||
import { buildAgentNewTopicAction } from './newTabHelpers';
|
||||
import { type NewTabAction, type PluginContext, type RecentlyViewedPlugin } from './types';
|
||||
import { createPageReference } from './types';
|
||||
|
||||
const AGENT_PATH_REGEX = /^\/agent\/([^/?]+)$/;
|
||||
|
|
@ -18,6 +19,13 @@ export const agentTopicPlugin: RecentlyViewedPlugin<'agent-topic'> = {
|
|||
return agentMeta !== undefined && Object.keys(agentMeta).length > 0 && topic !== undefined;
|
||||
},
|
||||
|
||||
createNewTabAction(
|
||||
reference: PageReference<'agent-topic'>,
|
||||
ctx: PluginContext,
|
||||
): NewTabAction | null {
|
||||
return buildAgentNewTopicAction(reference.params.agentId, ctx);
|
||||
},
|
||||
|
||||
generateId(reference: PageReference<'agent-topic'>): string {
|
||||
const { agentId, topicId } = reference.params;
|
||||
return `agent-topic:${agentId}:${topicId}`;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { Users } from 'lucide-react';
|
||||
|
||||
import { type GroupParams, type PageReference, type ResolvedPageData } from '../types';
|
||||
import { type PluginContext, type RecentlyViewedPlugin } from './types';
|
||||
import { buildGroupNewTopicAction } from './newTabHelpers';
|
||||
import { type NewTabAction, type PluginContext, type RecentlyViewedPlugin } from './types';
|
||||
import { createPageReference } from './types';
|
||||
|
||||
const GROUP_PATH_REGEX = /^\/group\/([^/?]+)$/;
|
||||
|
|
@ -11,6 +12,11 @@ export const groupPlugin: RecentlyViewedPlugin<'group'> = {
|
|||
const group = ctx.getSessionGroup(reference.params.groupId);
|
||||
return group !== undefined;
|
||||
},
|
||||
|
||||
createNewTabAction(reference: PageReference<'group'>, ctx: PluginContext): NewTabAction | null {
|
||||
return buildGroupNewTopicAction(reference.params.groupId, ctx);
|
||||
},
|
||||
|
||||
generateId(reference: PageReference<'group'>): string {
|
||||
return `group:${reference.params.groupId}`;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { Users } from 'lucide-react';
|
||||
|
||||
import { type GroupTopicParams, type PageReference, type ResolvedPageData } from '../types';
|
||||
import { type PluginContext, type RecentlyViewedPlugin } from './types';
|
||||
import { buildGroupNewTopicAction } from './newTabHelpers';
|
||||
import { type NewTabAction, type PluginContext, type RecentlyViewedPlugin } from './types';
|
||||
import { createPageReference } from './types';
|
||||
|
||||
const GROUP_PATH_REGEX = /^\/group\/([^/?]+)$/;
|
||||
|
|
@ -16,6 +17,13 @@ export const groupTopicPlugin: RecentlyViewedPlugin<'group-topic'> = {
|
|||
return group !== undefined && topic !== undefined;
|
||||
},
|
||||
|
||||
createNewTabAction(
|
||||
reference: PageReference<'group-topic'>,
|
||||
ctx: PluginContext,
|
||||
): NewTabAction | null {
|
||||
return buildGroupNewTopicAction(reference.params.groupId, ctx);
|
||||
},
|
||||
|
||||
generateId(reference: PageReference<'group-topic'>): string {
|
||||
const { groupId, topicId } = reference.params;
|
||||
return `group-topic:${groupId}:${topicId}`;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,152 @@
|
|||
import { lambdaClient } from '@/libs/trpc/client';
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { usePageStore } from '@/store/page';
|
||||
import { DocumentSourceType, type LobeDocument } from '@/types/document';
|
||||
|
||||
import { type CachedPageData, type PageReference } from '../types';
|
||||
import { type NewTabAction, type NewTabActionResult, type PluginContext } from './types';
|
||||
|
||||
const EDITOR_PAGE_FILE_TYPE = 'custom/document';
|
||||
|
||||
/**
|
||||
* Build a NewTabAction that creates a fresh topic under an agent and
|
||||
* returns an `agent-topic` reference pointing to it. The new reference
|
||||
* id embeds the topicId, which is globally unique, so it never collides
|
||||
* with the existing tab it was opened from.
|
||||
*/
|
||||
export const buildAgentNewTopicAction = (
|
||||
agentId: string,
|
||||
ctx: PluginContext,
|
||||
): NewTabAction | null => {
|
||||
const meta = ctx.getAgentMeta(agentId);
|
||||
if (!meta || Object.keys(meta).length === 0) return null;
|
||||
|
||||
return {
|
||||
onCreate: async (): Promise<NewTabActionResult | null> => {
|
||||
const defaultTitle = ctx.t('defaultTitle', { ns: 'topic' });
|
||||
const topicId = await lambdaClient.topic.createTopic.mutate({
|
||||
agentId,
|
||||
messages: [],
|
||||
title: defaultTitle,
|
||||
});
|
||||
|
||||
await useChatStore.getState().refreshTopic();
|
||||
|
||||
const reference: PageReference<'agent-topic'> = {
|
||||
id: `agent-topic:${agentId}:${topicId}`,
|
||||
lastVisited: Date.now(),
|
||||
params: { agentId, topicId },
|
||||
type: 'agent-topic',
|
||||
};
|
||||
|
||||
const cached: CachedPageData = {
|
||||
avatar: meta.avatar,
|
||||
backgroundColor: meta.backgroundColor,
|
||||
title: defaultTitle,
|
||||
};
|
||||
|
||||
return { cached, reference };
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a NewTabAction that creates a fresh topic under a group and
|
||||
* returns a `group-topic` reference pointing to it.
|
||||
*/
|
||||
export const buildGroupNewTopicAction = (
|
||||
groupId: string,
|
||||
ctx: PluginContext,
|
||||
): NewTabAction | null => {
|
||||
const group = ctx.getSessionGroup(groupId);
|
||||
if (!group) return null;
|
||||
|
||||
return {
|
||||
onCreate: async (): Promise<NewTabActionResult | null> => {
|
||||
const defaultTitle = ctx.t('defaultTitle', { ns: 'topic' });
|
||||
const topicId = await lambdaClient.topic.createTopic.mutate({
|
||||
groupId,
|
||||
messages: [],
|
||||
title: defaultTitle,
|
||||
});
|
||||
|
||||
await useChatStore.getState().refreshTopic();
|
||||
|
||||
const reference: PageReference<'group-topic'> = {
|
||||
id: `group-topic:${groupId}:${topicId}`,
|
||||
lastVisited: Date.now(),
|
||||
params: { groupId, topicId },
|
||||
type: 'group-topic',
|
||||
};
|
||||
|
||||
const cached: CachedPageData = {
|
||||
title: defaultTitle,
|
||||
};
|
||||
|
||||
return { cached, reference };
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a NewTabAction that creates a fresh untitled page document and
|
||||
* returns a `page` reference pointing to it.
|
||||
*/
|
||||
export const buildPageNewTabAction = (ctx: PluginContext): NewTabAction => {
|
||||
return {
|
||||
onCreate: async (): Promise<NewTabActionResult | null> => {
|
||||
const untitled = ctx.t('pageList.untitled', { ns: 'file' });
|
||||
const pageStore = usePageStore.getState();
|
||||
|
||||
// Create the real page via service first — once the row exists on
|
||||
// the server, any SWR revalidation of the page list will include
|
||||
// it and won't clobber the optimistic add we do below.
|
||||
const newPage = await pageStore.createPage({ content: '', title: untitled });
|
||||
|
||||
// Synthesize a `LobeDocument` for the sidebar list.
|
||||
const now = new Date();
|
||||
const document: LobeDocument = {
|
||||
content: newPage.content || '',
|
||||
createdAt: newPage.createdAt ? new Date(newPage.createdAt) : now,
|
||||
editorData:
|
||||
typeof newPage.editorData === 'string'
|
||||
? (() => {
|
||||
try {
|
||||
return JSON.parse(newPage.editorData);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})()
|
||||
: newPage.editorData || null,
|
||||
fileType: newPage.fileType || EDITOR_PAGE_FILE_TYPE,
|
||||
filename: newPage.title || untitled,
|
||||
id: newPage.id,
|
||||
metadata: newPage.metadata || {},
|
||||
source: 'document',
|
||||
sourceType: DocumentSourceType.EDITOR,
|
||||
title: newPage.title || untitled,
|
||||
totalCharCount: (newPage.content || '').length,
|
||||
totalLineCount: 0,
|
||||
updatedAt: newPage.updatedAt ? new Date(newPage.updatedAt) : now,
|
||||
};
|
||||
|
||||
// Dispatch into the sidebar list and mark selected so the nav item
|
||||
// highlights in sync with the new tab.
|
||||
pageStore.internal_dispatchDocuments({ document, type: 'addDocument' });
|
||||
usePageStore.setState({ selectedPageId: newPage.id }, false, 'TabBar/newPage');
|
||||
|
||||
const reference: PageReference<'page'> = {
|
||||
id: `page:${newPage.id}`,
|
||||
lastVisited: Date.now(),
|
||||
params: { pageId: newPage.id },
|
||||
type: 'page',
|
||||
};
|
||||
|
||||
const cached: CachedPageData = {
|
||||
title: document.title || untitled,
|
||||
};
|
||||
|
||||
return { cached, reference };
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
@ -3,7 +3,8 @@ import { FileText } from 'lucide-react';
|
|||
import { getRouteById } from '@/config/routes';
|
||||
|
||||
import { type PageParams, type PageReference, type ResolvedPageData } from '../types';
|
||||
import { type PluginContext, type RecentlyViewedPlugin } from './types';
|
||||
import { buildPageNewTabAction } from './newTabHelpers';
|
||||
import { type NewTabAction, type PluginContext, type RecentlyViewedPlugin } from './types';
|
||||
import { createPageReference } from './types';
|
||||
|
||||
const PAGE_PATH_REGEX = /^\/page\/([^/?]+)$/;
|
||||
|
|
@ -15,6 +16,11 @@ export const pagePlugin: RecentlyViewedPlugin<'page'> = {
|
|||
const document = ctx.getDocument(reference.params.pageId);
|
||||
return document !== undefined;
|
||||
},
|
||||
|
||||
createNewTabAction(_reference: PageReference<'page'>, ctx: PluginContext): NewTabAction | null {
|
||||
return buildPageNewTabAction(ctx);
|
||||
},
|
||||
|
||||
generateId(reference: PageReference<'page'>): string {
|
||||
return `page:${reference.params.pageId}`;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { type PageReference, type PageType, type ResolvedPageData } from '../types';
|
||||
import {
|
||||
type BaseRecentlyViewedPlugin,
|
||||
type NewTabAction,
|
||||
type PluginContext,
|
||||
type RecentlyViewedPlugin,
|
||||
} from './types';
|
||||
|
|
@ -225,6 +226,16 @@ class PluginRegistry {
|
|||
plugin?.onActivate?.(reference);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a "new tab" action for the given reference via its plugin.
|
||||
* Returns null when the plugin does not implement the extension or
|
||||
* declines to produce an action for the current context.
|
||||
*/
|
||||
getNewTabAction(reference: PageReference, ctx: PluginContext): NewTabAction | null {
|
||||
const plugin = this.plugins.get(reference.type);
|
||||
return plugin?.createNewTabAction?.(reference, ctx) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve multiple page references, filtering out non-existent ones
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -6,12 +6,33 @@ import { type SessionGroupItem } from '@/types/session';
|
|||
import { type ChatTopic } from '@/types/topic';
|
||||
|
||||
import {
|
||||
type CachedPageData,
|
||||
type PageParamsMap,
|
||||
type PageReference,
|
||||
type PageType,
|
||||
type ResolvedPageData,
|
||||
} from '../types';
|
||||
|
||||
// ======== New Tab Action ======== //
|
||||
|
||||
/**
|
||||
* Descriptor returned by a plugin to enable the TabBar "+" button
|
||||
* for a given active reference.
|
||||
*/
|
||||
export interface NewTabAction {
|
||||
/**
|
||||
* Produce a new PageReference (plus optional cached display data) for
|
||||
* a fresh tab in the same context as the active tab. Return null to
|
||||
* cancel the creation (e.g. missing prerequisites).
|
||||
*/
|
||||
onCreate: () => Promise<NewTabActionResult | null>;
|
||||
}
|
||||
|
||||
export interface NewTabActionResult {
|
||||
cached?: CachedPageData;
|
||||
reference: PageReference;
|
||||
}
|
||||
|
||||
// ======== Plugin Context ======== //
|
||||
|
||||
/**
|
||||
|
|
@ -52,6 +73,12 @@ export interface BaseRecentlyViewedPlugin {
|
|||
*/
|
||||
checkExists: (reference: PageReference, ctx: PluginContext) => boolean;
|
||||
|
||||
/**
|
||||
* Build a "new tab" action for the TabBar "+" button. Return null to
|
||||
* hide the button when this plugin's reference is active.
|
||||
*/
|
||||
createNewTabAction?: (reference: PageReference, ctx: PluginContext) => NewTabAction | null;
|
||||
|
||||
/**
|
||||
* Generate unique ID from reference params
|
||||
*/
|
||||
|
|
@ -110,6 +137,12 @@ export interface RecentlyViewedPlugin<T extends PageType = PageType> {
|
|||
*/
|
||||
checkExists: (reference: PageReference<T>, ctx: PluginContext) => boolean;
|
||||
|
||||
/**
|
||||
* Build a "new tab" action for the TabBar "+" button. Return null to
|
||||
* hide the button when this plugin's reference is active.
|
||||
*/
|
||||
createNewTabAction?: (reference: PageReference<T>, ctx: PluginContext) => NewTabAction | null;
|
||||
|
||||
/**
|
||||
* Generate unique ID from reference params
|
||||
* e.g., "agent:abc123" or "agent-topic:abc123:topic456"
|
||||
|
|
|
|||
|
|
@ -1,11 +1,16 @@
|
|||
'use client';
|
||||
|
||||
import { ScrollArea } from '@lobehub/ui';
|
||||
import { startTransition, useCallback, useEffect, useRef } from 'react';
|
||||
import { ActionIcon, ScrollArea } from '@lobehub/ui';
|
||||
import { cx } from 'antd-style';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { startTransition, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { usePluginContext } from '@/features/Electron/titlebar/RecentlyViewed/hooks/usePluginContext';
|
||||
import { pluginRegistry } from '@/features/Electron/titlebar/RecentlyViewed/plugins';
|
||||
import { useElectronStore } from '@/store/electron';
|
||||
import { electronStylish } from '@/styles/electron';
|
||||
|
||||
import { useResolvedTabs } from './hooks/useResolvedTabs';
|
||||
import { useStyles } from './styles';
|
||||
|
|
@ -17,9 +22,12 @@ const TAB_GAP = 2;
|
|||
const TabBar = () => {
|
||||
const styles = useStyles;
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('electron');
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
const { tabs, activeTabId } = useResolvedTabs();
|
||||
const pluginCtx = usePluginContext();
|
||||
const activateTab = useElectronStore((s) => s.activateTab);
|
||||
const addTab = useElectronStore((s) => s.addTab);
|
||||
const removeTab = useElectronStore((s) => s.removeTab);
|
||||
const closeOtherTabs = useElectronStore((s) => s.closeOtherTabs);
|
||||
const closeLeftTabs = useElectronStore((s) => s.closeLeftTabs);
|
||||
|
|
@ -116,7 +124,37 @@ const TabBar = () => {
|
|||
}
|
||||
}, [activeTabId, tabs]);
|
||||
|
||||
if (tabs.length < 2) return null;
|
||||
const activeReference = useMemo(() => {
|
||||
if (!activeTabId) return null;
|
||||
return tabs.find((t) => t.reference.id === activeTabId)?.reference ?? null;
|
||||
}, [activeTabId, tabs]);
|
||||
|
||||
const newTabAction = useMemo(() => {
|
||||
if (!activeReference) return null;
|
||||
return pluginRegistry.getNewTabAction(activeReference, pluginCtx);
|
||||
}, [activeReference, pluginCtx]);
|
||||
|
||||
const handleNewTab = useCallback(async () => {
|
||||
if (!newTabAction) return;
|
||||
let result;
|
||||
try {
|
||||
result = await newTabAction.onCreate();
|
||||
} catch (error) {
|
||||
console.error('[TabBar] failed to create new tab:', error);
|
||||
return;
|
||||
}
|
||||
if (!result) return;
|
||||
|
||||
const { reference, cached } = result;
|
||||
addTab(reference, cached, true);
|
||||
pluginRegistry.onActivate(reference);
|
||||
|
||||
const resolved = pluginRegistry.resolve(reference, pluginCtx);
|
||||
const url = resolved?.url;
|
||||
if (url) startTransition(() => navigate(url));
|
||||
}, [newTabAction, addTab, pluginCtx, navigate]);
|
||||
|
||||
if (tabs.length === 0) return null;
|
||||
|
||||
return (
|
||||
<ScrollArea
|
||||
|
|
@ -140,6 +178,15 @@ const TabBar = () => {
|
|||
onCloseRight={handleCloseRight}
|
||||
/>
|
||||
))}
|
||||
{newTabAction && (
|
||||
<ActionIcon
|
||||
className={cx(electronStylish.nodrag, styles.newTabButton)}
|
||||
icon={Plus}
|
||||
size="small"
|
||||
title={t('tab.newTab')}
|
||||
onClick={handleNewTab}
|
||||
/>
|
||||
)}
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -77,4 +77,25 @@ export const useStyles = createStaticStyles(({ css, cssVar }) => ({
|
|||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`,
|
||||
newTabButton: css`
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 26px;
|
||||
height: 22px;
|
||||
border-radius: ${cssVar.borderRadiusSM};
|
||||
|
||||
color: ${cssVar.colorTextSecondary};
|
||||
|
||||
transition:
|
||||
background-color 0.15s ${cssVar.motionEaseInOut},
|
||||
color 0.15s ${cssVar.motionEaseInOut};
|
||||
|
||||
&:hover {
|
||||
color: ${cssVar.colorText};
|
||||
background-color: ${cssVar.colorFillTertiary};
|
||||
}
|
||||
`,
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ export default {
|
|||
'tab.closeLeftTabs': 'Close Tabs to the Left',
|
||||
'tab.closeOtherTabs': 'Close Other Tabs',
|
||||
'tab.closeRightTabs': 'Close Tabs to the Right',
|
||||
'tab.newTab': 'New Tab',
|
||||
'tab.running': 'Agent is running',
|
||||
'proxy.auth': 'Authentication Required',
|
||||
'proxy.authDesc': 'If the proxy server requires a username and password',
|
||||
|
|
|
|||
Loading…
Reference in a new issue