diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index bf5af327..2ce73614 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -920,11 +920,15 @@ 'This workbook is too large or complex to preview.': '此工作簿过大或过于复杂,无法预览。', 'This workbook could not be read. The file may be damaged or not a valid .xlsx file.': '无法读取此工作簿。文件可能已损坏,或不是有效的 .xlsx 文件。', - charts: '图表', - drawings: '绘图', - 'pivot tables': '数据透视表', - 'external links': '外部链接', - macros: '宏', + // The features the preview leaves out (spreadsheet-preview.js warningText). + // Scoped keys on purpose: a bare 'charts' or 'macros' key would also + // translate a folder or case of that name (a Helm chart's charts/, a dbt + // project's macros/) in the Files panel and the case picker. + 'Spreadsheet feature: charts': '图表', + 'Spreadsheet feature: drawings': '绘图', + 'Spreadsheet feature: pivot tables': '数据透视表', + 'Spreadsheet feature: external links': '外部链接', + 'Spreadsheet feature: macros': '宏', 'Formula has no cached result': '公式没有缓存的计算结果', 'Unsupported cell value': '不支持的单元格值', 'Unsupported number format': '不支持的数字格式', diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index e9cce7ff..b348313c 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -344,7 +344,10 @@ html.mobile-init .file-browser-panel { geometry; a 44px box would hang below the 48px header. Ends at 767px, not at the tablet block's 768: from 768 getDeviceType() says 'desktop', and the desktop rule in styles.css (flex-shrink: 0, wrapping box by box) owns the - strip. */ + strip. With the boxes, their labels and their case-colour borders gone, the + `-` part of a generated name (.tab-name-case, which styles.css hides + in the clustered strip) is the only cue left to which case a chip is in: the + w counter is per case, so w1-alpha and w1-beta would both read "w1". */ @media (min-width: 600px) and (max-width: 767px) { :where(.header) .session-tabs-host > .session-tabs.tabs-clusters > .tab-cluster { display: contents; @@ -353,6 +356,10 @@ html.mobile-init .file-browser-panel { :where(.header) .tab-cluster-label { display: none; } + + :where(.header) .session-tabs-host > .session-tabs.tabs-clusters .tab-name-case { + display: inline; + } } /* Edge fade for the phone's header tab strip (used in the block below). @@ -788,7 +795,9 @@ html.mobile-init .file-browser-panel { /* Clustered by case, the same holds: the boxes dissolve into the one chip row (in cluster order) and the labels go, since every chip still names - its session. */ + its session. It does so in full: the `-` part of a generated name, + which styles.css hides in the clustered strip, shows again, or w1-alpha + and w1-beta (the w counter is per case) would both read "w1". */ :where(.header) .session-tabs-host > .session-tabs.tabs-clusters > .tab-cluster { display: contents; } @@ -797,6 +806,10 @@ html.mobile-init .file-browser-panel { display: none; } + :where(.header) .session-tabs-host > .session-tabs.tabs-clusters .tab-name-case { + display: inline; + } + /* Only the active tab shows its action icons on a phone (see below), so on every other tab the container is empty but still a flex item, and its gap made the chip visibly wider on the right than on the left. */ diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index e2fb1083..a419121b 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -3573,8 +3573,15 @@ Object.assign(CodemanApp.prototype, { } catch (err) { console.error('Failed to load app settings:', err); } - // Return device-specific defaults - this._cachedAppSettings = this.getDefaultSettings(); + // Return device-specific defaults, without showTileGridButton: its default + // on a non-handheld follows the LIVE primary pointer (getDefaultSettings), + // and this object is what a fresh device caches and the server-settings + // merge then persists, which would freeze a 2-in-1's first-load posture + // into a stored value. Every reader resolves the absent key through a + // fresh getDefaultSettings() (?? defaults.showTileGridButton ?? true). + const defaults = { ...this.getDefaultSettings() }; + delete defaults.showTileGridButton; + this._cachedAppSettings = defaults; return this._cachedAppSettings; }, @@ -3620,6 +3627,10 @@ Object.assign(CodemanApp.prototype, { if (result && this.notificationManager) { this.notificationManager.originalTitle = document.title; } + // The connection tile's value word (Header Stats Style Tiles) is written + // already translated into a data-i18n-skip span, so the translator above + // cannot revert it; its own language compare makes this one call repaint it. + this._updateConnectionIndicator?.(); }, // Resolved per-device state of the plan-usage chip. Desktop defaults ON, diff --git a/src/web/public/spreadsheet-preview.js b/src/web/public/spreadsheet-preview.js index 27d6297f..9096ed9b 100644 --- a/src/web/public/spreadsheet-preview.js +++ b/src/web/public/spreadsheet-preview.js @@ -81,15 +81,24 @@ } // One notice bar item, translated on its own (the bar is one text node, which - // the i18n layer could only match whole). A number format's code is workbook - // text: it is appended as is, never passed through the translator, which - // would read a `{…}` in it as a placeholder. + // the i18n layer could only match whole; the bar itself carries + // data-i18n-skip). A number format's code is workbook text: it is appended + // as is, never passed through the translator, which would read a `{…}` in it + // as a placeholder. A feature word goes through its scoped + // 'Spreadsheet feature: ' key and reads as the plain word when that key + // has no translation: a bare 'charts' key would also rename a charts/ folder. function warningText(warning) { const text = String(warning); if (text.startsWith(UNSUPPORTED_FORMAT_PREFIX)) { return `${translate('Unsupported number format')}: ${text.slice(UNSUPPORTED_FORMAT_PREFIX.length)}`; } - return translate(own(FEATURE_LABELS, text) ? FEATURE_LABELS[text] : text); + if (own(FEATURE_LABELS, text)) { + const label = FEATURE_LABELS[text]; + const key = `Spreadsheet feature: ${label}`; + const translated = translate(key); + return translated !== key ? translated : label; + } + return translate(text); } function message(container, text, kind) { @@ -436,6 +445,10 @@ } const notice = document.createElement('div'); notice.className = 'spreadsheet-preview-notice'; + // Written already translated, item by item (renderWarnings), and it ends + // with workbook text (a number format's code): the observer's t() over + // the whole line would rewrite a `{name}` or a "Codeman" in that code. + notice.setAttribute('data-i18n-skip', ''); notice.hidden = true; emptySheetState = document.createElement('div'); emptySheetState.className = 'spreadsheet-empty-sheet'; diff --git a/src/web/public/tile-grid.js b/src/web/public/tile-grid.js index 1216a4b4..98644dc7 100644 --- a/src/web/public/tile-grid.js +++ b/src/web/public/tile-grid.js @@ -337,15 +337,13 @@ Object.assign(CodemanApp.prototype, { const max = window.CodemanTileGrid.TILE_GRID_MAX; // The grid and the split are never open together. An open split becomes the // grid's first two tiles (Pane A focused, Pane B beside it), so "split, then - // want more" is one step. No closing resize for Pane A: it is about to park. + // want more" is one step. let requested = ids || []; - if (this._splitPane) { + const splitOpen = !!this._splitPane; + if (splitOpen && mergeSplit) { const seed = [this.activeSessionId, this._splitSessionId].filter(Boolean); - this.closeSplitPane({ skipPrimaryResize: true }); - if (mergeSplit) { - requested = [...seed, ...requested]; - if (!requested.includes(focusedId)) focusedId = seed[0] ?? null; - } + requested = [...seed, ...requested]; + if (!requested.includes(focusedId)) focusedId = seed[0] ?? null; } const wanted = []; for (const id of requested) { @@ -354,6 +352,12 @@ Object.assign(CodemanApp.prototype, { wanted.push(id); if (wanted.length === max) break; } + // No closing resize for Pane A only when it becomes a tile (its tile sizes + // the PTY). A Pane A left out of the set (a group, a stored grid: mergeSplit + // false) gets its full width back now, while the main terminal still shows + // it, or its PTY stays at the split's half width for as long as the grid + // is open. + if (splitOpen) this.closeSplitPane({ skipPrimaryResize: wanted.includes(this.activeSessionId) }); if (wanted.length === 0) return grid.open; const focus = wanted.includes(focusedId) ? focusedId : wanted[0]; diff --git a/test/header-stats-style.test.ts b/test/header-stats-style.test.ts index 1a7b8d1e..3c4c0a3e 100644 --- a/test/header-stats-style.test.ts +++ b/test/header-stats-style.test.ts @@ -226,10 +226,21 @@ describe('the parts the new styles draw', () => { app._updateConnectionIndicator(); expect(document.getElementById('connectionTileLabel')!.textContent).toBe('WS'); expect(document.getElementById('connectionTileValue')!.textContent).toBe('已连接'); - // The same descriptor after a switch back to English is not skipped as unchanged. - window.codemanT = en.t; - window.CodemanI18n = { language: 'en' }; - app._updateConnectionIndicator(); + // A switch back to English repaints it with nothing else happening: the + // value span is data-i18n-skip, so only applyLocalization (settings-ui.js, + // which every language change runs) can, and the same descriptor is not + // skipped as unchanged. No indicator update by hand here. + window.CodemanI18n = { + language: 'zh-CN', + configure({ language }: { language: string }) { + this.language = language; + window.codemanT = language === 'zh-CN' ? zh.t : en.t; + return { language }; + }, + }; + app.loadAppSettingsFromStorage = () => ({ language: 'en' }); + app.applyLocalization(); + expect(window.CodemanI18n.language).toBe('en'); expect(document.getElementById('connectionTileValue')!.textContent).toBe('live'); // Every value word has its own Chinese, and none is a bare key. diff --git a/test/spreadsheet-preview.test.ts b/test/spreadsheet-preview.test.ts index e4329294..e6c904a2 100644 --- a/test/spreadsheet-preview.test.ts +++ b/test/spreadsheet-preview.test.ts @@ -504,12 +504,64 @@ describe('spreadsheet preview text the user reads', () => { 'View truncated to the first 2500 cells, Formula has no cached result, Unsupported cell value, ' + 'Unsupported number format: {name}"x", 3 unsupported number formats' ); + // The real English translator reads the feature words as plain words too, + // never as their scoped keys. + (window as any).codemanT = en; + expect(await notice()).toBe(english); (window as any).codemanT = zh; const chinese = await notice(); // The format code is workbook text: kept as is ({name} is not a placeholder here). expect(chinese).toContain('不支持的数字格式: {name}"x"'); expect(leftover(chinese.replace('{name}"x"', ''))).toEqual([]); expect(chinese.startsWith(`${zh('Some workbook features are not shown')}: 数据透视表, 外部链接, 图表`)).toBe(true); + // Through scoped keys only: the translator runs over the Files panel tree and + // the case picker too, where a bare 'charts' or 'macros' key would rename a + // Helm chart's charts/ or a dbt project's macros/ folder, or a case of that name. + const words = ['charts', 'drawings', 'pivot tables', 'external links', 'macros']; + expect(words.filter((word) => zh(word) !== word || zh(word.toUpperCase()) !== word.toUpperCase())).toEqual([]); + }); + + it('keeps the notice bar away from the page translator, so a format code is never rewritten', async () => { + // The real i18n.js in the renderer's own window. configure() walks the whole + // body with t(), which fills `{name}` and rebrands "Codeman" in every text + // node it reaches; the bar is written already translated, item by item, and + // ends with workbook text. + const { confirm, alert } = window; + const context = vm.createContext({ + window, + document, + Node: window.Node, + NodeFilter: window.NodeFilter, + MutationObserver: window.MutationObserver, + console, + }); + vm.runInContext(i18nSource, context, { filename: 'i18n.js' }); + try { + const i18n = (window as any).CodemanI18n as { configure(o: object): void }; + i18n.configure({ language: 'en', displayName: 'Acme' }); + const worker = await openLoaded(); + worker.emit({ ...metadata(), warnings: ['charts'] }); + const request = worker.postMessage.mock.calls.at(-1)?.[0]; + worker.emit({ + type: 'tile', + requestId: request.requestId, + sheetId: '1', + cells: [], + warnings: ['Unsupported number format: {name}"Codeman"0'], + }); + const bar = document.querySelector('.spreadsheet-preview-notice')!; + const written = 'Some workbook features are not shown: charts, Unsupported number format: {name}"Codeman"0'; + expect(bar.textContent).toBe(written); + // A language or display-name change re-walks the page. + i18n.configure({ language: 'en', displayName: 'Acme' }); + expect(bar.textContent).toBe(written); + expect(bar.hasAttribute('data-i18n-skip')).toBe(true); + } finally { + delete (window as any).CodemanI18n; + delete (window as any).codemanT; + window.confirm = confirm; + window.alert = alert; + } }); it('the HTTP status line reads in Chinese with its status', async () => { diff --git a/test/tab-clusters.test.ts b/test/tab-clusters.test.ts index 8ff69d57..d5cc8c87 100644 --- a/test/tab-clusters.test.ts +++ b/test/tab-clusters.test.ts @@ -383,7 +383,7 @@ describe('tab layouts by case and ledger (static)', () => { widths: ReturnType; }> = []; postcss.parse(mobileCss).walkRules((rule) => { - if (!rule.selector.includes('tab-cluster')) return; + if (!rule.selector.includes('tab-cluster') && !rule.selector.includes('tab-name-case')) return; const decls: Record = {}; rule.walkDecls((d) => { decls[d.prop] = d.value; @@ -412,6 +412,19 @@ describe('tab layouts by case and ledger (static)', () => { ), `label hidden at ${width}px` ).toBe(true); + // With the box, its label and its case-colour border gone, the case part + // of a generated name is the only cue left: the w counter is per case, + // so w1-alpha and w1-beta would otherwise both read "w1". It must outrank + // styles.css's `.session-tabs.tabs-clusters .tab-name-case { display: none }`. + expect( + rules.some( + (r) => + applies(r) && + r.selector === ':where(.header) .session-tabs-host > .session-tabs.tabs-clusters .tab-name-case' && + r.decls.display === 'inline' + ), + `case part of the name shown at ${width}px` + ).toBe(true); } }); diff --git a/test/tile-grid-per-device-setting.test.ts b/test/tile-grid-per-device-setting.test.ts index 204ca44a..09736160 100644 --- a/test/tile-grid-per-device-setting.test.ts +++ b/test/tile-grid-per-device-setting.test.ts @@ -118,6 +118,56 @@ describe('the device default: ON on desktop, OFF on handhelds and touch-primary expect(defaultsOn(PHONE).showTileGridButton).toBe(false); }); + it('never stores the posture default: a 2-in-1 first opened as a tablet gets the button once it is docked', () => { + // A fresh device caches loadAppSettingsFromStorage()'s fallback, and the + // server-settings merge saves that object (saveAppSettingsToStorage(merged)), + // so a default taken from the instantaneous primary pointer must not be in it. + let coarse = true; + const stored = new Map(); + const CodemanApp = function CodemanApp(this: unknown) {}; + const context = vm.createContext({ + CodemanApp, + VoiceInput: {}, + localStorage: { + getItem: (k: string) => stored.get(k) ?? null, + setItem: (k: string, v: string) => stored.set(k, String(v)), + }, + document: { getElementById: () => null }, + console, + MobileDetection: { + isHandheldDevice: () => false, + isTouchDevice: () => true, + getDeviceType: () => 'desktop', + }, + window: { + matchMedia: (q: string) => ({ + matches: q === '(pointer: coarse)' ? coarse : q === '(pointer: fine)' && !coarse, + }), + }, + }); + vm.runInContext(SOURCE, context, { filename: 'settings-ui.js' }); + type SettingsApp = { + getDefaultSettings(): Record; + loadAppSettingsFromStorage(): Record; + saveAppSettingsToStorage(s: Record): void; + }; + const app = Object.create(CodemanApp.prototype) as SettingsApp; + // What every reader resolves (header button, App Settings chip, chord). + const resolvedNow = () => + (app.loadAppSettingsFromStorage().showTileGridButton ?? app.getDefaultSettings().showTileGridButton ?? true) === + true; + + // First load in tablet posture: OFF, and nothing about it is cached or saved. + expect(resolvedNow()).toBe(false); + expect(app.loadAppSettingsFromStorage()).not.toHaveProperty('showTileGridButton'); + app.saveAppSettingsToStorage({ ...app.loadAppSettingsFromStorage() }); + expect([...stored.values()].join('')).not.toContain('showTileGridButton'); + + // Docked (keyboard and trackpad: fine primary pointer): the desktop default. + coarse = false; + expect(resolvedNow()).toBe(true); + }); + it('a context with no window at all still answers (the desktop default)', () => { const CodemanApp = function CodemanApp(this: unknown) {}; const context = vm.createContext({ diff --git a/test/tile-grid-split-coexistence.test.ts b/test/tile-grid-split-coexistence.test.ts index 7b4edcd6..7a7ee1fa 100644 --- a/test/tile-grid-split-coexistence.test.ts +++ b/test/tile-grid-split-coexistence.test.ts @@ -83,6 +83,7 @@ describe('opening the grid over an open split', () => { app.activeSessionId = 's-a'; localStore.set('codeman:tile-count', '2'); const paneB = openSplit(app); + app.sendResize.mockClear(); app.toggleTileGrid(); @@ -91,6 +92,8 @@ describe('opening the grid over an open split', () => { // Filled from tab order (s-other first), not seeded with the split's two. expect(app._tileGrid.ids).toEqual(['s-c', 's-other']); expect(app.activeSessionId).toBe('s-c'); + // Pane A (s-a) is not a tile: the split's closing resize gave it its full width back. + expect(app.sendResize.mock.calls).toEqual([['s-a', { force: true }]]); }); it('an explicit open over a split keeps both split sessions first', () => { @@ -126,6 +129,12 @@ describe('opening the grid over an open split', () => { expect(app._splitPane).toBeNull(); expect(app._tileGrid.ids).toEqual(['s-c', 's-d']); expect(app.activeSessionId).toBe('s-c'); + // Pane A (s-a) is not in the group, so no tile will ever size its PTY: the + // split's closing resize gives it its full width back before the main + // terminal parks, or it stays at the split's half width for as long as + // the grid is open. The split's opening resize, then the closing one. + expect(app.sendResize).toHaveBeenCalledTimes(2); + expect(app.sendResize).toHaveBeenLastCalledWith('s-a', { force: true }); }); it('Ctrl/Cmd+click over a split opens the remembered count in total, never one more', () => { @@ -139,6 +148,8 @@ describe('opening the grid over an open split', () => { // The split's Pane A seeds the set (tileGridOpenSet), the clicked one joins: 2, not 3. expect(app._tileGrid.ids).toEqual(['s-a', 's-c']); expect(app.activeSessionId).toBe('s-c'); + // Pane A is a tile, which sizes its PTY: no closing resize for it. + expect(app.sendResize).toHaveBeenCalledTimes(1); // the split's own opening resize only }); it('Ctrl/Cmd+click over a split with a remembered grid: the remembered grid wins, not the split', () => {