From 571eaf960be5cb7db839444b0e225bee49ec2e84 Mon Sep 17 00:00:00 2001 From: AiLi <3195572062@qq.com> Date: Wed, 23 Sep 2026 15:45:20 +0800 Subject: [PATCH] fix(plugins): localize workbench and filesystem tab titles on locale change --- apps/desktop/src/App.vue | 16 ++++- .../queryStore.openPluginWorkbench.spec.ts | 59 +++++++++++++++++++ apps/desktop/src/stores/queryStore.ts | 23 ++++++++ 3 files changed, 97 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index b9f075f463..48f2027212 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -93,6 +93,7 @@ import { externalSqlFileOpenErrorMessage, externalSqlEditorMaxBytes, isSqlFilePa import type { ConnectionConfig, DatabaseType, ObjectBrowserFilter, ObjectSourceKind, QueryTab, TabOutputView, TreeNode } from "@/types/database"; import type { PluginCenterFocus } from "@/lib/plugins/pluginCenterNavigation"; import { parsePluginInstallDeepLink } from "@/lib/plugins/pluginInstallDeepLink"; +import { createFrontendPluginRegistry } from "@/lib/plugins/frontendPlugin"; import { parseConnectionDeepLink, parseConnectionDeepLinkUpdate, type ConnectionDeepLinkDraft, type ConnectionDeepLinkUpdate } from "@/lib/connection/connectionDeepLink"; import { resolveConnectionDeepLinkUpdate } from "@/lib/connection/connectionDeepLinkUpdate"; import { parseAiConfigDeepLink, type AiConfigDeepLinkDraft } from "@/lib/ai/aiConfigDeepLink"; @@ -208,7 +209,7 @@ type AiAssistantHandle = { type AuxiliarySearchSurface = "ai" | "history" | "sqlLibrary" | null; -const { t } = useI18n(); +const { t, locale: appLocale } = useI18n(); const connectionStore = useConnectionStore(); const queryStore = useQueryStore(); const settingsStore = useSettingsStore(); @@ -216,6 +217,19 @@ const { active: appBackgroundActive, backgroundObjectUrl: appBackgroundObjectUrl const { uiFontFamilyPreview } = useUiFontFamilyPreview(); const savedSqlStore = useSavedSqlStore(); const promptTemplateStore = usePromptTemplateStore(); +let pluginTitleLocaleGeneration = 0; +watch(appLocale, async (locale) => { + const generation = ++pluginTitleLocaleGeneration; + try { + const registry = createFrontendPluginRegistry(await api.listPlugins(), locale); + if (generation !== pluginTitleLocaleGeneration) return; + queryStore.localizePluginTabTitles((pluginId, contributionId, surface) => + surface === "filesystem" ? registry.listFilesystemProviders().find((entry) => entry.plugin.manifest.id === pluginId && entry.contribution.id === contributionId)?.contribution.label : registry.findUiContribution(pluginId, contributionId)?.contribution.label, + ); + } catch (error) { + console.warn("Failed to refresh localized plugin tab titles", error); + } +}); const recentConnectionIds = ref(parseRecentConnectionIds(safeLocalStorageGet(RECENT_CONNECTION_IDS_STORAGE_KEY))); connectionStore.setBeforeConnectHandler(async (config) => { const jdbcxRuntime = await ensureJdbcxRuntimeDrivers(config, api); diff --git a/apps/desktop/src/stores/__tests__/queryStore.openPluginWorkbench.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.openPluginWorkbench.spec.ts index 22884c00e4..b2648dee05 100644 --- a/apps/desktop/src/stores/__tests__/queryStore.openPluginWorkbench.spec.ts +++ b/apps/desktop/src/stores/__tests__/queryStore.openPluginWorkbench.spec.ts @@ -217,3 +217,62 @@ describe("queryStore openPluginWorkbench reuse", () => { expect(bridged?.connectionId).toBe("conn-1"); }); }); + +describe("queryStore localizePluginTabTitles", () => { + beforeEach(() => { + vi.resetModules(); + vi.unstubAllGlobals(); + vi.stubGlobal("localStorage", { + getItem: vi.fn(() => null), + setItem: vi.fn(), + removeItem: vi.fn(), + }); + vi.stubGlobal("window", { dispatchEvent: vi.fn() }); + setActivePinia(createPinia()); + }); + + it("relocalizes connectionless workbench titles on locale change, preserving session suffixes and skipping bound, renamed, or unresolvable tabs", () => { + const queryStore = useQueryStore(); + + // Two connectionless workbench tabs: bare title + session-suffixed title. + const bareId = queryStore.openPluginWorkbench("io.dbx.ssh", "workbench", { title: "Workbench", forceNew: true }); + const suffixedId = queryStore.openPluginWorkbench("io.dbx.ssh", "workbench", { title: "Workbench", forceNew: true }); + // Connection-bound session tab is titled after the connection and stays as-is. + const boundId = queryStore.openPluginWorkbench("io.dbx.ssh", "workbench", { title: "prod-server", connectionId: "conn-1", context: { connectionId: "conn-1" }, forceNew: true }); + // A contribution whose label is unavailable in the new locale keeps its title. + const unresolvableId = queryStore.openPluginWorkbench("io.dbx.mystery", "workbench", { title: "Mystery", forceNew: true }); + // An explicitly renamed tab: customTitle wins over the locale refresh. + // (Session numbering is plugin-wide per connection, so this third + // connectionless io.dbx.ssh tab is created as "Pinned (2)".) + const renamedId = queryStore.openPluginWorkbench("io.dbx.ssh", "result-view", { title: "Pinned", forceNew: true }); + const renamedTab = queryStore.tabs.find((tab) => tab.id === renamedId); + expect(renamedTab).toBeDefined(); + renamedTab!.customTitle = true; + const renamedTitleBefore = renamedTab!.title; + + queryStore.localizePluginTabTitles((pluginId, contributionId, surface) => (pluginId === "io.dbx.ssh" && contributionId === "workbench" && surface === "ui" ? "工作台" : undefined)); + + const titles = Object.fromEntries(queryStore.tabs.map((tab) => [tab.id, tab.title])); + expect(titles[bareId]).toBe("工作台"); + expect(titles[suffixedId]).toBe("工作台 (1)"); + expect(titles[boundId]).toBe("prod-server"); + expect(titles[unresolvableId]).toBe("Mystery"); + expect(titles[renamedId]).toBe(renamedTitleBefore); + }); + + it("relocalizes connectionless plugin-filesystem titles on locale change, skipping connection-bound ones", () => { + const queryStore = useQueryStore(); + + // PluginWorkbenchTab.vue opens the filesystem browse with the localized + // provider label and no connection binding; that title must follow the + // app locale like the workbench tabs do. + const freeId = queryStore.openPluginFilesystem("io.dbx.ssh", "ssh.files", { title: "Files", forceNew: true }); + const boundId = queryStore.openPluginFilesystem("io.dbx.ssh", "ssh.files", { title: "prod-server · SFTP", connectionId: "conn-1", forceNew: true }); + + queryStore.localizePluginTabTitles((pluginId, contributionId, surface) => (pluginId === "io.dbx.ssh" && contributionId === "ssh.files" && surface === "filesystem" ? "文件" : undefined)); + + const titles = Object.fromEntries(queryStore.tabs.map((tab) => [tab.id, tab.title])); + expect(titles[freeId]).toBe("文件"); + expect(titles[boundId]).toBe("prod-server · SFTP"); + }); +}); diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 5009893eac..3546660b24 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -3657,6 +3657,28 @@ export const useQueryStore = defineStore("query", () => { return registerOpenTab(tab); } + // Connectionless plugin tabs inherit their title from the localized + // contribution label (sidebar/webview workbench opens and filesystem + // browse), so a locale switch leaves them showing the previous language. + // Re-resolve them on locale change: connection-bound tabs keep the + // connection name, explicit renames (customTitle) win, and a missing + // localized label leaves the current title untouched. + function localizePluginTabTitles(resolveTitle: (pluginId: string, contributionId: string, surface: "ui" | "filesystem") => string | undefined): void { + for (const tab of tabs.value) { + const target = + tab.mode === "plugin-workbench" && tab.pluginWorkbench + ? { pluginId: tab.pluginWorkbench.pluginId, contributionId: tab.pluginWorkbench.contributionId, surface: "ui" as const } + : tab.mode === "plugin-filesystem" && tab.pluginFilesystem + ? { pluginId: tab.pluginFilesystem.pluginId, contributionId: tab.pluginFilesystem.providerId, surface: "filesystem" as const } + : undefined; + if (!target || tab.customTitle || pluginTabConnectionId(tab)) continue; + const localizedTitle = resolveTitle(target.pluginId, target.contributionId, target.surface)?.trim(); + if (!localizedTitle) continue; + const suffix = / \((\d+)\)$/.exec(tab.title)?.[0] || ""; + tab.title = `${localizedTitle}${suffix}`; + } + } + function openPluginFilesystem(pluginId: string, providerId: string, options: { title?: string; connectionId?: string; rootUri?: string; currentUri?: string; forceNew?: boolean } = {}) { if (!options.forceNew) { const existing = tabs.value.find((tab) => tab.mode === "plugin-filesystem" && tab.pluginFilesystem?.pluginId === pluginId && tab.pluginFilesystem?.providerId === providerId && tab.connectionId === (options.connectionId || "")); @@ -9133,6 +9155,7 @@ export const useQueryStore = defineStore("query", () => { openMqttAdmin, openNacosAdmin, openPluginWorkbench, + localizePluginTabTitles, openPluginFilesystem, reconnectRestoredPluginTabs, openPluginConnection,