mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 16:59:43 +02:00
fix: final checkup review follow-ups
- i18n: the spreadsheet notice's feature words (charts, drawings, macros,
pivot tables, external links) were bare, case-insensitive zh-CN keys, so
the page translator also renamed a charts/ or macros/ folder in the Files
panel and a case of that name in the case picker. They are now scoped
'Spreadsheet feature: <word>' keys; warningText() falls back to the plain
word when the scoped key has no translation (English, no i18n). Removing
the bare keys closes this branch's regression. The older 'models' key has
the same class of problem; it is left alone here, since adding
.case-combobox-option-label to USER_TEXT_SELECTOR would also untranslate
the picker's two action rows and still miss the title attribute.
- Spreadsheet notice bar: marked data-i18n-skip. It is written already
translated, item by item, and ends with a number format's code, which the
observer's t() over the whole line rewrote ({name}, "Codeman").
- Connection tile (Header Stats Style Tiles): applyLocalization() now repaints
the indicator, so a switch back to English no longer leaves the Chinese
value word in its data-i18n-skip span until the next keystroke or ACK.
- Tiles default: loadAppSettingsFromStorage() no longer caches the
posture-dependent showTileGridButton default, so the init merge never
persists it; a 2-in-1 first opened as a tablet gets the button once docked.
Every reader still resolves the absent key through a fresh
getDefaultSettings(), so phones stay OFF and desktops ON.
- Tile grid over a split: closeSplitPane() skips Pane A's closing resize only
when Pane A becomes a tile. With mergeSplit false (Open group as tiles, a
stored grid) a Pane A left out of the set gets its full width back before
the main terminal parks, instead of keeping the split's half width.
- By-case tab strip on tablets and phones: with the boxes dissolved, the
-<case> part of a generated name shows again, so w1-alpha and w1-beta no
longer both read "w1".
Each new assertion fails against the previous source.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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': '不支持的数字格式',
|
||||
|
||||
@@ -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
|
||||
`-<case>` 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<n> 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 `-<case>` part of a generated name,
|
||||
which styles.css hides in the clustered strip, shows again, or w1-alpha
|
||||
and w1-beta (the w<n> 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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: <word>' 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';
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -383,7 +383,7 @@ describe('tab layouts by case and ledger (static)', () => {
|
||||
widths: ReturnType<typeof mobileRuleWidths>;
|
||||
}> = [];
|
||||
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<string, string> = {};
|
||||
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<n> 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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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<string, string>();
|
||||
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<string, unknown>;
|
||||
loadAppSettingsFromStorage(): Record<string, unknown>;
|
||||
saveAppSettingsToStorage(s: Record<string, unknown>): 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({
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user