From a8f3950436550508ba8b7472f597040d6d53810d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:23:15 +0000 Subject: [PATCH 01/11] Initial plan From be878bf5de078cf528fbdf54ba82704ee02112ee Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:39:30 +0000 Subject: [PATCH 02/11] Respect configured TypeScript locale Co-authored-by: DanielRosenwasser <972891+DanielRosenwasser@users.noreply.github.com> --- _extension/src/client.ts | 6 ++++++ _extension/src/extension.ts | 10 ++++++++++ 2 files changed, 16 insertions(+) diff --git a/_extension/src/client.ts b/_extension/src/client.ts index cbc937e06c3..beaf58067e5 100644 --- a/_extension/src/client.ts +++ b/_extension/src/client.ts @@ -34,6 +34,7 @@ import { jsTsLanguageModes, languageClientName, readNativePreviewConfig, + readUnifiedConfig, } from "./util"; import { getLanguageForUri } from "./util"; @@ -401,6 +402,11 @@ function isInsiders(): boolean { // LanguageClient subclass that lets the user control whether a failed request // surfaces an error notification, via the `js/ts.server.showFailedResponses` setting. class NativePreviewLanguageClient extends LanguageClient { + protected override getLocale(): string { + const locale = readUnifiedConfig("locale", "typescript", "locale", undefined, "auto"); + return locale && locale !== "auto" ? locale : super.getLocale(); + } + override handleFailedRequest( type: MessageSignature, token: vscode.CancellationToken | undefined, diff --git a/_extension/src/extension.ts b/_extension/src/extension.ts index d776ce76066..c74cfad5740 100644 --- a/_extension/src/extension.ts +++ b/_extension/src/extension.ts @@ -84,6 +84,16 @@ export async function activate(context: vscode.ExtensionContext): Promise | undefined; context.subscriptions.push(vscode.workspace.onDidChangeConfiguration(event => { + if ( + sessionManager.currentSession + && ( + event.affectsConfiguration("js/ts.locale") + || event.affectsConfiguration("typescript.locale") + ) + ) { + void sessionManager.restart(context); + } + if ( event.affectsConfiguration("typescript.experimental.useTsgo") || event.affectsConfiguration("js/ts.experimental.useTsgo") From 1147f01fbf00f77b1447399721b587c764d42e4c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:33:22 +0000 Subject: [PATCH 03/11] Coalesce locale and useTsgo config change handlers to prevent overlapping restarts Co-authored-by: DanielRosenwasser <972891+DanielRosenwasser@users.noreply.github.com> --- _extension/src/extension.ts | 37 ++++++++++++++++++++++++++----------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/_extension/src/extension.ts b/_extension/src/extension.ts index c74cfad5740..196510c603b 100644 --- a/_extension/src/extension.ts +++ b/_extension/src/extension.ts @@ -83,24 +83,36 @@ export async function activate(context: vscode.ExtensionContext): Promise | undefined; + let pendingLocaleChange = false; + let pendingUseTsgoChange = false; context.subscriptions.push(vscode.workspace.onDidChangeConfiguration(event => { if ( - sessionManager.currentSession - && ( - event.affectsConfiguration("js/ts.locale") - || event.affectsConfiguration("typescript.locale") - ) + event.affectsConfiguration("js/ts.locale") + || event.affectsConfiguration("typescript.locale") ) { - void sessionManager.restart(context); + pendingLocaleChange = true; } if ( event.affectsConfiguration("typescript.experimental.useTsgo") || event.affectsConfiguration("js/ts.experimental.useTsgo") ) { - // Debounce to coalesce rapid events when both settings are updated together. - clearTimeout(configChangeTimeout); - configChangeTimeout = setTimeout(async () => { + pendingUseTsgoChange = true; + } + + if (!pendingLocaleChange && !pendingUseTsgoChange) { + return; + } + + // Debounce to coalesce rapid events when multiple settings are updated together. + clearTimeout(configChangeTimeout); + configChangeTimeout = setTimeout(async () => { + const hadLocaleChange = pendingLocaleChange; + const hadUseTsgoChange = pendingUseTsgoChange; + pendingLocaleChange = false; + pendingUseTsgoChange = false; + + if (hadUseTsgoChange) { if (needsExtHostRestartOnChange()) { const selected = await vscode.window.showInformationMessage(vscode.l10n.t("TypeScript 7 setting has changed. Restart extensions to apply changes."), vscode.l10n.t("Restart Extensions")); if (selected) { @@ -115,8 +127,11 @@ export async function activate(context: vscode.ExtensionContext): Promise clearTimeout(configChangeTimeout) }); From cc0df5adfcdf6e4c41479244d898832268528990 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:12:46 +0000 Subject: [PATCH 04/11] Plumb locale through Client instead of reading config in getLocale() override Co-authored-by: DanielRosenwasser <972891+DanielRosenwasser@users.noreply.github.com> --- _extension/src/client.ts | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/_extension/src/client.ts b/_extension/src/client.ts index beaf58067e5..6aade2b77aa 100644 --- a/_extension/src/client.ts +++ b/_extension/src/client.ts @@ -195,11 +195,15 @@ export class Client implements vscode.Disposable { // level changed between construction and start. this.clientOptions.initializationOptions.logVerbosity = this.outputChannel.logLevel; + const rawLocale = readUnifiedConfig("locale", "typescript", "locale", undefined, "auto"); + const locale = rawLocale && rawLocale !== "auto" ? rawLocale : undefined; + this.client = new NativePreviewLanguageClient( "js/ts", languageClientName, serverOptions, this.clientOptions, + locale, ); // Register a static feature to advertise verbosityLevel support in hover capabilities. @@ -402,9 +406,21 @@ function isInsiders(): boolean { // LanguageClient subclass that lets the user control whether a failed request // surfaces an error notification, via the `js/ts.server.showFailedResponses` setting. class NativePreviewLanguageClient extends LanguageClient { + private readonly _locale: string | undefined; + + constructor( + id: string, + name: string, + serverOptions: ServerOptions, + clientOptions: LanguageClientOptions, + locale: string | undefined, + ) { + super(id, name, serverOptions, clientOptions); + this._locale = locale; + } + protected override getLocale(): string { - const locale = readUnifiedConfig("locale", "typescript", "locale", undefined, "auto"); - return locale && locale !== "auto" ? locale : super.getLocale(); + return this._locale ?? super.getLocale(); } override handleFailedRequest( From e961a387bf982f6941b31052c96f9d3329fde5ae Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:10:09 +0000 Subject: [PATCH 05/11] Plumb locale through project.Client interface; update locale dynamically from workspace configuration Co-authored-by: DanielRosenwasser <972891+DanielRosenwasser@users.noreply.github.com> --- _extension/src/extension.ts | 17 ++------- internal/lsp/server.go | 34 ++++++++++++++++++ internal/project/client.go | 5 +++ internal/project/extendedconfigcache_test.go | 3 ++ internal/project/session.go | 4 +++ .../projecttestutil/clientmock_generated.go | 35 +++++++++++++++++++ 6 files changed, 83 insertions(+), 15 deletions(-) diff --git a/_extension/src/extension.ts b/_extension/src/extension.ts index 196510c603b..2e8d632af69 100644 --- a/_extension/src/extension.ts +++ b/_extension/src/extension.ts @@ -83,16 +83,8 @@ export async function activate(context: vscode.ExtensionContext): Promise | undefined; - let pendingLocaleChange = false; let pendingUseTsgoChange = false; context.subscriptions.push(vscode.workspace.onDidChangeConfiguration(event => { - if ( - event.affectsConfiguration("js/ts.locale") - || event.affectsConfiguration("typescript.locale") - ) { - pendingLocaleChange = true; - } - if ( event.affectsConfiguration("typescript.experimental.useTsgo") || event.affectsConfiguration("js/ts.experimental.useTsgo") @@ -100,16 +92,14 @@ export async function activate(context: vscode.ExtensionContext): Promise { - const hadLocaleChange = pendingLocaleChange; const hadUseTsgoChange = pendingUseTsgoChange; - pendingLocaleChange = false; pendingUseTsgoChange = false; if (hadUseTsgoChange) { @@ -128,9 +118,6 @@ export async function activate(context: vscode.ExtensionContext): Promise clearTimeout(configChangeTimeout) }); diff --git a/internal/lsp/server.go b/internal/lsp/server.go index 8a0b7214c9d..fab04549724 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -177,6 +177,9 @@ type Server struct { clientCapabilities lsproto.ResolvedClientCapabilities positionEncoding lsproto.PositionEncodingKind locale locale.Locale + // initLocale is the locale resolved from the initialize request; it is + // used as the fallback when the user's locale preference is "auto". + initLocale locale.Locale watchEnabled bool telemetryEnabled bool @@ -362,6 +365,11 @@ func (s *Server) ProgressFinish(message *diagnostics.Message, args ...any) { } } +// GetLocale implements project.Client. +func (s *Server) GetLocale() locale.Locale { + return s.locale +} + func (s *Server) RequestConfiguration(ctx context.Context) (lsutil.UserPreferences, error) { caps := lsproto.GetClientCapabilities(ctx) if !caps.Workspace.Configuration { @@ -1057,6 +1065,7 @@ func (s *Server) handleInitialize(ctx context.Context, params *lsproto.Initializ if s.initializeParams.Locale != nil { s.locale, _ = locale.Parse(*s.initializeParams.Locale) } + s.initLocale = s.locale if s.startWatchdog != nil && params.ProcessId.Integer != nil { s.startWatchdog(int(*params.ProcessId.Integer)) @@ -1317,10 +1326,35 @@ func (s *Server) handleDidChangeWorkspaceConfiguration(ctx context.Context, para return nil } else if settings, ok := params.Settings.(map[string]any); ok { s.session.Configure(lsutil.ParseUserPreferences(settings)) + s.locale = s.localeFromSettings(settings) } return nil } +// localeFromSettings extracts the diagnostic locale from a workspace configuration +// settings map. When the locale is absent, empty, or "auto", it falls back to +// the locale negotiated during the initialize handshake (initLocale), which +// itself already resolved "auto" to the editor's display language. +func (s *Server) localeFromSettings(settings map[string]any) locale.Locale { + // The extension's sendNotificationMiddleware sends the same merged config for + // every section (js/ts > typescript > javascript), so checking any one section + // is sufficient. We use "js/ts" as the canonical source. + for _, section := range []string{"js/ts", "typescript"} { + sectionMap, ok := settings[section].(map[string]any) + if !ok { + continue + } + localeStr, ok := sectionMap["locale"].(string) + if !ok || localeStr == "" || localeStr == "auto" { + break + } + if parsed, ok := locale.Parse(localeStr); ok { + return parsed + } + } + return s.initLocale +} + func (s *Server) handleDidOpen(ctx context.Context, params *lsproto.DidOpenTextDocumentParams) error { s.session.DidOpenFile(ctx, params.TextDocument.Uri, params.TextDocument.Version, params.TextDocument.Text, params.TextDocument.LanguageId) return nil diff --git a/internal/project/client.go b/internal/project/client.go index 98ff57b6c3d..8ca4c24f520 100644 --- a/internal/project/client.go +++ b/internal/project/client.go @@ -4,6 +4,7 @@ import ( "context" "github.com/microsoft/typescript-go/internal/diagnostics" + "github.com/microsoft/typescript-go/internal/locale" "github.com/microsoft/typescript-go/internal/lsp/lsproto" ) @@ -18,4 +19,8 @@ type Client interface { ProgressFinish(message *diagnostics.Message, args ...any) SendTelemetry(ctx context.Context, telemetry lsproto.TelemetryEvent) error IsActive() bool + // GetLocale returns the current display locale for diagnostic messages. + // Implementations should return the most up-to-date locale; the value may + // change when the user updates their locale preference at runtime. + GetLocale() locale.Locale } diff --git a/internal/project/extendedconfigcache_test.go b/internal/project/extendedconfigcache_test.go index dd4a7a1493e..6e07c0a3d9c 100644 --- a/internal/project/extendedconfigcache_test.go +++ b/internal/project/extendedconfigcache_test.go @@ -7,6 +7,7 @@ import ( "github.com/microsoft/typescript-go/internal/bundled" "github.com/microsoft/typescript-go/internal/diagnostics" + "github.com/microsoft/typescript-go/internal/locale" "github.com/microsoft/typescript-go/internal/lsp/lsproto" "github.com/microsoft/typescript-go/internal/project/logging" "github.com/microsoft/typescript-go/internal/tsoptions" @@ -44,6 +45,8 @@ func (noopClient) SendTelemetry(ctx context.Context, telemetry lsproto.Telemetry func (noopClient) IsActive() bool { return true } +func (noopClient) GetLocale() locale.Locale { return locale.Default } + // TestExtendedConfigCacheOwnership tests the invariant that each ExtendedSourceFile // of a config in the ConfigFileRegistry is owned exactly once per snapshot that // references it, and released exactly once when that snapshot is removed. diff --git a/internal/project/session.go b/internal/project/session.go index d8776aea586..1c9e1830069 100644 --- a/internal/project/session.go +++ b/internal/project/session.go @@ -1683,6 +1683,10 @@ func (s *Session) publishProjectDiagnostics(ctx context.Context, configFilePath if s.Config().EnableValidation.IsFalse() { diagnostics = nil } + // Inject the current locale so that push-diagnostic messages are formatted + // in the user's chosen language, even when ctx is the background context + // (which does not have a locale set by the LSP dispatch loop). + ctx = locale.WithLocale(ctx, s.client.GetLocale()) lspDiagnostics := make([]*lsproto.Diagnostic, 0, len(diagnostics)) for _, diag := range diagnostics { lspDiagnostics = append(lspDiagnostics, lsconv.DiagnosticToLSPPush(ctx, converters, diag)) diff --git a/internal/testutil/projecttestutil/clientmock_generated.go b/internal/testutil/projecttestutil/clientmock_generated.go index 7a1b97c0365..e5cc64b5a13 100644 --- a/internal/testutil/projecttestutil/clientmock_generated.go +++ b/internal/testutil/projecttestutil/clientmock_generated.go @@ -8,6 +8,7 @@ import ( "sync" "github.com/microsoft/typescript-go/internal/diagnostics" + "github.com/microsoft/typescript-go/internal/locale" "github.com/microsoft/typescript-go/internal/lsp/lsproto" "github.com/microsoft/typescript-go/internal/project" ) @@ -22,6 +23,9 @@ var _ project.Client = &ClientMock{} // // // make and configure a mocked project.Client // mockedClient := &ClientMock{ +// GetLocaleFunc: func() locale.Locale { +// panic("mock out the GetLocale method") +// }, // IsActiveFunc: func() bool { // panic("mock out the IsActive method") // }, @@ -59,6 +63,9 @@ var _ project.Client = &ClientMock{} // // } type ClientMock struct { + // GetLocaleFunc mocks the GetLocale method. + GetLocaleFunc func() locale.Locale + // IsActiveFunc mocks the IsActive method. IsActiveFunc func() bool @@ -91,6 +98,8 @@ type ClientMock struct { // calls tracks calls to the methods. calls struct { + // GetLocale holds details about calls to the GetLocale method. + GetLocale []struct{} // IsActive holds details about calls to the IsActive method. IsActive []struct{} // ProgressFinish holds details about calls to the ProgressFinish method. @@ -153,6 +162,7 @@ type ClientMock struct { Watchers []*lsproto.FileSystemWatcher } } + lockGetLocale sync.RWMutex lockIsActive sync.RWMutex lockProgressFinish sync.RWMutex lockProgressStart sync.RWMutex @@ -165,6 +175,31 @@ type ClientMock struct { lockWatchFiles sync.RWMutex } +// GetLocale calls GetLocaleFunc. +func (mock *ClientMock) GetLocale() locale.Locale { + callInfo := struct{}{} + mock.lockGetLocale.Lock() + mock.calls.GetLocale = append(mock.calls.GetLocale, callInfo) + mock.lockGetLocale.Unlock() + if mock.GetLocaleFunc == nil { + var localeOut locale.Locale + return localeOut + } + return mock.GetLocaleFunc() +} + +// GetLocaleCalls gets all the calls that were made to GetLocale. +// Check the length with: +// +// len(mockedClient.GetLocaleCalls()) +func (mock *ClientMock) GetLocaleCalls() []struct{} { + var calls []struct{} + mock.lockGetLocale.RLock() + calls = mock.calls.GetLocale + mock.lockGetLocale.RUnlock() + return calls +} + // IsActive calls IsActiveFunc. func (mock *ClientMock) IsActive() bool { callInfo := struct{}{} From e83a1d7df071171a8ecf5cec0c5af2eabe598d43 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:35:40 +0000 Subject: [PATCH 06/11] Route locale updates through project preferences Co-authored-by: jakebailey <5341706+jakebailey@users.noreply.github.com> --- _extension/src/client.ts | 22 --------- _extension/src/extension.ts | 22 ++------- internal/ls/lsutil/userpreferences.go | 1 + internal/ls/lsutil/userpreferences_test.go | 16 +++++++ internal/lsp/server.go | 47 ++++++++----------- internal/project/client.go | 4 +- internal/project/extendedconfigcache_test.go | 2 + internal/project/session.go | 4 ++ internal/project/session_test.go | 13 +++++ .../projecttestutil/clientmock_generated.go | 44 +++++++++++++++++ 10 files changed, 107 insertions(+), 68 deletions(-) diff --git a/_extension/src/client.ts b/_extension/src/client.ts index 6aade2b77aa..cbc937e06c3 100644 --- a/_extension/src/client.ts +++ b/_extension/src/client.ts @@ -34,7 +34,6 @@ import { jsTsLanguageModes, languageClientName, readNativePreviewConfig, - readUnifiedConfig, } from "./util"; import { getLanguageForUri } from "./util"; @@ -195,15 +194,11 @@ export class Client implements vscode.Disposable { // level changed between construction and start. this.clientOptions.initializationOptions.logVerbosity = this.outputChannel.logLevel; - const rawLocale = readUnifiedConfig("locale", "typescript", "locale", undefined, "auto"); - const locale = rawLocale && rawLocale !== "auto" ? rawLocale : undefined; - this.client = new NativePreviewLanguageClient( "js/ts", languageClientName, serverOptions, this.clientOptions, - locale, ); // Register a static feature to advertise verbosityLevel support in hover capabilities. @@ -406,23 +401,6 @@ function isInsiders(): boolean { // LanguageClient subclass that lets the user control whether a failed request // surfaces an error notification, via the `js/ts.server.showFailedResponses` setting. class NativePreviewLanguageClient extends LanguageClient { - private readonly _locale: string | undefined; - - constructor( - id: string, - name: string, - serverOptions: ServerOptions, - clientOptions: LanguageClientOptions, - locale: string | undefined, - ) { - super(id, name, serverOptions, clientOptions); - this._locale = locale; - } - - protected override getLocale(): string { - return this._locale ?? super.getLocale(); - } - override handleFailedRequest( type: MessageSignature, token: vscode.CancellationToken | undefined, diff --git a/_extension/src/extension.ts b/_extension/src/extension.ts index 2e8d632af69..d776ce76066 100644 --- a/_extension/src/extension.ts +++ b/_extension/src/extension.ts @@ -83,26 +83,14 @@ export async function activate(context: vscode.ExtensionContext): Promise | undefined; - let pendingUseTsgoChange = false; context.subscriptions.push(vscode.workspace.onDidChangeConfiguration(event => { if ( event.affectsConfiguration("typescript.experimental.useTsgo") || event.affectsConfiguration("js/ts.experimental.useTsgo") ) { - pendingUseTsgoChange = true; - } - - if (!pendingUseTsgoChange) { - return; - } - - // Debounce to coalesce rapid events when both settings are updated together. - clearTimeout(configChangeTimeout); - configChangeTimeout = setTimeout(async () => { - const hadUseTsgoChange = pendingUseTsgoChange; - pendingUseTsgoChange = false; - - if (hadUseTsgoChange) { + // Debounce to coalesce rapid events when both settings are updated together. + clearTimeout(configChangeTimeout); + configChangeTimeout = setTimeout(async () => { if (needsExtHostRestartOnChange()) { const selected = await vscode.window.showInformationMessage(vscode.l10n.t("TypeScript 7 setting has changed. Restart extensions to apply changes."), vscode.l10n.t("Restart Extensions")); if (selected) { @@ -117,8 +105,8 @@ export async function activate(context: vscode.ExtensionContext): Promise clearTimeout(configChangeTimeout) }); diff --git a/internal/ls/lsutil/userpreferences.go b/internal/ls/lsutil/userpreferences.go index 8fc5146d634..06417371a2e 100644 --- a/internal/ls/lsutil/userpreferences.go +++ b/internal/ls/lsutil/userpreferences.go @@ -176,6 +176,7 @@ type UserPreferences struct { DisableLineTextInReferences core.Tristate `raw:"disableLineTextInReferences"` // !!! DisplayPartsForJSDoc core.Tristate `raw:"displayPartsForJSDoc"` // !!! ReportStyleChecksAsWarnings core.Tristate `raw:"reportStyleChecksAsWarnings" config:"reportStyleChecksAsWarnings"` + Locale string `config:"locale"` // ------- ATA ------- diff --git a/internal/ls/lsutil/userpreferences_test.go b/internal/ls/lsutil/userpreferences_test.go index 153f737b345..c3cd98445a0 100644 --- a/internal/ls/lsutil/userpreferences_test.go +++ b/internal/ls/lsutil/userpreferences_test.go @@ -179,6 +179,7 @@ func TestUserPreferencesParseUnstable(t *testing.T) { "maximumHoverLength": 100, "allowRenameOfImportPath": true } + }`, expected: UserPreferences{ DisableSuggestions: core.TSTrue, @@ -424,6 +425,21 @@ func TestUserPreferencesParseUnstable(t *testing.T) { } } +func TestUserPreferencesLocale(t *testing.T) { + t.Parallel() + + prefs := ParseUserPreferences(map[string]any{ + "typescript": map[string]any{ + "locale": "de", + }, + "js/ts": map[string]any{ + "locale": "fr", + }, + }) + + assert.Equal(t, prefs.Locale, "fr") +} + func TestUserPreferencesReportStyleChecksAsWarnings(t *testing.T) { t.Parallel() diff --git a/internal/lsp/server.go b/internal/lsp/server.go index fab04549724..16a5c8428af 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -176,6 +176,7 @@ type Server struct { initializationOptions *lsproto.InitializationOptions clientCapabilities lsproto.ResolvedClientCapabilities positionEncoding lsproto.PositionEncodingKind + localeMu sync.RWMutex locale locale.Locale // initLocale is the locale resolved from the initialize request; it is // used as the fallback when the user's locale preference is "auto". @@ -367,9 +368,26 @@ func (s *Server) ProgressFinish(message *diagnostics.Message, args ...any) { // GetLocale implements project.Client. func (s *Server) GetLocale() locale.Locale { + s.localeMu.RLock() + defer s.localeMu.RUnlock() return s.locale } +// SetLocale implements project.Client. +func (s *Server) SetLocale(localeString string) { + newLocale := s.initLocale + if localeString != "auto" { + parsed, ok := locale.Parse(localeString) + if !ok { + return + } + newLocale = parsed + } + s.localeMu.Lock() + s.locale = newLocale + s.localeMu.Unlock() +} + func (s *Server) RequestConfiguration(ctx context.Context) (lsutil.UserPreferences, error) { caps := lsproto.GetClientCapabilities(ctx) if !caps.Workspace.Configuration { @@ -547,7 +565,7 @@ func (s *Server) dispatchLoop(ctx context.Context) error { } s.lastRequestTimeMs.Store(time.Now().UnixMilli()) - requestCtx := locale.WithLocale(ctx, s.locale) + requestCtx := locale.WithLocale(ctx, s.GetLocale()) var cancel context.CancelFunc if req.ID != nil { requestCtx, cancel = context.WithCancel(core.WithRequestID(requestCtx, req.ID.String())) @@ -1263,7 +1281,7 @@ func (s *Server) handleInitialized(ctx context.Context, params *lsproto.Initiali TelemetryEnabled: enableTelemetry, DebounceDelay: 500 * time.Millisecond, PushDiagnosticsEnabled: !disablePushDiagnostics, - Locale: s.locale, + Locale: s.GetLocale(), }, FS: s.fs, Logger: s.logger, @@ -1326,35 +1344,10 @@ func (s *Server) handleDidChangeWorkspaceConfiguration(ctx context.Context, para return nil } else if settings, ok := params.Settings.(map[string]any); ok { s.session.Configure(lsutil.ParseUserPreferences(settings)) - s.locale = s.localeFromSettings(settings) } return nil } -// localeFromSettings extracts the diagnostic locale from a workspace configuration -// settings map. When the locale is absent, empty, or "auto", it falls back to -// the locale negotiated during the initialize handshake (initLocale), which -// itself already resolved "auto" to the editor's display language. -func (s *Server) localeFromSettings(settings map[string]any) locale.Locale { - // The extension's sendNotificationMiddleware sends the same merged config for - // every section (js/ts > typescript > javascript), so checking any one section - // is sufficient. We use "js/ts" as the canonical source. - for _, section := range []string{"js/ts", "typescript"} { - sectionMap, ok := settings[section].(map[string]any) - if !ok { - continue - } - localeStr, ok := sectionMap["locale"].(string) - if !ok || localeStr == "" || localeStr == "auto" { - break - } - if parsed, ok := locale.Parse(localeStr); ok { - return parsed - } - } - return s.initLocale -} - func (s *Server) handleDidOpen(ctx context.Context, params *lsproto.DidOpenTextDocumentParams) error { s.session.DidOpenFile(ctx, params.TextDocument.Uri, params.TextDocument.Version, params.TextDocument.Text, params.TextDocument.LanguageId) return nil diff --git a/internal/project/client.go b/internal/project/client.go index 8ca4c24f520..48b3f9ddd5e 100644 --- a/internal/project/client.go +++ b/internal/project/client.go @@ -19,8 +19,8 @@ type Client interface { ProgressFinish(message *diagnostics.Message, args ...any) SendTelemetry(ctx context.Context, telemetry lsproto.TelemetryEvent) error IsActive() bool + // SetLocale updates the locale used for diagnostic messages. + SetLocale(locale string) // GetLocale returns the current display locale for diagnostic messages. - // Implementations should return the most up-to-date locale; the value may - // change when the user updates their locale preference at runtime. GetLocale() locale.Locale } diff --git a/internal/project/extendedconfigcache_test.go b/internal/project/extendedconfigcache_test.go index 6e07c0a3d9c..dec5756e7df 100644 --- a/internal/project/extendedconfigcache_test.go +++ b/internal/project/extendedconfigcache_test.go @@ -47,6 +47,8 @@ func (noopClient) IsActive() bool { return true } func (noopClient) GetLocale() locale.Locale { return locale.Default } +func (noopClient) SetLocale(locale string) {} + // TestExtendedConfigCacheOwnership tests the invariant that each ExtendedSourceFile // of a config in the ConfigFileRegistry is owned exactly once per snapshot that // references it, and released exactly once when that snapshot is removed. diff --git a/internal/project/session.go b/internal/project/session.go index 1c9e1830069..f39a7994c71 100644 --- a/internal/project/session.go +++ b/internal/project/session.go @@ -280,6 +280,10 @@ func (s *Session) Configure(config lsutil.UserPreferences) { oldConfig := s.workspaceUserPreferences s.workspaceUserPreferences = config + if config.Locale != "" { + s.client.SetLocale(config.Locale) + } + // Tell the client to re-request certain commands depending on user preference changes. s.refreshInlayHintsIfNeeded(oldConfig, config) s.refreshCodeLensIfNeeded(oldConfig, config) diff --git a/internal/project/session_test.go b/internal/project/session_test.go index 79208dfdf49..d68cf794c30 100644 --- a/internal/project/session_test.go +++ b/internal/project/session_test.go @@ -1406,6 +1406,19 @@ export const value = content;`, assert.Equal(t, len(inlayHintsRefreshCalls), 1, "expected one RefreshInlayHints call after inlay hints preference change") }) + t.Run("sets locale when configured", func(t *testing.T) { + t.Parallel() + session, utils := projecttestutil.Setup(map[string]any{}) + prefs := lsutil.NewDefaultUserPreferences() + prefs.Locale = "fr" + + session.Configure(prefs) + + setLocaleCalls := utils.Client().SetLocaleCalls() + assert.Equal(t, len(setLocaleCalls), 1) + assert.Equal(t, setLocaleCalls[0].LocaleMoqParam, "fr") + }) + t.Run("schedules diagnostics refresh when reportStyleChecksAsWarnings changes", func(t *testing.T) { t.Parallel() files := map[string]any{ diff --git a/internal/testutil/projecttestutil/clientmock_generated.go b/internal/testutil/projecttestutil/clientmock_generated.go index e5cc64b5a13..140a3f8dcc6 100644 --- a/internal/testutil/projecttestutil/clientmock_generated.go +++ b/internal/testutil/projecttestutil/clientmock_generated.go @@ -50,6 +50,9 @@ var _ project.Client = &ClientMock{} // SendTelemetryFunc: func(ctx context.Context, telemetry lsproto.TelemetryEvent) error { // panic("mock out the SendTelemetry method") // }, +// SetLocaleFunc: func(localeMoqParam string) { +// panic("mock out the SetLocale method") +// }, // UnwatchFilesFunc: func(ctx context.Context, id project.WatcherID) error { // panic("mock out the UnwatchFiles method") // }, @@ -90,6 +93,9 @@ type ClientMock struct { // SendTelemetryFunc mocks the SendTelemetry method. SendTelemetryFunc func(ctx context.Context, telemetry lsproto.TelemetryEvent) error + // SetLocaleFunc mocks the SetLocale method. + SetLocaleFunc func(localeMoqParam string) + // UnwatchFilesFunc mocks the UnwatchFiles method. UnwatchFilesFunc func(ctx context.Context, id project.WatcherID) error @@ -145,6 +151,11 @@ type ClientMock struct { // Telemetry is the telemetry argument value. Telemetry lsproto.TelemetryEvent } + // SetLocale holds details about calls to the SetLocale method. + SetLocale []struct { + // LocaleMoqParam is the localeMoqParam argument value. + LocaleMoqParam string + } // UnwatchFiles holds details about calls to the UnwatchFiles method. UnwatchFiles []struct { // Ctx is the ctx argument value. @@ -171,6 +182,7 @@ type ClientMock struct { lockRefreshDiagnostics sync.RWMutex lockRefreshInlayHints sync.RWMutex lockSendTelemetry sync.RWMutex + lockSetLocale sync.RWMutex lockUnwatchFiles sync.RWMutex lockWatchFiles sync.RWMutex } @@ -470,6 +482,38 @@ func (mock *ClientMock) SendTelemetryCalls() []struct { return calls } +// SetLocale calls SetLocaleFunc. +func (mock *ClientMock) SetLocale(localeMoqParam string) { + callInfo := struct { + LocaleMoqParam string + }{ + LocaleMoqParam: localeMoqParam, + } + mock.lockSetLocale.Lock() + mock.calls.SetLocale = append(mock.calls.SetLocale, callInfo) + mock.lockSetLocale.Unlock() + if mock.SetLocaleFunc == nil { + return + } + mock.SetLocaleFunc(localeMoqParam) +} + +// SetLocaleCalls gets all the calls that were made to SetLocale. +// Check the length with: +// +// len(mockedClient.SetLocaleCalls()) +func (mock *ClientMock) SetLocaleCalls() []struct { + LocaleMoqParam string +} { + var calls []struct { + LocaleMoqParam string + } + mock.lockSetLocale.RLock() + calls = mock.calls.SetLocale + mock.lockSetLocale.RUnlock() + return calls +} + // UnwatchFiles calls UnwatchFilesFunc. func (mock *ClientMock) UnwatchFiles(ctx context.Context, id project.WatcherID) error { callInfo := struct { From 2fc75ce6cee4bd3b83b0afd296f8db48a8997b25 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:57:54 +0000 Subject: [PATCH 07/11] Remove unused session locale option Co-authored-by: jakebailey <5341706+jakebailey@users.noreply.github.com> --- internal/lsp/server.go | 1 - internal/project/session.go | 1 - 2 files changed, 2 deletions(-) diff --git a/internal/lsp/server.go b/internal/lsp/server.go index 16a5c8428af..6fed3a9783a 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -1281,7 +1281,6 @@ func (s *Server) handleInitialized(ctx context.Context, params *lsproto.Initiali TelemetryEnabled: enableTelemetry, DebounceDelay: 500 * time.Millisecond, PushDiagnosticsEnabled: !disablePushDiagnostics, - Locale: s.GetLocale(), }, FS: s.fs, Logger: s.logger, diff --git a/internal/project/session.go b/internal/project/session.go index f39a7994c71..98ad4d1376e 100644 --- a/internal/project/session.go +++ b/internal/project/session.go @@ -62,7 +62,6 @@ type SessionOptions struct { TelemetryEnabled bool PushDiagnosticsEnabled bool DebounceDelay time.Duration - Locale locale.Locale CheckerPoolOptions CheckerPoolOptions } From d5b8c0e9a5054e1d8bb573b7c5a662d03fac004a Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:04:15 -0700 Subject: [PATCH 08/11] Apply suggestion from @jakebailey --- internal/ls/lsutil/userpreferences_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/ls/lsutil/userpreferences_test.go b/internal/ls/lsutil/userpreferences_test.go index c3cd98445a0..29d7ba9eec2 100644 --- a/internal/ls/lsutil/userpreferences_test.go +++ b/internal/ls/lsutil/userpreferences_test.go @@ -179,7 +179,6 @@ func TestUserPreferencesParseUnstable(t *testing.T) { "maximumHoverLength": 100, "allowRenameOfImportPath": true } - }`, expected: UserPreferences{ DisableSuggestions: core.TSTrue, From 2f11d43a15576e2ac175a516e0b95713c08c10f6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Jul 2026 00:17:31 +0000 Subject: [PATCH 09/11] Centralize localized session background contexts Co-authored-by: jakebailey <5341706+jakebailey@users.noreply.github.com> --- internal/project/session.go | 38 ++++++++++++++++---------------- internal/project/session_test.go | 21 ++++++++++++++++++ 2 files changed, 40 insertions(+), 19 deletions(-) diff --git a/internal/project/session.go b/internal/project/session.go index 98ad4d1376e..197d2c36ec1 100644 --- a/internal/project/session.go +++ b/internal/project/session.go @@ -267,6 +267,10 @@ func (s *Session) Config() lsutil.UserPreferences { return s.workspaceUserPreferences } +func (s *Session) backgroundContext() context.Context { + return locale.WithLocale(s.backgroundCtx, s.client.GetLocale()) +} + // Trace implements module.ResolutionHost func (s *Session) Trace(msg string) { panic("ATA module resolution should not use tracing") @@ -407,7 +411,7 @@ func (s *Session) ScheduleDiagnosticsRefresh() { } // Create a new cancellable context for the debounce task - debounceCtx, cancel := context.WithCancel(s.backgroundCtx) + debounceCtx, cancel := context.WithCancel(s.backgroundContext()) s.diagnosticsRefreshGeneration++ generation := s.diagnosticsRefreshGeneration s.diagnosticsRefreshCancel = cancel @@ -436,7 +440,7 @@ func (s *Session) ScheduleDiagnosticsRefresh() { if s.options.LoggingEnabled { s.logger.Log("Running scheduled diagnostics refresh") } - if err := s.client.RefreshDiagnostics(s.backgroundCtx); err != nil && s.options.LoggingEnabled { + if err := s.client.RefreshDiagnostics(s.backgroundContext()); err != nil && s.options.LoggingEnabled { s.logger.Logf("Error refreshing diagnostics: %v", err) } }) @@ -468,7 +472,7 @@ func (s *Session) ScheduleSnapshotUpdate(reason UpdateReason) { } // Create a new cancellable context for the debounce task - debounceCtx, cancel := context.WithCancel(s.backgroundCtx) + debounceCtx, cancel := context.WithCancel(s.backgroundContext()) s.scheduledSnapshotUpdateGeneration++ generation := s.scheduledSnapshotUpdateGeneration s.scheduledSnapshotUpdateCancel = cancel @@ -556,7 +560,7 @@ func (s *Session) scheduleIdleCacheClean() { defer s.snapshotUpdateMu.Unlock() s.cancelScheduledSnapshotUpdate() - ctx := s.backgroundCtx + ctx := s.backgroundContext() fileChanges, overlays, ataChanges, newConfig := s.flushChanges(ctx) s.UpdateSnapshot(ctx, overlays, SnapshotChange{ reason: UpdateReasonIdleCleanDiskCache, @@ -587,7 +591,7 @@ func (s *Session) StartPerformanceTelemetry() { if !s.options.TelemetryEnabled { return } - ctx, cancel := context.WithCancel(s.backgroundCtx) + ctx, cancel := context.WithCancel(s.backgroundContext()) s.performanceTelemetryCancel = cancel s.backgroundQueue.Enqueue(ctx, func(ctx context.Context) { ticker := time.NewTicker(performanceTelemetryInterval) @@ -739,7 +743,7 @@ func (s *Session) sendProjectInfoTelemetryForNewProjects(oldSnapshot *Snapshot, if !s.options.TelemetryEnabled { return } - ctx := s.backgroundCtx + ctx := s.backgroundContext() collections.DiffOrderedMaps( oldSnapshot.ProjectCollection.ProjectsByPath(), newSnapshot.ProjectCollection.ProjectsByPath(), @@ -1173,7 +1177,7 @@ func (s *Session) adoptSnapshotChangeInBackground(baseSnapshot, newSnapshot *Sna // The clone's initial ref (1) is transferred to adoptSnapshotChange, // which will either promote it as the session's current snapshot or // release it if the session has moved on. - s.backgroundQueue.Enqueue(s.backgroundCtx, func(ctx context.Context) { + s.backgroundQueue.Enqueue(s.backgroundContext(), func(ctx context.Context) { s.adoptSnapshotChange(baseSnapshot, newSnapshot) }) } @@ -1248,7 +1252,7 @@ func (s *Session) updateSnapshot(ctx context.Context, overlays map[tspath.Path]* // Enqueue logging, watch updates, and diagnostic refresh tasks // !!! userPreferences/configuration updates - s.backgroundQueue.Enqueue(s.backgroundCtx, func(ctx context.Context) { + s.backgroundQueue.Enqueue(s.backgroundContext(), func(ctx context.Context) { if s.options.LoggingEnabled { s.logger.Logf("Adopted snapshot %d (parent %d) as current session snapshot (replacing %d)", newSnapshot.id, newSnapshot.parentId, oldSnapshot.id) s.logger.Log(newSnapshot.builderLogs.String()) @@ -1358,7 +1362,7 @@ func updateWatch[T any](ctx context.Context, session *Session, logger logging.Lo func (s *Session) updateWatches(oldSnapshot *Snapshot, newSnapshot *Snapshot) error { var errors []error start := time.Now() - ctx := s.backgroundCtx + ctx := s.backgroundContext() core.DiffMapsFunc( oldSnapshot.ConfigFileRegistry.configs, newSnapshot.ConfigFileRegistry.configs, @@ -1580,7 +1584,7 @@ func (s *Session) NpmInstall(cwd string, npmInstallArgs []string) ([]byte, error func (s *Session) refreshInlayHintsIfNeeded(oldPrefs lsutil.UserPreferences, newPrefs lsutil.UserPreferences) { if oldPrefs.InlayHints != newPrefs.InlayHints { - if err := s.client.RefreshInlayHints(s.backgroundCtx); err != nil && s.options.LoggingEnabled { + if err := s.client.RefreshInlayHints(s.backgroundContext()); err != nil && s.options.LoggingEnabled { s.logger.Logf("Error refreshing inlay hints: %v", err) } } @@ -1588,7 +1592,7 @@ func (s *Session) refreshInlayHintsIfNeeded(oldPrefs lsutil.UserPreferences, new func (s *Session) refreshCodeLensIfNeeded(oldPrefs lsutil.UserPreferences, newPrefs lsutil.UserPreferences) { if oldPrefs.CodeLens != newPrefs.CodeLens { - if err := s.client.RefreshCodeLens(s.backgroundCtx); err != nil && s.options.LoggingEnabled { + if err := s.client.RefreshCodeLens(s.backgroundContext()); err != nil && s.options.LoggingEnabled { s.logger.Logf("Error refreshing code lens: %v", err) } } @@ -1620,13 +1624,13 @@ func (s *Session) publishProgramDiagnostics(oldSnapshot *Snapshot, newSnapshot * } for configFilePath, oldProject := range oldSnapshot.ProjectCollection.ProjectsByPath().Entries() { if oldProject.Kind == KindConfigured && oldSnapshot.ProjectCollection.GetOpenConfiguredProjects().Has(configFilePath) { - s.publishProjectDiagnostics(s.backgroundCtx, string(configFilePath), nil, oldSnapshot.converters) + s.publishProjectDiagnostics(s.backgroundContext(), string(configFilePath), nil, oldSnapshot.converters) } } return } - ctx := s.backgroundCtx + ctx := s.backgroundContext() oldProjects := oldSnapshot.ProjectCollection.ProjectsByPath() newProjects := newSnapshot.ProjectCollection.ProjectsByPath() oldOpenProjects := oldSnapshot.ProjectCollection.GetOpenConfiguredProjects() @@ -1686,10 +1690,6 @@ func (s *Session) publishProjectDiagnostics(ctx context.Context, configFilePath if s.Config().EnableValidation.IsFalse() { diagnostics = nil } - // Inject the current locale so that push-diagnostic messages are formatted - // in the user's chosen language, even when ctx is the background context - // (which does not have a locale set by the LSP dispatch loop). - ctx = locale.WithLocale(ctx, s.client.GetLocale()) lspDiagnostics := make([]*lsproto.Diagnostic, 0, len(diagnostics)) for _, diag := range diagnostics { lspDiagnostics = append(lspDiagnostics, lsconv.DiagnosticToLSPPush(ctx, converters, diag)) @@ -1711,7 +1711,7 @@ func (s *Session) EnqueuePublishGlobalDiagnostics() { return } if s.globalDiagPublishPending.CompareAndSwap(false, true) { - s.backgroundQueue.Enqueue(s.backgroundCtx, s.publishGlobalDiagnostics) + s.backgroundQueue.Enqueue(s.backgroundContext(), s.publishGlobalDiagnostics) } } @@ -1737,7 +1737,7 @@ func (s *Session) publishGlobalDiagnostics(ctx context.Context) { func (s *Session) triggerATAForUpdatedProjects(newSnapshot *Snapshot) { for _, project := range newSnapshot.ProjectCollection.Projects() { if project.ShouldTriggerATA(newSnapshot.ID()) { - s.backgroundQueue.Enqueue(s.backgroundCtx, func(ctx context.Context) { + s.backgroundQueue.Enqueue(s.backgroundContext(), func(ctx context.Context) { var logTree *logging.LogTree if s.options.LoggingEnabled { logTree = logging.NewLogTree("Triggering ATA for project " + project.Name()) diff --git a/internal/project/session_test.go b/internal/project/session_test.go index d68cf794c30..facd5c36312 100644 --- a/internal/project/session_test.go +++ b/internal/project/session_test.go @@ -10,6 +10,7 @@ import ( "github.com/microsoft/typescript-go/internal/bundled" "github.com/microsoft/typescript-go/internal/core" + "github.com/microsoft/typescript-go/internal/locale" "github.com/microsoft/typescript-go/internal/ls/lsutil" "github.com/microsoft/typescript-go/internal/lsp/lsproto" "github.com/microsoft/typescript-go/internal/project" @@ -1419,6 +1420,26 @@ export const value = content;`, assert.Equal(t, setLocaleCalls[0].LocaleMoqParam, "fr") }) + t.Run("adds locale to background contexts", func(t *testing.T) { + t.Parallel() + session, utils := projecttestutil.Setup(map[string]any{}) + fr, ok := locale.Parse("fr") + assert.Assert(t, ok) + utils.Client().GetLocaleFunc = func() locale.Locale { + return fr + } + utils.Client().RefreshCodeLensFunc = func(ctx context.Context) error { + assert.Equal(t, locale.FromContext(ctx), fr) + return nil + } + prefs := lsutil.NewDefaultUserPreferences() + prefs.CodeLens.ReferencesCodeLensEnabled = core.TSTrue + + session.Configure(prefs) + + assert.Equal(t, len(utils.Client().RefreshCodeLensCalls()), 1) + }) + t.Run("schedules diagnostics refresh when reportStyleChecksAsWarnings changes", func(t *testing.T) { t.Parallel() files := map[string]any{ From d14d5b6993a9c393e00acbfb848fc97ac715b4d1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Jul 2026 00:24:38 +0000 Subject: [PATCH 10/11] Handle sessions without clients in background context helper Co-authored-by: jakebailey <5341706+jakebailey@users.noreply.github.com> --- internal/project/session.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/project/session.go b/internal/project/session.go index 197d2c36ec1..9df46f61888 100644 --- a/internal/project/session.go +++ b/internal/project/session.go @@ -268,6 +268,9 @@ func (s *Session) Config() lsutil.UserPreferences { } func (s *Session) backgroundContext() context.Context { + if s.client == nil { + return s.backgroundCtx + } return locale.WithLocale(s.backgroundCtx, s.client.GetLocale()) } From 992a6cf1ccfb9616d127b563ce54c3f451084533 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Jul 2026 00:37:48 +0000 Subject: [PATCH 11/11] Refresh locale when publishing queued diagnostics Co-authored-by: jakebailey <5341706+jakebailey@users.noreply.github.com> --- internal/project/session.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/internal/project/session.go b/internal/project/session.go index 9df46f61888..8c9ed87c5d0 100644 --- a/internal/project/session.go +++ b/internal/project/session.go @@ -268,10 +268,14 @@ func (s *Session) Config() lsutil.UserPreferences { } func (s *Session) backgroundContext() context.Context { + return s.withCurrentLocale(s.backgroundCtx) +} + +func (s *Session) withCurrentLocale(ctx context.Context) context.Context { if s.client == nil { - return s.backgroundCtx + return ctx } - return locale.WithLocale(s.backgroundCtx, s.client.GetLocale()) + return locale.WithLocale(ctx, s.client.GetLocale()) } // Trace implements module.ResolutionHost @@ -1693,6 +1697,7 @@ func (s *Session) publishProjectDiagnostics(ctx context.Context, configFilePath if s.Config().EnableValidation.IsFalse() { diagnostics = nil } + ctx = s.withCurrentLocale(ctx) lspDiagnostics := make([]*lsproto.Diagnostic, 0, len(diagnostics)) for _, diag := range diagnostics { lspDiagnostics = append(lspDiagnostics, lsconv.DiagnosticToLSPPush(ctx, converters, diag))