diff --git a/README.md b/README.md
index 55c1fd9..29a9ef6 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# TXTeditor
-TXTeditor 0.4.7 is a Windows-focused desktop editor for Diablo II / Diablo II: Resurrected style tab-separated `.txt` data files. It is built as a Tauri v2 desktop app with a canvas-rendered virtual grid for editing large tables.
+TXTeditor is a Windows-focused desktop editor for Diablo II / Diablo II: Resurrected style tab-separated `.txt` data files and supported D2R JSON string files. It is built as a Tauri v2 desktop app with a canvas-rendered virtual grid for editing large tables.
TXTeditor is a personal project. I am not an experienced programmer, and most of the implementation was built with the help of OpenAI Codex. The app may contain bugs, incomplete behavior, or rough edges, but I am sharing it in case it is useful to others.
@@ -8,36 +8,63 @@ TXTeditor is not affiliated with, endorsed by, or connected to Blizzard Entertai
## Features
-- Open single `.txt` files.
-- Open a D2R `data/global/excel` style folder or workspace.
+- Open individual `.txt` files and supported D2R JSON string files.
+- Open a D2R `data/global/excel` style folder or workspace, including subfolders when desired.
+- Open file paths passed to TXTeditor when the desktop app starts.
- Edit tab-separated data in a grid interface.
+- Edit `data/local/lng/strings/*.json` localization files in a dedicated JSON code editor.
- Canvas virtualization for large files.
-- Frozen first row and first column.
-- Zoom, search, undo, and redo.
-- Copy, cut, paste, fill, and simple arithmetic operations on selections.
+- Independently freeze the first row and first column; the selected state is remembered between runs.
+- Grid zoom, forward and backward search, undo, and redo.
+- Copy, cut, paste, fill, and simple arithmetic operations on selections, including filling a multi-cell selection by pasting one value.
- Hide and unhide rows or columns.
+- Optionally lock mouse-based row-height and column-width resizing.
- D2R-aware linting in the live Problems panel.
- Select either Vector-LSP or Legacy Lint as the active lint engine.
-- Click a diagnostic to jump to the matching file, row, column, and cell.
-- RotW and 2.4 lint profile toggle.
+- Run cross-file lint rules against the active workspace or the sibling files of an individually opened table.
+- Optionally lint supported D2R JSON string files with Vector-LSP.
+- Click a diagnostic to jump to the matching table cell or JSON range.
+- Configure versioned schema and bundled reference data, or use RotW and 2.4 profiles with Legacy Lint.
-## Lint Profiles
+## JSON Editing
-TXTeditor includes RotW and 2.4 lint profiles. These rules are based on the behavior of [d2rlint](https://github.com/eezstreet/d2rlint), the original D2R linting tool made by eezstreet, and are integrated into TXTeditor's live Problems panel.
+TXTeditor includes a dedicated JSON code editor based on [CodeMirror](https://codemirror.net/). It currently supports D2R string files under `data/local/lng/strings/*.json`, with JSON syntax highlighting, bracket matching, folding, search, and syntax markers.
-The RotW-oriented lint behavior has been checked against the project's current d2rlint-compatible fixture/oracle workflow. Other data sets, mod variants, or future rule changes may still expose bugs or differences.
+JSON files use the same document tab and save workflow as table files. If an open JSON file changes on disk, TXTeditor asks whether to reload the disk version or keep the editor version.
+
+## Linting
-## Lint Engines
+Lint results appear in the live Problems panel. Selecting a diagnostic opens the matching table cell or JSON range.
-Version 0.4.1 adds a lint engine selector in Settings.
+### Lint Engines
-Vector-LSP is the default engine for first-time runs and keeps the 0.4 behavior: bundled Vector-LSP diagnostics, Vector-LSP hover, Lint Options, and Problems panel integration.
+Vector-LSP is the default engine for first-time runs. It provides bundled Vector-LSP diagnostics, Vector-LSP hover, Lint Options, JSON lint support, and Problems panel integration.
-Legacy Lint restores the earlier built-in lint path from TXTeditor 0.33. In Legacy Lint mode, the Problems panel shows the RotW / 2.4 profile selector and Rules panel, and diagnostics are produced by TXTeditor's built-in legacy lint engine instead of Vector-LSP.
+Legacy Lint uses TXTeditor's built-in lint path. In Legacy Lint mode, diagnostics are produced by TXTeditor instead of Vector-LSP, and the Problems panel provides the profile selector and Rules panel.
You can switch between Vector-LSP and Legacy Lint while TXTeditor is running. Only the selected engine updates the active diagnostics, cell markers, overview-ruler marks, and Problems panel. Switching back to Vector-LSP resyncs open files with Vector-LSP and restores the stored Vector-LSP Hover preference.
-Version 0.4.1 integrates [vector-lsp](https://github.com/eezstreet/vector-lsp) created by eezstreet while allowing users to choose Vector-LSP or Legacy Lint as the active lint engine.
+TXTeditor uses a [modified fork of vector-lsp](https://github.com/yinyin333333/vector-lsp) that includes application-specific integration changes. This fork is derived from the [original vector-lsp](https://github.com/eezstreet/vector-lsp) created by eezstreet and remains subject to the original project's attribution and license.
+
+### Lint Profiles
+
+Legacy Lint includes RotW and 2.4 profiles. These rules are based on the behavior of [d2rlint](https://github.com/eezstreet/d2rlint), the original D2R linting tool made by eezstreet.
+
+The RotW-oriented lint behavior has been checked against the project's current d2rlint-compatible fixture/oracle workflow. Other data sets, mod variants, or future rule changes may still expose bugs or differences.
+
+### Reference Data and Cross-File Lint
+
+Cross-file rules use the files in the active workspace. For a separately opened `.txt` file, sibling tables in the same folder provide its lint context. Folder workspaces include subfolders by default; **Exclude subfolders when opening a folder** in Settings limits the session to the selected folder itself.
+
+Versioned bundled reference data can supply tables that are absent from the current mod. A local workspace, sibling, or explicitly opened table takes precedence over the bundled fallback, so diagnostics follow the files being edited. One selected reference version is used for the whole lint session.
+
+### JSON Lint
+
+Vector-LSP can lint the supported D2R JSON string files. JSON lint is disabled by default and can be enabled through **Lint Options**. Individual rules can check duplicate IDs or keys, required string fields, and unused string keys. The unused-key rule also has a configurable lower ID threshold.
+
+Only JSON files present in the mod are checked. D2R layout JSON is used as evidence when checking whether string keys are used, but layout files are not opened as editable string documents.
+
+Malformed JSON is reported as a syntax problem. When semantic results from the last successful parse are still relevant, a syntax error does not make those existing findings appear resolved merely because the current document cannot be parsed.
## Build
@@ -74,7 +101,10 @@ The toolbar **Shortcuts** button lets you replace command and grid-scrolling sho
- `Ctrl+O`: open file
- `Ctrl+S`: save
- `Ctrl+Shift+S`: save as
-- `Ctrl+F`: search
+- `Ctrl+F`: search from the active cell
+- `F3` / `Shift+F3`: find next / previous
+- `Ctrl+Shift+H`: find and replace
+- `Ctrl+G`: go to a displayed row number
- `Ctrl+B`: toggle Explorer panel
- `Ctrl+L`: toggle Problems panel
- `Ctrl+H`: reset all row heights to default
@@ -86,15 +116,17 @@ The toolbar **Shortcuts** button lets you replace command and grid-scrolling sho
- `Ctrl+A`: select all
- `Ctrl+P` / `Ctrl+Shift+P`: command palette
- `Ctrl+W`: close current tab
-- `Ctrl+Plus`: zoom in
-- `Ctrl+Minus`: zoom out
-- `Ctrl+0`: reset zoom
+- `Ctrl+Tab` / `Ctrl+Shift+Tab`: move to the next / previous open tab
+- `Ctrl+Plus`: zoom in on the table grid
+- `Ctrl+Minus`: zoom out on the table grid
+- `Ctrl+0`: reset table-grid zoom
- `PageUp` / `PageDown`: scroll one grid page vertically
- `Home` / `End`: scroll to the top or bottom of the grid
- `Shift+Home` / `Shift+End`: scroll to the left or right edge of the grid
- `Enter` / `F2`: edit cell
- `Escape`: cancel edit
- `Tab` / `Shift+Tab`: move horizontally after edit
+- `Enter` / `Shift+Enter` in the Find window: find next / previous
## Acknowledgements
diff --git a/index.html b/index.html
index 56e4841..8865a8d 100644
--- a/index.html
+++ b/index.html
@@ -8,26 +8,26 @@
-
Settings
+
${translate("toolbar.settings")}
+
${translate("settings.language")}
+
${localeOptions}
${visualControls.colorize.label}
@@ -367,10 +394,10 @@ export function createSettingsController({
${visualControls.workspaceSubfolders.label}
-
Lint Engine
-
-
Vector-LSP
-
Legacy Lint
+
${translate("settings.lintEngine")}
+
+ ${translate("settings.vectorEngine")}
+ ${translate("settings.legacyEngine")}
@@ -379,19 +406,19 @@ export function createSettingsController({
${visualControls.vectorHover.hintText}
${visualControls.font.label}
${fontOptions}
- Theme
-
+
${translate("settings.theme")}
+
${themeControls}
${dockControls}
- Reset Layout
+ ${translate("settings.resetLayout")}
- Close
+ ${translate("common.close")}
`;
document.body.append(backdrop);
@@ -402,6 +429,7 @@ export function createSettingsController({
const hoverInput = backdrop.querySelector("#settingsVectorLspHover");
const hoverHint = backdrop.querySelector("#settingsVectorLspHoverHint");
const fontInput = backdrop.querySelector("#settingsGridFont");
+ const localeInput = backdrop.querySelector("#settingsLocale");
const lintEngineButtons = [...backdrop.querySelectorAll("[data-settings-lint-engine]")];
const themeButtons = [...backdrop.querySelectorAll("[data-settings-theme]")];
const dockButtons = [...backdrop.querySelectorAll("[data-settings-dock-panel]")];
@@ -413,10 +441,16 @@ export function createSettingsController({
hoverInput.disabled = isLegacyLintEngine();
hoverHint?.classList.toggle("hidden", !isLegacyLintEngine());
fontInput.value = state.gridFont;
+ localeInput.value = state.locale;
for (const button of lintEngineButtons) button.classList.toggle("active", button.dataset.settingsLintEngine === state.lint.engine);
for (const button of themeButtons) button.classList.toggle("active", button.dataset.settingsTheme === state.theme);
for (const button of dockButtons) button.classList.toggle("active", dockForPanel(button.dataset.settingsDockPanel) === button.dataset.settingsDockEdge);
};
+ const refreshLocaleLabels = () => {
+ for (const element of backdrop.querySelectorAll("[data-settings-i18n]")) {
+ element.textContent = translate(element.dataset.settingsI18n);
+ }
+ };
colorizeInput.addEventListener("change", () => { setColorizeColumns(colorizeInput.checked); refresh(); });
mouseResizeInput.addEventListener("change", () => { setMouseResizeLocked(mouseResizeInput.checked); refresh(); });
workspaceSubfoldersInput.addEventListener("change", () => {
@@ -431,6 +465,12 @@ export function createSettingsController({
});
hoverInput.addEventListener("change", () => { setVectorLspHover(hoverInput.checked); refresh(); });
fontInput.addEventListener("change", () => { changeGridFont(fontInput.value); refresh(); });
+ localeInput.addEventListener("change", () => {
+ setLocale(localeInput.value).then(() => {
+ refresh();
+ refreshLocaleLabels();
+ }).catch(showError);
+ });
for (const button of lintEngineButtons) {
button.addEventListener("click", () => { setLintEngine(button.dataset.settingsLintEngine); refresh(); });
}
@@ -448,25 +488,33 @@ export function createSettingsController({
if (closed) return;
closed = true;
unbindEscape?.();
+ document.removeEventListener("txteditor-locale-changed", refreshForLocale);
backdrop.remove();
els.host.focus();
};
+ const refreshForLocale = () => {
+ if (closed) return;
+ close();
+ showAppSettings();
+ };
+ document.addEventListener("txteditor-locale-changed", refreshForLocale);
unbindEscape = bindEscapeToClose(close);
backdrop.addEventListener("click", (event) => {
if (event.target === backdrop || event.target.closest("[data-settings-close]")) close();
});
}
- async function showSettings() {
+ async function showSettings(draftConfig = null) {
if (isLegacyLintEngine()) {
state.lint.legacy.rulesOpen = true;
renderChrome();
return;
}
- const config = await getConfig().catch((error) => {
+ const config = draftConfig ?? await getConfig().catch((error) => {
reportBackgroundFailure("Configuration load", error, "settings");
return {};
});
+ const translate = (key, params = {}) => t(key, params, state.locale);
const mode = config.lintMode ?? "basic";
const schemaVersion = config.schemaVersion ?? "3.2";
const VERSIONS = ["3.2", "3.1", "2.4", "1.13"];
@@ -475,7 +523,7 @@ export function createSettingsController({
).join("");
const referenceVersion = config.referenceVersion ?? "";
const referenceVersionOptions = [
- ["", "Use schema/profile version"],
+ ["", translate("settings.useSchemaProfileVersion")],
["3.2", "3.2"],
["3.1", "3.1"],
["2.4", "2.4"],
@@ -489,89 +537,89 @@ export function createSettingsController({
backdrop.className = "modal-backdrop";
backdrop.innerHTML = `
-
Lint Options
+
${translate("lint.optionsTitle")}
- Basic
- Advanced
+ ${translate("settings.basic")}
+ ${translate("settings.advanced")}
- Schema Version
+ ${translate("settings.schemaVersion")}
${versionOptions}
-
Plugin Folder
+
${translate("settings.pluginFolder")}
- ${isTauriRuntime() ? `Browse… ` : ""}
+ placeholder="${translate("settings.pluginFolderPlaceholder")}" />
+ ${isTauriRuntime() ? `${translate("common.browse")}… ` : ""}
-
Schema Folder
+
${translate("settings.schemaFolder")}
- ${isTauriRuntime() ? `Browse… ` : ""}
+ placeholder="${translate("settings.schemaFolderPlaceholder")}" />
+ ${isTauriRuntime() ? `${translate("common.browse")}… ` : ""}
-
Linter (vector-lsp) Path
+
${translate("settings.vectorLspPath")}
- ${isTauriRuntime() ? `Browse… ` : ""}
+ placeholder="${translate("settings.vectorLspPathPlaceholder")}" />
+ ${isTauriRuntime() ? `${translate("common.browse")}… ` : ""}
-
Bundled Reference Data
+
${translate("settings.bundledReferenceData")}
${referenceVersionOptions}
-
One version is used for the whole lint session. Advanced mode requires an explicit version; leave this disabled only when no baseline fallback is wanted.
+
${translate("settings.referenceVersionHint")}
- Enable localization JSON diagnostics
+ ${translate("settings.jsonDiagnostics")}
-
Checks only JSON files present in this mod. Layout JSON is used only as Key Usage evidence.
+
${translate("settings.jsonDiagnosticsHint")}
- Duplicate IDs / keys
+ ${translate("settings.jsonDuplicateIds")}
Json/DuplicateIds
- ${jsonRuleActionOptions(jsonRules.duplicateIds.action)}
+ ${jsonRuleActionOptions(jsonRules.duplicateIds.action, translate)}
- Required string fields
+ ${translate("settings.jsonStringFormat")}
Json/StringFormat
- ${jsonRuleActionOptions(jsonRules.stringFormat.action)}
+ ${jsonRuleActionOptions(jsonRules.stringFormat.action, translate)}
- Unused localization keys
+ ${translate("settings.jsonKeyUsage")}
Json/KeyUsage
- ${jsonRuleActionOptions(jsonRules.keyUsage.action)}
+ ${jsonRuleActionOptions(jsonRules.keyUsage.action, translate)}
- Only check IDs greater than
+ ${translate("settings.jsonKeyUsageIdStart")}
-
With ${jsonRules.keyUsage.idStart}, ID ${jsonRules.keyUsage.idStart} is excluded and larger IDs are checked.
+
${translate("settings.jsonKeyUsageHint", { id: jsonRules.keyUsage.idStart })}
- Enable debug logging (shows in Log panel)
+ ${translate("settings.debugLogging")}
- Save
- Cancel
- ${state.lsp.started ? `Restart LSP ` : ""}
+ ${translate("common.save")}
+ ${translate("common.cancel")}
+ ${state.lsp.started ? `${translate("settings.restartLsp")} ` : ""}
`;
document.body.append(backdrop);
@@ -647,14 +695,41 @@ export function createSettingsController({
let closed = false;
let saving = false;
let unbindEscape = null;
+ let refreshForLocale = null;
const finish = () => {
if (closed) return;
closed = true;
unbindEscape?.();
+ document.removeEventListener("txteditor-locale-changed", refreshForLocale);
backdrop.remove();
els.host.focus();
resolve();
};
+ refreshForLocale = () => {
+ if (closed) return;
+ const draft = {
+ ...config,
+ lintMode: backdrop.querySelector(".settings-tab.active")?.dataset.settingsTab ?? mode,
+ schemaVersion: versionSelect?.value || schemaVersion,
+ referenceVersion: referenceVersionSelect?.value || undefined,
+ pluginPath: pluginInput?.value ?? config.pluginPath,
+ schemaPath: schemaInput?.value ?? config.schemaPath,
+ vectorLspPath: lspInput?.value ?? config.vectorLspPath,
+ debugLogging: backdrop.querySelector("#settingsDebugLogging")?.checked ?? config.debugLogging,
+ jsonDiagnostics: jsonDiagnosticsEl?.checked ?? config.jsonDiagnostics,
+ jsonDiagnosticRules: {
+ duplicateIds: { action: normalizeJsonRuleAction(jsonDuplicateIdsActionEl?.value) },
+ stringFormat: { action: normalizeJsonRuleAction(jsonStringFormatActionEl?.value) },
+ keyUsage: {
+ action: normalizeJsonRuleAction(jsonKeyUsageActionEl?.value, "ignore"),
+ idStart: parseJsonKeyUsageIdStart(jsonKeyUsageIdStartEl?.value) ?? jsonRules.keyUsage.idStart
+ }
+ }
+ };
+ finish();
+ void showSettings(draft);
+ };
+ document.addEventListener("txteditor-locale-changed", refreshForLocale);
unbindEscape = bindEscapeToClose(finish);
backdrop.addEventListener("click", async (event) => {
const choice = event.target.closest("[data-settings-choice]")?.dataset.settingsChoice;
@@ -670,7 +745,7 @@ export function createSettingsController({
const parsedJsonKeyUsageIdStart = parseJsonKeyUsageIdStart(jsonKeyUsageIdStartEl?.value);
const jsonKeyUsageNeedsThreshold = jsonDiagnosticsEnabled && jsonKeyUsageAction !== "ignore";
if (jsonKeyUsageNeedsThreshold && parsedJsonKeyUsageIdStart === null) {
- showError("Key Usage ID threshold must be a finite number.");
+ showError(tText("error.keyUsageThreshold", {}, state.locale));
jsonKeyUsageIdStartEl?.focus();
saving = false;
if (saveButton) saveButton.disabled = false;
@@ -699,7 +774,7 @@ export function createSettingsController({
try {
await saveConfig(updated);
} catch (err) {
- showError(`Failed to save lint options: ${err}`);
+ showError(tText("error.lintOptionsSave", { error: err }, state.locale));
saving = false;
if (saveButton) saveButton.disabled = false;
return;
@@ -784,7 +859,7 @@ export function createSettingsController({
return;
}
els.lintRulesPanel.classList.remove("hidden");
- els.lintRulesPanel.innerHTML = lintRuleGroupsForProfile(state.lint.legacy.settings.profile).map((group) => `
+ els.lintRulesPanel.innerHTML = lintRuleGroupsForProfile(state.lint.legacy.settings.profile, state.locale).map((group) => `
${escapeHtml(group.group)}
${group.rules.map((entry) => {
@@ -820,6 +895,7 @@ export function createSettingsController({
setLegacyLintReferenceVersion,
setLegacyLintRuleEnabled,
setLintEngine,
+ setLocale,
setTheme,
setVectorLspHover,
showAppSettings,
diff --git a/src/ui/controllers/shell-controller.js b/src/ui/controllers/shell-controller.js
index 10d1771..93a3780 100644
--- a/src/ui/controllers/shell-controller.js
+++ b/src/ui/controllers/shell-controller.js
@@ -1,5 +1,7 @@
import { isJsonDocument, isTableDocument } from "../../core/document-file-state.js";
+import { cyclicDocumentIndex } from "../document-lifecycle-policy.js";
import { renderWorkspaceFileList } from "../workspace-file-list-policy.js";
+import { tText } from "../../core/i18n.js";
export function createShellController({
state,
@@ -25,6 +27,7 @@ export function createShellController({
syncProblemsHeaderLayout,
scheduleHoverPrewarm,
ensureDocumentSession = async () => {},
+ commitActiveEditor = () => {},
saveSelectionState = () => {},
recordUiPerf,
perfNow,
@@ -42,15 +45,15 @@ export function createShellController({
const count = lintNotificationCount();
if (count) {
button.dataset.badge = String(count);
- button.title = `Explorer (${count} problems)`;
+ button.title = `${tText("activity.explorer")} (${count} ${tText("activity.problems").toLowerCase()})`;
} else {
delete button.dataset.badge;
- button.title = "Explorer";
+ button.title = tText("activity.explorer");
}
}
for (const button of documentRef.querySelectorAll("[data-command='show-problems']")) {
button.textContent = "P";
- button.title = state.lint.diagnostics.length ? `Problems (${state.lint.diagnostics.length})` : "Problems";
+ button.title = state.lint.diagnostics.length ? `${tText("activity.problems")} (${state.lint.diagnostics.length})` : tText("activity.problems");
}
if (els.lintSummary) els.lintSummary.textContent = lintSummaryText();
if (fileBadges) updateFileProblemBadges();
@@ -104,6 +107,9 @@ export function createShellController({
for (const button of documentRef.querySelectorAll("[data-command='show-problems']")) {
button.classList.toggle("active", state.problemsVisible);
}
+ for (const button of documentRef.querySelectorAll("[data-command='close-all']")) {
+ button.disabled = !state.workspace && !state.docs.length;
+ }
const tableDocumentOpen = documentOpen && isTableDocument(activeDoc());
for (const button of documentRef.querySelectorAll("[data-command='toggle-freeze-row']")) {
button.disabled = !tableDocumentOpen;
@@ -119,10 +125,10 @@ export function createShellController({
renderLintControls();
for (const button of documentRef.querySelectorAll("[data-command='toggle-lint']")) {
button.classList.toggle("active", state.lint.enabled);
- button.textContent = state.lint.enabled ? "Lint: On" : "Lint: Off";
+ button.textContent = state.lint.enabled ? tText("lint.on") : tText("lint.offSummary");
}
for (const button of documentRef.querySelectorAll("[data-command='toggle-theme']")) {
- button.textContent = state.theme === "dark" ? "Light Mode" : "Dark Mode";
+ button.textContent = state.theme === "dark" ? tText("theme.lightMode") : tText("theme.darkMode");
button.classList.remove("active");
}
syncProblemsHeaderLayout();
@@ -131,8 +137,8 @@ export function createShellController({
const severity = docDiagnosticSeverity(doc);
const titleClass = severity ? `tab-title tab-title-${severity}` : "tab-title";
const kindClass = isJsonDocument(doc) ? "tab-json" : "tab-table";
- const dirty = doc.dirty ? '● ' : "";
- return `${escapeHtml(doc.name)} ${dirty}x `;
+ const dirty = doc.dirty ? `● ` : "";
+ return `${escapeHtml(doc.name)} ${dirty}x `;
})
.join("");
const workspaceFiles = renderWorkspaceFileList({
@@ -176,6 +182,8 @@ export function createShellController({
}
function selectTab(index) {
+ if (index < 0 || index >= state.docs.length) return Promise.resolve();
+ commitActiveEditor();
saveSelectionState();
state.active = index;
const doc = activeDoc();
@@ -198,6 +206,16 @@ export function createShellController({
return Promise.resolve();
}
+ function switchTab(delta) {
+ const index = cyclicDocumentIndex({
+ activeIndex: state.active,
+ documentCount: state.docs.length,
+ delta
+ });
+ if (index < 0 || index === state.active) return Promise.resolve();
+ return selectTab(index);
+ }
+
function bindExplorerFilter() {
if (!els.explorerFilter || els.explorerFilter.dataset.bound) return;
els.explorerFilter.dataset.bound = "true";
@@ -245,7 +263,9 @@ export function createShellController({
els.explorerFilter.value = "";
renderExplorerSearchResults([]);
if (result.index != null) return selectTab(result.index);
- openDroppedNativePaths([result.path], { requireCurrentJsonMode: true }).catch(showError);
+ openDroppedNativePaths([result.path], {
+ requireCurrentJsonMode: true
+ }).catch(showError);
}
function explorerSearchResults() {
@@ -276,9 +296,19 @@ export function createShellController({
return String(value || "").trim().toLowerCase();
}
+ function resetWorkspaceView() {
+ collapsedFileGroups.clear();
+ explorerSearchActiveIndex = 0;
+ if (els.explorerFilter) els.explorerFilter.value = "";
+ renderExplorerSearchResults([]);
+ }
+
return {
collapsedFileGroups,
renderDiagnosticsChrome,
- renderChrome
+ renderChrome,
+ resetWorkspaceView,
+ selectTab,
+ switchTab
};
}
diff --git a/src/ui/controllers/shortcut-settings-controller.js b/src/ui/controllers/shortcut-settings-controller.js
index 1d41319..1356e06 100644
--- a/src/ui/controllers/shortcut-settings-controller.js
+++ b/src/ui/controllers/shortcut-settings-controller.js
@@ -8,6 +8,7 @@ import {
shortcutDisplayForAction,
validateShortcutChord
} from "../shortcut-policy.js";
+import { tText } from "../../core/i18n.js";
export function createShortcutSettingsController({
state,
@@ -28,14 +29,14 @@ export function createShortcutSettingsController({
backdrop.className = "modal-backdrop";
backdrop.innerHTML = `
-
Keyboard Shortcuts
-
Click a shortcut, then press the new key combination. Changes apply only after Save.
+
${tText("toolbar.shortcuts")}
+
${tText("shortcut.hint")}
- Reset All to Defaults
+ ${tText("shortcut.resetAll")}
- Save
- Cancel
+ ${tText("toolbar.save")}
+ ${tText("common.cancel")}
`;
document.body.append(backdrop);
@@ -58,18 +59,18 @@ export function createShortcutSettingsController({
displaced.push({ definition, removed, unassigned });
rowMessages.set(
definition.action,
- `${removed.join(" / ")} was reassigned to ${target.label}.${unassigned ? " This command is now unassigned." : ""}`
+ tText("shortcut.reassigned", { keys: removed.join(" / "), target: target.label, suffix: unassigned ? tText("shortcut.nowUnassigned") : "" })
);
}
draft[action] = assigned;
if (displaced.length) {
const previousAssignments = displaced
- .map(({ definition, unassigned }) => `${definition.label}${unassigned ? " (now unassigned)" : ""}`)
+ .map(({ definition, unassigned }) => `${definition.label}${unassigned ? tText("shortcut.nowUnassignedLabel") : ""}`)
.join(", ");
- rowMessages.set(action, `${assigned.join(" / ")} was previously assigned to ${previousAssignments}.`);
+ rowMessages.set(action, tText("shortcut.previouslyAssigned", { keys: assigned.join(" / "), targets: previousAssignments }));
} else if (restoringDefault) {
- rowMessages.set(action, "Restored to default. Save to apply.");
+ rowMessages.set(action, tText("shortcut.restored"));
}
};
@@ -77,18 +78,18 @@ export function createShortcutSettingsController({
const groups = [...new Set(SHORTCUT_DEFINITIONS.map(({ group }) => group))];
list.innerHTML = groups.map((group) => `
- ${escapeHtml(group)}
+ ${escapeHtml(tText(group))}
${SHORTCUT_DEFINITIONS.filter((definition) => definition.group === group).map((definition) => {
const recording = recordingAction === definition.action;
- const display = shortcutDisplayForAction(definition.action, draft) || "Unassigned";
+ const display = shortcutDisplayForAction(definition.action, draft) || tText("shortcut.unassigned");
const message = rowMessages.get(definition.action) ?? "";
return `
${escapeHtml(definition.label)}
- ${recording ? "Press shortcut..." : escapeHtml(display)}
+ ${recording ? tText("shortcut.press") : escapeHtml(display)}
-
Default
+
${tText("shortcut.default")}
${message ? `
${escapeHtml(message)}
` : ""}
`;
}).join("")}
@@ -99,7 +100,7 @@ export function createShortcutSettingsController({
button.addEventListener("click", () => {
recordingAction = button.dataset.shortcutRecord;
rowMessages.clear();
- rowMessages.set(recordingAction, "Press the new shortcut. Escape cancels recording.");
+ rowMessages.set(recordingAction, tText("shortcut.pressNew"));
renderRows();
list.querySelector(`[data-shortcut-record='${recordingAction}']`)?.focus();
});
@@ -114,22 +115,32 @@ export function createShortcutSettingsController({
}
};
+ const refreshForLocale = () => {
+ if (closed) return;
+ rowMessages.clear();
+ for (const element of backdrop.querySelectorAll("[data-shortcut-i18n]")) {
+ element.textContent = tText(element.dataset.shortcutI18n);
+ }
+ renderRows();
+ };
+
const finish = (save = false) => {
if (closed) return;
if (save) {
const conflict = shortcutConflicts(draft)[0];
if (conflict) {
for (const action of conflict.actions) {
- rowMessages.set(action, `${conflict.chord} is still assigned to ${conflict.labels.join(" and ")}.`);
+ rowMessages.set(action, tText("shortcut.conflict", { chord: conflict.chord, labels: conflict.labels.join(" and ") }));
}
renderRows();
return;
}
state.shortcuts = saveShortcutBindings(draft, storage);
- showToast("Keyboard shortcuts saved.");
+ showToast(tText("shortcut.saved"));
}
closed = true;
document.removeEventListener("keydown", onKeydown, true);
+ document.removeEventListener("txteditor-locale-changed", refreshForLocale);
backdrop.remove();
els.host.focus();
};
@@ -170,6 +181,7 @@ export function createShortcutSettingsController({
};
document.addEventListener("keydown", onKeydown, true);
+ document.addEventListener("txteditor-locale-changed", refreshForLocale);
backdrop.addEventListener("click", (event) => {
if (event.target === backdrop) return finish(false);
const choice = event.target.closest("[data-shortcut-choice]")?.dataset.shortcutChoice;
diff --git a/src/ui/dock-layout-policy.js b/src/ui/dock-layout-policy.js
index b52bba7..0617c29 100644
--- a/src/ui/dock-layout-policy.js
+++ b/src/ui/dock-layout-policy.js
@@ -1,3 +1,5 @@
+import { tText } from "../core/i18n.js";
+
export const MIN_DOCK_WIDTH = 180;
export const MIN_DOCK_HEIGHT = 150;
export const MIN_EDITOR_WIDTH = 320;
@@ -61,10 +63,10 @@ export function panelsForDockEdge({ layout, edge, visiblePanels = new Set(DOCK_P
export function dockSettingsControls({ layout = DEFAULT_DOCK_LAYOUT } = {}) {
return DOCK_PANELS.map((panel) => ({
panel,
- label: panel === "explorer" ? "Explorer Dock" : "Problems Dock",
+ label: tText(panel === "explorer" ? "dock.explorer" : "dock.problems"),
options: DOCK_EDGES.map((edge) => ({
edge,
- label: `${edge[0].toUpperCase()}${edge.slice(1)}`,
+ label: tText(`dock.${edge}`),
active: dockPanelEdge(layout, panel) === edge
}))
}));
diff --git a/src/ui/document-lifecycle-policy.js b/src/ui/document-lifecycle-policy.js
index c0a7989..1dc68a0 100644
--- a/src/ui/document-lifecycle-policy.js
+++ b/src/ui/document-lifecycle-policy.js
@@ -1,4 +1,5 @@
import { normalizePath } from "../core/lint-paths.js";
+import { tText } from "../core/i18n.js";
export function documentOpenPlan(docs, doc) {
const target = doc?.path ? normalizePath(doc.path) : "";
@@ -20,6 +21,12 @@ export function activeIndexAfterTabClose({ activeIndex, closeIndex, documentCoun
return Math.max(0, Math.min(remainingCount - 1, next));
}
+export function cyclicDocumentIndex({ activeIndex, documentCount, delta = 1 }) {
+ if (documentCount <= 0) return -1;
+ const current = Math.max(0, Math.min(documentCount - 1, Number(activeIndex) || 0));
+ return ((current + Number(delta || 0)) % documentCount + documentCount) % documentCount;
+}
+
export function closeDialogMessage(doc) {
- return `${doc.name} has unsaved changes.`;
+ return tText("dialog.fileUnsaved", { file: doc.name });
}
diff --git a/src/ui/edit-policy.js b/src/ui/edit-policy.js
index eb7791d..376c084 100644
--- a/src/ui/edit-policy.js
+++ b/src/ui/edit-policy.js
@@ -14,7 +14,7 @@ export function arrowNavigationDelta(key) {
return { rowDelta: 0, columnDelta: 0 };
}
-export function editorKeyAction({ key, shiftKey = false, editMode = null } = {}) {
+export function editorKeyAction({ key, shiftKey = false, ctrlKey = false, altKey = false, metaKey = false, editMode = null } = {}) {
if (shouldCommitEditOnArrow(editMode, key)) {
return { action: "commit-move", ...arrowNavigationDelta(key) };
}
@@ -22,6 +22,7 @@ export function editorKeyAction({ key, shiftKey = false, editMode = null } = {})
return { action: "commit-move", rowDelta: shiftKey ? -1 : 1, columnDelta: 0 };
}
if (key === "Tab") {
+ if (ctrlKey || altKey || metaKey) return { action: "none" };
return { action: "commit-move", rowDelta: 0, columnDelta: shiftKey ? -1 : 1 };
}
if (key === "Escape") return { action: "cancel" };
diff --git a/src/ui/grid-selection-policy.js b/src/ui/grid-selection-policy.js
index dbac04b..6cccf15 100644
--- a/src/ui/grid-selection-policy.js
+++ b/src/ui/grid-selection-policy.js
@@ -52,8 +52,9 @@ export function hasFullColumnRange(ranges, rowCount) {
return ranges.some((rect) => rect.top === 0 && rect.bottom >= rowCount - 1);
}
-export function keyboardSelectionTarget({ key, shiftKey = false, ctrlKey = false, focus, rowCount, columnCount, jumpRow, jumpColumn }) {
+export function keyboardSelectionTarget({ key, shiftKey = false, ctrlKey = false, altKey = false, metaKey = false, focus, rowCount, columnCount, jumpRow, jumpColumn }) {
let { row, column } = focus;
+ if (key === "Tab" && (ctrlKey || altKey || metaKey)) return null;
if (key === "Tab") column += shiftKey ? -1 : 1;
else if (key === "ArrowDown") row = ctrlKey ? jumpRow(row, 1) : row + 1;
else if (key === "ArrowUp") row = ctrlKey ? jumpRow(row, -1) : row - 1;
diff --git a/src/ui/hover-policy.js b/src/ui/hover-policy.js
index b17c3b8..7693005 100644
--- a/src/ui/hover-policy.js
+++ b/src/ui/hover-policy.js
@@ -105,18 +105,22 @@ export function vectorTooltipSections({ value = "", hoverText = "", diagnostics
export function diagnosticTooltipText(diagnostic = {}) {
const message = String(diagnostic.message ?? "");
const guidance = diagnosticUserGuidance(diagnostic);
+ const guidanceHeading = stringField(diagnostic.data?.localizedGuidanceHeading) || "What to do";
const trimmedMessage = message.trim();
const trimmedGuidance = guidance.trim();
const alreadyIncluded = trimmedGuidance
&& (trimmedMessage === trimmedGuidance || trimmedMessage.endsWith(trimmedGuidance));
return trimmedGuidance && !alreadyIncluded
- ? `${message}\n\nWhat to do:\n${guidance}`
+ ? `${message}\n\n${guidanceHeading}:\n${guidance}`
: message;
}
export function diagnosticUserGuidance(diagnostic = {}) {
const data = diagnostic.data;
if (!data || typeof data !== "object") return "";
+ const localizedGuidance = stringField(data.localizedGuidance);
+ if (localizedGuidance) return localizedGuidance;
+ if (data.localizedMessage === true) return "";
const insertText = stringField(data.insertText) || stringField(data.expected);
if (data.kind === "missing-token" && insertText) return `Insert '${insertText}' at the marked position.`;
if (data.kind === "unexpected-character") {
diff --git a/src/ui/lint-controls-policy.js b/src/ui/lint-controls-policy.js
index 41d9054..ea7dc31 100644
--- a/src/ui/lint-controls-policy.js
+++ b/src/ui/lint-controls-policy.js
@@ -1,7 +1,9 @@
+import { tText } from "../core/i18n.js";
+
export function lintToggleControl(lintEnabled = false) {
return {
id: "toggle-lint",
- label: lintEnabled ? "Lint: On" : "Lint: Off",
+ label: lintEnabled ? tText("lint.on") : tText("lint.offSummary"),
active: Boolean(lintEnabled)
};
}
@@ -22,7 +24,7 @@ export function lintControlsModel({
profileSelect: {
id: "lintProfileSelect",
className: "profile-select",
- title: "D2R lint profile",
+ title: tText("lint.profileTitle"),
options: profiles.map((profile) => ({
value: profile,
label: profile,
@@ -32,16 +34,16 @@ export function lintControlsModel({
referenceSelect: {
id: "lintReferenceVersionSelect",
className: "profile-select",
- title: "Bundled reference data version",
+ title: tText("lint.referenceTitle"),
options: ["", "3.2", "3.1", "2.4", "1.13c"].map((version) => ({
value: version,
- label: version || "Profile",
+ label: version || tText("lint.profile"),
selected: version === activeReferenceVersion
}))
},
rulesButton: {
id: "toggle-lint-rules",
- label: "Rules",
+ label: tText("lint.rulesButton"),
active: Boolean(rulesOpen)
},
settingsButton: null,
@@ -56,8 +58,8 @@ export function lintControlsModel({
rulesButton: null,
settingsButton: {
id: "open-settings",
- label: "Lint Options",
- title: "Lint options"
+ label: tText("command.open-settings"),
+ title: tText("lint.optionsTitle")
},
hideRulesPanel: true
};
diff --git a/src/ui/problems-policy.js b/src/ui/problems-policy.js
index 22cf854..7cf1c68 100644
--- a/src/ui/problems-policy.js
+++ b/src/ui/problems-policy.js
@@ -1,3 +1,5 @@
+import { t, tText } from "../core/i18n.js";
+
export function lintPanelActive({ problemsVisible = false, lintEnabled = false } = {}) {
return Boolean(problemsVisible && lintEnabled);
}
@@ -124,15 +126,15 @@ export function problemsPanelHtml({
collapsedFiles = [],
escapeHtml = escapeHtmlValue
} = {}) {
- if (!lintEnabled) return `Lint is off.
`;
- if (vectorEngine && !lspStarted) return `Open a folder to enable linting.
`;
- if (!diagnostics.length) return `No problems.
`;
+ if (!lintEnabled) return `${t("lint.off")}
`;
+ if (vectorEngine && !lspStarted) return `${t("lint.openFolder")}
`;
+ if (!diagnostics.length) return `${t("lint.none")}
`;
const collapsed = collapsedFiles instanceof Set ? collapsedFiles : new Set(collapsedFiles);
return groupDiagnosticsByFile(diagnostics).map(([fileName, fileDiagnostics]) => `
${fileDiagnostics.map((diagnostic) => `
-
+
R${diagnostic.rowIndex + 1}:C${diagnostic.columnIndex + 1}
${escapeHtml(diagnostic.message)}
${diagnostic.ruleId ? `${escapeHtml(diagnostic.ruleId)} ` : ""}
@@ -156,19 +158,19 @@ export function lintSummaryText({
counts = null,
openFileCount = 0
} = {}) {
- if (!lintEnabled) return "Lint off";
+ if (!lintEnabled) return t("lint.offSummary");
if (legacyEngine) {
if (legacyStatus) return legacyStatus;
- if (legacyWorkspaceLoadStatus === "failed") return `Workspace index failed - ${legacyProfile}`;
+ if (legacyWorkspaceLoadStatus === "failed") return tText("lint.workspaceIndexFailed", { profile: legacyProfile });
const summaryCounts = counts ?? diagnosticCounts(diagnostics);
- if (!diagnostics.length) return `No problems - ${legacyProfile}`;
- return `${summaryCounts.error} errors, ${summaryCounts.warning} warnings, ${summaryCounts.info} info - ${legacyProfile}`;
+ if (!diagnostics.length) return tText("lint.noProblemsProfile", { profile: legacyProfile });
+ return tText("lint.summaryCountsProfile", { ...summaryCounts, profile: legacyProfile });
}
- if (vectorEngine && !lspStarted) return "Open a folder to enable linting";
+ if (vectorEngine && !lspStarted) return t("lint.openFolderSummary");
if (lintStatus) return lintStatus;
const summaryCounts = counts ?? diagnosticCounts(diagnostics);
- if (!diagnostics.length) return `No problems (${openFileCount} file${openFileCount === 1 ? "" : "s"} linted)`;
- return `${summaryCounts.error} errors, ${summaryCounts.warning} warnings, ${summaryCounts.info} info (${openFileCount} files)`;
+ if (!diagnostics.length) return tText(openFileCount === 1 ? "lint.noProblemsOneFile" : "lint.noProblemsFiles", { count: openFileCount });
+ return tText("lint.summaryCounts", { ...summaryCounts, count: openFileCount });
}
export function problemBadgeHtml({ diagnostics = [], fileKey = "", notificationsVisible = false, escapeHtml = escapeHtmlValue } = {}) {
diff --git a/src/ui/prompt-dialog.js b/src/ui/prompt-dialog.js
index 4ff5eff..01c8bc7 100644
--- a/src/ui/prompt-dialog.js
+++ b/src/ui/prompt-dialog.js
@@ -1,3 +1,5 @@
+import { tText } from "../core/i18n.js";
+
export function askText({ title, message, defaultValue = "", inputMode = "text", validate = (value) => ({ value }), escapeHtml, host }) {
const backdrop = document.createElement("div");
backdrop.className = "modal-backdrop";
@@ -8,8 +10,8 @@ export function askText({ title, message, defaultValue = "", inputMode = "text",
- OK
- Cancel
+ ${tText("common.ok")}
+ ${tText("common.cancel")}
`;
document.body.append(backdrop);
@@ -54,7 +56,7 @@ export function askText({ title, message, defaultValue = "", inputMode = "text",
});
}
-export function promptNumber({ title, message, defaultValue = "", min = null, allowFloat = false, askText }) {
+export function promptNumber({ title, message, defaultValue = "", min = null, max = null, allowFloat = false, askText }) {
return askText({
title,
message,
@@ -63,9 +65,10 @@ export function promptNumber({ title, message, defaultValue = "", min = null, al
validate(value) {
const text = value.trim();
const number = allowFloat ? Number(text) : Number.parseInt(text, 10);
- if (text === "" || !Number.isFinite(number)) return { error: "Enter a valid number." };
- if (!allowFloat && String(number) !== text) return { error: "Enter a whole number." };
- if (min !== null && number < min) return { error: `Enter a number ${min} or higher.` };
+ if (text === "" || !Number.isFinite(number)) return { error: tText("prompt.validNumber") };
+ if (!allowFloat && String(number) !== text) return { error: tText("prompt.wholeNumber") };
+ if (min !== null && number < min) return { error: tText("prompt.numberAtLeast", { min }) };
+ if (max !== null && number > max) return { error: tText("prompt.numberAtMost", { max }) };
return { value: number };
}
});
diff --git a/src/ui/search-policy.js b/src/ui/search-policy.js
index e24438d..a298092 100644
--- a/src/ui/search-policy.js
+++ b/src/ui/search-policy.js
@@ -4,6 +4,7 @@ import {
SEARCH_SCOPE_ROW_TITLES,
normalizeSearchScope
} from "../core/search.js";
+import { tText } from "../core/i18n.js";
export function initialSearchState() {
return { lastQuery: "", lastScope: SEARCH_SCOPE_ALL };
@@ -56,12 +57,12 @@ export function searchScrollOptionsForScope(scope) {
export function searchStatusText(scope, found, target) {
const normalized = normalizeSearchScope(scope);
if (normalized === SEARCH_SCOPE_COLUMN_TITLES) {
- return `Column C${target.column + 1} (header R${found.row + 1}:C${found.column + 1})`;
+ return tText("search.columnStatus", { column: target.column + 1, headerRow: found.row + 1, headerColumn: found.column + 1 });
}
if (normalized === SEARCH_SCOPE_ROW_TITLES) {
- return `Row R${target.row + 1} (title R${found.row + 1}:C${found.column + 1})`;
+ return tText("search.rowStatus", { row: target.row + 1, titleRow: found.row + 1, titleColumn: found.column + 1 });
}
- return `R${target.row + 1}:C${target.column + 1}`;
+ return tText("search.cellStatus", { row: target.row + 1, column: target.column + 1 });
}
export function shouldSubmitSearchKey(key) {
diff --git a/src/ui/shortcut-policy.js b/src/ui/shortcut-policy.js
index 429fe1d..2c37955 100644
--- a/src/ui/shortcut-policy.js
+++ b/src/ui/shortcut-policy.js
@@ -1,31 +1,8 @@
export const SHORTCUT_STORAGE_KEY = "txteditor.shortcuts.v1";
+import { tText } from "../core/i18n.js";
export const SHORTCUT_DEFINITIONS = Object.freeze([
- shortcut("open-file", "Open File", ["Ctrl+O"]),
- shortcut("save-file", "Save", ["Ctrl+S"], { allowWhileEditing: true }),
- shortcut("save-as", "Save As", ["Ctrl+Shift+S"], { allowWhileEditing: true }),
- shortcut("search", "Find/Search", ["Ctrl+F"]),
- shortcut("undo", "Undo", ["Ctrl+Z"]),
- shortcut("redo", "Redo", ["Ctrl+Y", "Ctrl+Shift+Z"]),
- shortcut("copy", "Copy", ["Ctrl+C"]),
- shortcut("cut", "Cut", ["Ctrl+X"]),
- shortcut("paste", "Paste", ["Ctrl+V"]),
- shortcut("select-all", "Select All", ["Ctrl+A"]),
- shortcut("clear-selection", "Clear Cell(s)", ["Delete"]),
- shortcut("show-palette", "Command Palette", ["Ctrl+P", "Ctrl+Shift+P"]),
- shortcut("close-tab", "Close Current Tab", ["Ctrl+W"], { allowWhileEditing: true }),
- shortcut("toggle-sidebar", "Toggle Explorer", ["Ctrl+B"]),
- shortcut("toggle-problems", "Toggle Problems", ["Ctrl+L"], { allowWhileEditing: true }),
- shortcut("reset-row-heights", "Reset Row Heights", ["Ctrl+H"], { allowWhileEditing: true }),
- shortcut("zoom-in", "Zoom In", ["Ctrl+Plus", "Ctrl+="]),
- shortcut("zoom-out", "Zoom Out", ["Ctrl+Minus"]),
- shortcut("zoom-reset", "Reset Zoom", ["Ctrl+0"]),
- shortcut("scroll-page-up", "Scroll One Page Up", ["PageUp"], { context: "grid", group: "Grid scrolling" }),
- shortcut("scroll-page-down", "Scroll One Page Down", ["PageDown"], { context: "grid", group: "Grid scrolling" }),
- shortcut("scroll-top", "Scroll To Top", ["Home"], { context: "grid", group: "Grid scrolling" }),
- shortcut("scroll-bottom", "Scroll To Bottom", ["End"], { context: "grid", group: "Grid scrolling" }),
- shortcut("scroll-left", "Scroll To Left Edge", ["Shift+Home"], { context: "grid", group: "Grid scrolling" }),
- shortcut("scroll-right", "Scroll To Right Edge", ["Shift+End"], { context: "grid", group: "Grid scrolling" })
+ shortcut("open-file", "command.open-file", ["Ctrl+O"]), shortcut("save-file", "command.save-file", ["Ctrl+S"], { allowWhileEditing: true }), shortcut("save-as", "command.save-as", ["Ctrl+Shift+S"], { allowWhileEditing: true }), shortcut("search", "command.search", ["Ctrl+F"]), shortcut("find-next", "command.find-next", ["F3"], { allowWhileEditing: true }), shortcut("find-previous", "command.find-previous", ["Shift+F3"], { allowWhileEditing: true }), shortcut("replace", "command.replace", ["Ctrl+Shift+H"], { allowWhileEditing: true }), shortcut("go-to-row", "command.go-to-row", ["Ctrl+G"], { allowWhileEditing: true }), shortcut("undo", "command.undo", ["Ctrl+Z"]), shortcut("redo", "command.redo", ["Ctrl+Y", "Ctrl+Shift+Z"]), shortcut("copy", "command.copy", ["Ctrl+C"]), shortcut("cut", "command.cut", ["Ctrl+X"]), shortcut("paste", "command.paste", ["Ctrl+V"]), shortcut("select-all", "command.select-all", ["Ctrl+A"]), shortcut("clear-selection", "command.clear-selection", ["Delete"]), shortcut("show-palette", "shortcut.commandPalette", ["Ctrl+P", "Ctrl+Shift+P"]), shortcut("close-tab", "shortcut.closeCurrentTab", ["Ctrl+W"], { allowWhileEditing: true }), shortcut("next-tab", "command.next-tab", ["Ctrl+Tab"], { allowWhileEditing: true }), shortcut("previous-tab", "command.previous-tab", ["Ctrl+Shift+Tab"], { allowWhileEditing: true }), shortcut("toggle-sidebar", "command.toggle-sidebar", ["Ctrl+B"]), shortcut("toggle-problems", "shortcut.toggleProblems", ["Ctrl+L"], { allowWhileEditing: true }), shortcut("reset-row-heights", "command.reset-row-heights", ["Ctrl+H"], { allowWhileEditing: true }), shortcut("zoom-in", "command.zoom-in", ["Ctrl+Plus", "Ctrl+="]), shortcut("zoom-out", "command.zoom-out", ["Ctrl+Minus"]), shortcut("zoom-reset", "command.zoom-reset", ["Ctrl+0"]), shortcut("scroll-page-up", "shortcut.scrollPageUp", ["PageUp"], { context: "grid", group: "shortcut.groupGrid" }), shortcut("scroll-page-down", "shortcut.scrollPageDown", ["PageDown"], { context: "grid", group: "shortcut.groupGrid" }), shortcut("scroll-top", "shortcut.scrollTop", ["Home"], { context: "grid", group: "shortcut.groupGrid" }), shortcut("scroll-bottom", "shortcut.scrollBottom", ["End"], { context: "grid", group: "shortcut.groupGrid" }), shortcut("scroll-left", "shortcut.scrollLeft", ["Shift+Home"], { context: "grid", group: "shortcut.groupGrid" }), shortcut("scroll-right", "shortcut.scrollRight", ["Shift+End"], { context: "grid", group: "shortcut.groupGrid" })
]);
const DEFINITION_BY_ACTION = new Map(SHORTCUT_DEFINITIONS.map((definition) => [definition.action, definition]));
@@ -33,14 +10,15 @@ const MODIFIER_KEYS = new Set(["Alt", "AltGraph", "Control", "Meta", "OS", "Shif
const FIXED_NAVIGATION_KEYS = new Set(["ArrowDown", "ArrowLeft", "ArrowRight", "ArrowUp", "Enter", "Escape", "F2", "Tab"]);
const MODIFIER_ORDER = ["Ctrl", "Alt", "Shift", "Meta"];
-function shortcut(action, label, defaults, {
+function shortcut(action, labelKey, defaults, {
allowWhileEditing = false,
context = "global",
- group = "Commands"
+ group = "shortcut.groupCommands"
} = {}) {
return Object.freeze({
action,
- label,
+ labelKey,
+ get label() { return tText(labelKey); },
defaults: Object.freeze([...defaults]),
allowWhileEditing,
context,
@@ -131,13 +109,14 @@ export function normalizeShortcutChord(value) {
export function validateShortcutChord(value) {
const chord = normalizeShortcutChord(value);
- if (!chord) return { valid: false, message: "Press a non-modifier key." };
+ if (!chord) return { valid: false, message: tText("shortcut.validationNonModifier") };
const { key, modifiers } = shortcutChordParts(chord);
- if (FIXED_NAVIGATION_KEYS.has(key)) {
- return { valid: false, message: `${key} is reserved for grid editing and navigation.` };
+ const modifiedTab = key === "Tab" && modifiers.some((modifier) => modifier === "Ctrl" || modifier === "Alt" || modifier === "Meta");
+ if (FIXED_NAVIGATION_KEYS.has(key) && !modifiedTab) {
+ return { valid: false, message: tText("shortcut.validationReserved", { key }) };
}
if (isPrintableShortcutKey(key) && !modifiers.some((modifier) => modifier === "Ctrl" || modifier === "Alt" || modifier === "Meta")) {
- return { valid: false, message: "Use Ctrl, Alt, or Meta with letters, numbers, and symbols." };
+ return { valid: false, message: tText("shortcut.validationModifier") };
}
return { valid: true, message: "", chord };
}
@@ -148,10 +127,11 @@ export function shortcutActionForEvent(event, bindings, {
} = {}) {
const chord = shortcutChordFromEvent(event);
if (!chord) return null;
+ const { key } = shortcutChordParts(chord);
for (const definition of SHORTCUT_DEFINITIONS) {
if (definition.context !== context) continue;
if (editingCell && !definition.allowWhileEditing) continue;
- if (editingCell && !event.ctrlKey && !event.altKey && !event.metaKey) continue;
+ if (editingCell && !event.ctrlKey && !event.altKey && !event.metaKey && !isFunctionShortcutKey(key)) continue;
const assigned = Array.isArray(bindings?.[definition.action]) ? bindings[definition.action] : definition.defaults;
if (assigned.includes(chord)) return definition.action;
}
@@ -187,6 +167,10 @@ export function shortcutDefinition(action) {
return DEFINITION_BY_ACTION.get(action) ?? null;
}
+export function shortcutLabelForAction(action) {
+ return DEFINITION_BY_ACTION.get(action)?.label ?? "";
+}
+
function normalizeModifier(value) {
const key = String(value ?? "").trim().toLowerCase();
if (key === "ctrl" || key === "control") return "Ctrl";
@@ -236,3 +220,7 @@ function shortcutChordParts(chord) {
function isPrintableShortcutKey(key) {
return key.length === 1 || key === "Space" || key === "Plus" || key === "Minus";
}
+
+function isFunctionShortcutKey(key) {
+ return /^F([1-9]|1\d|2[0-4])$/.test(key);
+}
diff --git a/tests/app-controller.test.js b/tests/app-controller.test.js
index 5ea0e75..77be914 100644
--- a/tests/app-controller.test.js
+++ b/tests/app-controller.test.js
@@ -207,8 +207,8 @@ test("command registry preserves public command labels and availability policy",
const developmentIds = developmentLabels.map(([id]) => id);
assert.deepEqual(
- ["open-file", "open-folder", "save-file", "toggle-lint", "show-problems"].map((id) => productionIds.includes(id)),
- [true, true, true, true, true]
+ ["open-file", "open-folder", "close-all", "save-file", "toggle-lint", "show-problems"].map((id) => productionIds.includes(id)),
+ [true, true, true, true, true, true]
);
assert.equal(new Set(productionIds).size, productionIds.length);
assert.equal(productionIds.includes("load-fixture-20k"), false);
@@ -222,10 +222,12 @@ test("command registry preserves public command labels and availability policy",
assert.deepEqual(calls, ["open-file", "show-problems"]);
assert.equal(canRunCommandWithoutDocument("open-file"), true);
+ assert.equal(canRunCommandWithoutDocument("close-all"), true);
assert.equal(canRunCommandWithoutDocument("show-problems"), true);
assert.equal(canRunCommandWithoutDocument("save-file"), false);
assert.equal(canRunCommandWithoutDocument("go-to-definition"), false);
assert.deepEqual(commandActionForId("open-file"), { type: "handler", name: "openFile" });
+ assert.deepEqual(commandActionForId("close-all"), { type: "handler", name: "closeAll" });
assert.deepEqual(commandActionForId("load-fixture-20k"), { type: "fixture", size: 20000 });
assert.deepEqual(commandActionForId("insert-row"), { type: "handler", name: "insertRows" });
assert.deepEqual(commandActionForId("insert-column"), { type: "handler", name: "insertColumns" });
@@ -732,6 +734,100 @@ test("closing the active tab commits editor changes before checking dirty state"
assert.equal(state.docs.length, 1);
});
+test("Close All removes workspace and standalone tabs after stopping Vector-LSP", async () => {
+ const workspaceDoc = TableDocument.fromText("skills.txt", "skill\nworkspace", {
+ path: "E:\\Mods\\Example\\TXT\\skills.txt"
+ });
+ const standaloneDoc = TableDocument.fromText("notes.txt", "id\nstandalone", {
+ path: "E:\\Notes\\notes.txt"
+ });
+ const events = [];
+ const { controller, state } = testDocumentController([], {}, {
+ workspace: { path: "E:\\Mods\\Example\\TXT", files: [] },
+ lintEngine: "vector-lsp",
+ isVectorLintEngine: () => true,
+ isLegacyLintEngine: () => false,
+ lspOpenDoc: async () => {},
+ lspStopSession: async (reason) => events.push(["stop", reason]),
+ resetWorkspaceView: () => events.push(["reset-view"])
+ });
+
+ await controller.addDocument(workspaceDoc);
+ await controller.addDocument(standaloneDoc);
+ state.active = 0;
+ state.lint.diagnostics = [{ id: "workspace-diagnostic" }];
+
+ assert.equal(await controller.closeAll(), true);
+ assert.equal(state.workspace, null);
+ assert.deepEqual(state.docs, []);
+ assert.equal(state.active, -1);
+ assert.deepEqual(state.lint.diagnostics, []);
+ assert.deepEqual(events, [
+ ["stop", "all-documents-closed"],
+ ["reset-view"]
+ ]);
+});
+
+test("cancelling Close All preserves the workspace and documents without stopping LSP", async () => {
+ const workspaceDoc = TableDocument.fromText("skills.txt", "skill\nchanged", {
+ path: "E:\\Mods\\Example\\TXT\\skills.txt",
+ dirty: true
+ });
+ const stops = [];
+ const workspace = { path: "E:\\Mods\\Example\\TXT", files: [] };
+ const { controller, state } = testDocumentController([], {}, {
+ workspace,
+ lspStopSession: async (reason) => stops.push(reason)
+ });
+ await controller.addDocument(workspaceDoc);
+
+ const closing = controller.closeAll();
+ await waitFor(() => state.docs.length === 1);
+ controller.handleCloseDialogClick(closeChoiceEvent("cancel"));
+
+ assert.equal(await closing, false);
+ assert.equal(state.workspace, workspace);
+ assert.deepEqual(state.docs, [workspaceDoc]);
+ assert.deepEqual(stops, []);
+});
+
+test("Close All applies discard and save choices before removing every tab", async () => {
+ const discarded = TableDocument.fromText("discard.txt", "id\ndiscard", {
+ path: "E:\\Mods\\Example\\TXT\\discard.txt",
+ dirty: true
+ });
+ const saved = TableDocument.fromText("save.txt", "id\nsave", {
+ path: "E:\\Mods\\Example\\TXT\\save.txt",
+ dirty: true
+ });
+ const writes = [];
+ saved.handle = {
+ async createWritable() {
+ return {
+ write: async (text) => writes.push(text),
+ close: async () => {}
+ };
+ }
+ };
+ const { controller, state } = testDocumentController([], {}, {
+ workspace: { path: "E:\\Mods\\Example\\TXT", files: [] }
+ });
+ await controller.addDocument(discarded);
+ await controller.addDocument(saved);
+
+ const closing = controller.closeAll();
+ await waitFor(() => state.docs.length === 2);
+ controller.handleCloseDialogClick(closeChoiceEvent("discard"));
+ await waitFor(() => state.docs.length === 2);
+ controller.handleCloseDialogClick(closeChoiceEvent("save"));
+
+ assert.equal(await closing, true);
+ assert.deepEqual(writes, ["id\nsave"]);
+ assert.deepEqual(state.docs, []);
+ assert.equal(state.active, -1);
+ assert.equal(state.workspace, null);
+});
+
test("closing another tab commits the active editor before switching documents", async () => {
const activeDoc = TableDocument.fromText("active.txt", "id\nactive", { dirty: false });
const closingDoc = TableDocument.fromText("closing.txt", "id\nclosing", { dirty: true });
@@ -826,11 +922,11 @@ test("closing an active Vector tab waits for didClose before rebinding the revea
});
test("context menu command item registries preserve expected command groups", () => {
- assert.deepEqual(columnCommandItems().map((item) => item.id), ["add-column", "insert-column", "hide-column", "delete-column"]);
+ assert.deepEqual(columnCommandItems().map((item) => item.id), ["add-column", "insert-column", "hide-column", "delete-column", "clone-column"]);
assert.deepEqual(fillCommandItems().map((item) => item.id), ["fill", "increment-fill"]);
assert.deepEqual(mathCommandItems().map((item) => item.id), ["math-add", "math-subtract", "math-multiply", "math-divide"]);
const commandSurfaceController = readFileSync(new URL("../src/ui/controllers/command-surface-controller.js", import.meta.url), "utf8");
- assert.match(commandSurfaceController, /label: "Fill"[\s\S]*label: "Math"[\s\S]*id: "go-to-definition"/);
+ assert.match(commandSurfaceController, /tText\("menu\.fill"\)[\s\S]*tText\("menu\.math"\)[\s\S]*id: "go-to-definition"/);
});
test("Resize To Fit keeps the existing all-column command behavior", () => {
@@ -838,15 +934,17 @@ test("Resize To Fit keeps the existing all-column command behavior", () => {
assert.match(gridCommandController, /useSelection\s*\?\s*columnsFromSelection\(\)\s*:\s*indexRange\(0,\s*doc\.columnCount - 1\)/);
});
-test("Open File and Open Folder sidebar buttons are constrained to one line", () => {
+test("file actions use localized labels and Close All appears in the main toolbar", () => {
const html = readFileSync(new URL("../index.html", import.meta.url), "utf8");
const css = readFileSync(new URL("../src/styles.css", import.meta.url), "utf8");
- assert.match(html, /
Open File<\/button>/);
- assert.match(html, /Open Folder<\/button>/);
+ assert.match(html, /Open File<\/button>/);
+ assert.match(html, /Open Folder<\/button>/);
+ assert.match(html, /Save As<\/button>\s*Close All<\/button>/);
assert.match(css, /--sidebar-width:\s*260px/);
assert.match(css, /\.layout-root\s*\{[\s\S]*grid-template-columns:\s*var\(--dock-left-width\) minmax\(var\(--editor-min-width\), 1fr\) var\(--dock-right-width\)/);
assert.match(css, /\.sidebar\s*\{[\s\S]*min-width:\s*0/);
assert.match(css, /\.sidebar-actions button\s*\{[\s\S]*white-space:\s*nowrap/);
+ assert.match(css, /\.empty-state\s*\{[\s\S]*z-index:\s*6/);
assert.equal(MIN_SIDEBAR_WIDTH, 260);
assert.equal(MAX_SIDEBAR_WIDTH, 520);
});
@@ -1082,7 +1180,7 @@ test("dock drop UI is removed and docked controls keep a single-row Problems hea
assert.doesNotMatch(css, /\.problems-panel\[data-dock-edge="left"\] \.lint-controls/);
});
-test("context menu uses one explicit active submenu and exposes Clone Row only", () => {
+test("context menu uses one explicit active submenu and exposes row and column cloning", () => {
const css = readFileSync(new URL("../src/styles.css", import.meta.url), "utf8");
const activeGroup = { dataset: { menuGroup: "Row Operations" } };
const inactiveGroup = { dataset: { menuGroup: "Column Operations" } };
@@ -1092,8 +1190,10 @@ test("context menu uses one explicit active submenu and exposes Clone Row only",
assert.equal(contextMenuGroupIsActive(inactiveGroup, activeGroup), false);
assert.deepEqual(contextMenuHiddenState(), { contextMenuActiveGroup: "", contextMenuOpen: false });
assert.equal(rowCommandItems().some((item) => item.id === "clone-row" && item.label === "Clone Row"), true);
+ assert.equal(columnCommandItems().some((item) => item.id === "clone-column" && item.label === "Clone Column(s)"), true);
assert.equal(rowCommandItems().some((item) => item.label === "Swap Rows"), false);
assert.match(css, /\.menu-group\.active > \.submenu\s*\{\s*display: block;/);
+ assert.match(css, /\.submenu\s*\{[\s\S]*overflow-x:\s*hidden;[\s\S]*overflow-y:\s*auto;/);
assert.doesNotMatch(css, /\.menu-group:hover \.submenu/);
});
@@ -1104,6 +1204,13 @@ test("row context menu orders Clone Row after hide and delete without changing c
assert.deepEqual(rowItems.at(-1), { id: "clone-row", label: "Clone Row", disabled: true });
});
+test("column context menu orders Clone Column(s) after hide and delete", () => {
+ const columnItems = columnCommandItems({ cloneDisabled: true });
+ const ids = columnItems.map((item) => item.id);
+ assert.deepEqual(ids, ["add-column", "insert-column", "hide-column", "delete-column", "clone-column"]);
+ assert.deepEqual(columnItems.at(-1), { id: "clone-column", label: "Clone Column(s)", disabled: true });
+});
+
test("Settings modal exposes immediate visual settings without save cancel apply", () => {
const css = readFileSync(new URL("../src/styles.css", import.meta.url), "utf8");
const controls = appSettingsVisualControls({
@@ -1509,10 +1616,22 @@ test("data-grid scroll shortcuts require an open document and non-text focus", (
function testDocumentController(docOrDocs, gridOverrides = {}, options = {}) {
const hostDocument = { activeElement: null };
const host = { focus: () => { hostDocument.activeElement = host; } };
- const state = { docs: Array.isArray(docOrDocs) ? docOrDocs : [docOrDocs], active: 0, lint: { engine: options.lintEngine ?? "legacy" } };
+ const state = {
+ docs: Array.isArray(docOrDocs) ? docOrDocs : [docOrDocs],
+ active: 0,
+ workspace: options.workspace ?? null,
+ lint: {
+ engine: options.lintEngine ?? "legacy",
+ enabled: options.lintEnabled ?? true,
+ diagnostics: [],
+ status: ""
+ }
+ };
const grid = {
+ autoFitInitialColumns: () => {},
commitEdit: () => {},
draw: () => {},
+ layout: () => {},
setDocument: () => {},
...gridOverrides
};
@@ -1536,17 +1655,20 @@ function testDocumentController(docOrDocs, gridOverrides = {}, options = {}) {
reportLspOpenFailure: () => {},
lspCloseDoc: options.lspCloseDoc ?? (() => {}),
reportLspCloseFailure: () => {},
+ lspStopSession: options.lspStopSession ?? (() => 0),
lspStartWorkspace: options.lspStartWorkspace ?? (() => {}),
ensureDocumentSession: options.ensureDocumentSession ?? (() => {}),
scheduleHoverPrewarm: options.scheduleHoverPrewarm ?? (() => {}),
resetUndoManagerForDocument: () => {},
- resetLegacyWorkspaceIndex: () => {},
+ resetLegacyWorkspaceIndex: options.resetLegacyWorkspaceIndex ?? (() => {}),
scheduleLegacyLintForOpen: options.scheduleLegacyLintForOpen ?? (() => {}),
scheduleLegacyLintFull: () => {},
- cancelLegacyLintJobs: () => {},
+ cancelLegacyLintJobs: options.cancelLegacyLintJobs ?? (() => {}),
isVectorLintEngine: options.isVectorLintEngine ?? (() => false),
isLegacyLintEngine: options.isLegacyLintEngine ?? (() => true),
+ setLintDiagnostics: options.setLintDiagnostics ?? ((diagnostics) => { state.lint.diagnostics = diagnostics; }),
updateGridDiagnostics: () => {},
+ resetWorkspaceView: options.resetWorkspaceView ?? (() => {}),
scrollProblemsToActiveFile: options.scrollProblemsToActiveFile ?? (() => {})
});
return { controller, state, document: hostDocument, host };
diff --git a/tests/app-settings.test.js b/tests/app-settings.test.js
index 5fbfab7..a3ffad0 100644
--- a/tests/app-settings.test.js
+++ b/tests/app-settings.test.js
@@ -7,6 +7,7 @@ import { DEFAULT_GRID_FONT } from "../src/ui/app-settings-policy.js";
import { createInitialAppState } from "../src/ui/app-startup-state.js";
import { DEFAULT_DOCK_LAYOUT } from "../src/ui/dock-layout-policy.js";
import { createSettingsController } from "../src/ui/controllers/settings-controller.js";
+import { t } from "../src/core/i18n.js";
import { installFakeAppStartupDom } from "./helpers/fake-dom-app-startup.mjs";
function escapeHtml(value) {
@@ -57,6 +58,7 @@ function makeSettingsController({
const host = document.createElement("section");
const state = {
theme: "dark",
+ locale: "enUS",
colorizeColumns: true,
mouseResizeLocked: false,
excludeWorkspaceSubfolders: false,
@@ -102,7 +104,7 @@ function makeSettingsController({
resetDockLayout: () => { state.dockLayout = DEFAULT_DOCK_LAYOUT; },
isLegacyLintEngine: () => state.lint.engine === "legacy",
isVectorLintEngine: () => state.lint.engine === LINT_ENGINE_VECTOR,
- effectiveVectorLspHoverEnabled: () => true,
+ effectiveVectorLspHoverEnabled: () => state.lint.enabled && state.lint.engine === LINT_ENGINE_VECTOR,
cancelLegacyLintJobs: () => calls.push("cancel-legacy"),
scheduleLegacyLintFull: (reason, delay) => calls.push(["schedule-legacy", reason, delay]),
legacyLintDisplayActive: () => state.lint.engine === "legacy",
@@ -114,12 +116,19 @@ function makeSettingsController({
lspStarts.push(args);
calls.push("lsp-start");
},
+ stopVectorSession: async (reason) => calls.push(["stop-vector-session", reason]),
ensureDocumentSession: async (options) => calls.push(["ensure-document-session", options]),
resetLegacyWorkspaceIndex: () => calls.push("reset-legacy-workspace-index"),
recordLintEngineEvent: (name) => calls.push(["lint-event", name]),
renderChrome: () => calls.push("render"),
reportBackgroundFailure: (label) => calls.push(["background-failure", label]),
showError: (error) => calls.push(["error", String(error)]),
+ t,
+ setLocale: async (locale) => {
+ state.locale = locale;
+ calls.push(["locale", locale]);
+ return locale;
+ },
escapeHtml
});
return { controller, document, calls, host, lspStarts, state };
@@ -144,6 +153,7 @@ test("App Settings modal renders visual controls in the controller behavior path
assert.equal(document.body.querySelector("#settingsExcludeWorkspaceSubfolders")?.tagName, "INPUT");
assert.equal(document.body.querySelector("#settingsVectorLspHover")?.tagName, "INPUT");
assert.equal(document.body.querySelector("#settingsGridFont")?.tagName, "SELECT");
+ assert.equal(document.body.querySelector("#settingsLocale")?.tagName, "SELECT");
assert.equal(document.body.querySelector("[data-settings-lint-engine='vector-lsp']")?.tagName, "BUTTON");
assert.equal(document.body.querySelector("[data-settings-lint-engine='legacy']")?.tagName, "BUTTON");
assert.equal(document.body.querySelector("[data-settings-theme='dark']")?.tagName, "BUTTON");
@@ -152,6 +162,19 @@ test("App Settings modal renders visual controls in the controller behavior path
assert.equal(document.body.querySelector("[data-settings-close]")?.tagName, "BUTTON");
});
+test("App Settings changes the language immediately", async () => {
+ const { controller, document, calls, state } = makeSettingsController();
+ controller.showAppSettings();
+ const locale = document.body.querySelector("#settingsLocale");
+ locale.value = "koKR";
+ locale.dispatchEvent({ type: "change" });
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ assert.equal(state.locale, "koKR");
+ assert.deepEqual(calls.filter((call) => Array.isArray(call) && call[0] === "locale"), [["locale", "koKR"]]);
+ assert.equal(document.body.querySelector("[data-settings-i18n='settings.language']")?.textContent, "언어");
+});
+
test("workspace subfolder exclusion persists, relists Explorer, and restarts Vector-LSP", async () => {
const workspace = {
path: "E:\\Workspace",
@@ -356,6 +379,23 @@ test("Tauri Lint Options modal renders valid Vector-LSP Browse buttons and actio
await pending;
});
+test("Lint Options recreates itself in the new locale without discarding an open draft", async () => {
+ const { controller, document, state } = makeSettingsController({
+ config: { pluginPath: "E:\\Original" }
+ });
+ const pending = controller.showSettings();
+ assert.ok(await waitForSelector(document, ".settings-modal"));
+ document.body.querySelector("#settingsPluginPath").value = "E:\\Unsaved draft";
+ state.locale = "koKR";
+ document.listeners.get("txteditor-locale-changed").at(-1)({ type: "txteditor-locale-changed" });
+
+ const refreshed = await waitForSelector(document, ".settings-modal");
+ assert.equal(refreshed?.querySelector("h2")?.textContent, "Lint 옵션");
+ assert.equal(document.body.querySelector("#settingsPluginPath")?.value, "E:\\Unsaved draft");
+ await pending;
+ document.body.querySelector("[data-settings-choice='cancel']").click();
+});
+
test("standalone Vector Lint Options save and Restart LSP force-rebind the active document session", async () => {
const { controller, document, calls, state } = makeSettingsController({
lspStarted: true,
@@ -422,10 +462,10 @@ test("Legacy reference changes force a fresh Vector session while lint reactivat
assert.equal(engineSwitch.calls.includes("lsp-start"), false);
const lintEnable = makeSettingsController({ lintEnabled: false });
- lintEnable.controller.toggleLint();
+ await lintEnable.controller.toggleLint();
assert.equal(lintEnable.state.lint.enabled, true);
assert.deepEqual(lintEnable.calls.filter((entry) => Array.isArray(entry) && entry[0] === "ensure-document-session"), [
- ["ensure-document-session", {}]
+ ["ensure-document-session", { forceRestart: true }]
]);
const workspaceSwitch = makeSettingsController({
@@ -441,6 +481,28 @@ test("Legacy reference changes force a fresh Vector session while lint reactivat
]);
});
+test("Lint Off stops Vector-LSP and Lint On rebuilds the workspace session", async () => {
+ const disabled = makeSettingsController({
+ lintEnabled: true,
+ lspStarted: true,
+ workspace: { path: "E:\\Workspace" },
+ diagnostics: [{ id: "old" }]
+ });
+
+ await disabled.controller.toggleLint();
+ assert.equal(disabled.state.lint.enabled, false);
+ assert.deepEqual(disabled.state.lint.diagnostics, []);
+ assert.deepEqual(disabled.calls.filter((entry) => Array.isArray(entry)
+ && entry[0] === "stop-vector-session"), [
+ ["stop-vector-session", "lint-disabled"]
+ ]);
+ assert.equal(disabled.calls.includes("lsp-start"), false);
+
+ await disabled.controller.toggleLint();
+ assert.equal(disabled.state.lint.enabled, true);
+ assert.deepEqual(disabled.lspStarts, [["E:\\Workspace", { includeSubfolders: true }]]);
+});
+
test("rapid Legacy reference selections persist latest-wins and schedule one immediate re-lint", async () => {
const { controller, calls, state } = makeSettingsController({
legacy: true,
diff --git a/tests/clipboard-runtime.test.js b/tests/clipboard-runtime.test.js
index 74cded2..5863d62 100644
--- a/tests/clipboard-runtime.test.js
+++ b/tests/clipboard-runtime.test.js
@@ -14,14 +14,14 @@ function replaceGlobal(name, value) {
};
}
-test("desktop clipboard uses the Tauri plugin instead of permission-gated Web Clipboard APIs", async () => {
+test("desktop clipboard uses bounded native commands instead of permission-gated Web Clipboard APIs", async () => {
const calls = [];
const restoreWindow = replaceGlobal("window", {
__TAURI__: {
core: {
invoke: async (command, args) => {
calls.push({ command, args });
- return command.endsWith("read_text") ? "from desktop" : undefined;
+ return command === "read_clipboard_text" ? "from desktop" : undefined;
}
}
}
@@ -37,8 +37,8 @@ test("desktop clipboard uses the Tauri plugin instead of permission-gated Web Cl
await writeClipboardText("selected cells");
assert.equal(await readClipboardText(), "from desktop");
assert.deepEqual(calls, [
- { command: "plugin:clipboard-manager|write_text", args: { text: "selected cells" } },
- { command: "plugin:clipboard-manager|read_text", args: undefined }
+ { command: "write_clipboard_text", args: { text: "selected cells" } },
+ { command: "read_clipboard_text", args: undefined }
]);
} finally {
restoreNavigator();
@@ -66,15 +66,20 @@ test("browser clipboard retains the Web Clipboard fallback", async () => {
}
});
-test("desktop build registers clipboard text permissions and the plugin", () => {
+test("desktop build registers a serialized native clipboard worker", () => {
const cargo = readFileSync(new URL("../src-tauri/Cargo.toml", import.meta.url), "utf8");
const rust = readFileSync(new URL("../src-tauri/src/lib.rs", import.meta.url), "utf8");
+ const clipboardRust = readFileSync(new URL("../src-tauri/src/clipboard.rs", import.meta.url), "utf8");
const capability = JSON.parse(readFileSync(new URL("../src-tauri/capabilities/default.json", import.meta.url), "utf8"));
- assert.match(cargo, /tauri-plugin-clipboard-manager = "2"/);
- assert.match(rust, /\.plugin\(tauri_plugin_clipboard_manager::init\(\)\)/);
- assert.ok(capability.permissions.includes("clipboard-manager:allow-read-text"));
- assert.ok(capability.permissions.includes("clipboard-manager:allow-write-text"));
+ assert.match(cargo, /arboard = \{ version = "3\.6\.1", features = \["wayland-data-control"\] \}/);
+ assert.doesNotMatch(cargo, /tauri-plugin-clipboard-manager/);
+ assert.match(rust, /manage\(clipboard::ClipboardService::new\(\)\)/);
+ assert.match(rust, /clipboard::read_clipboard_text/);
+ assert.match(rust, /clipboard::write_clipboard_text/);
+ assert.match(clipboardRust, /std::thread::Builder/);
+ assert.doesNotMatch(clipboardRust, /spawn_blocking/);
+ assert.ok(!capability.permissions.some((permission) => permission.startsWith("clipboard-manager:")));
});
function editableDocument() {
@@ -101,8 +106,8 @@ test("copy, paste, and cut use the same command path for frozen and normal cells
__TAURI__: {
core: {
invoke: async (command, args) => {
- if (command.endsWith("write_text")) clipboardText = args.text;
- if (command.endsWith("read_text")) return "pasted";
+ if (command === "write_clipboard_text") clipboardText = args.text;
+ if (command === "read_clipboard_text") return "pasted";
return undefined;
}
}
@@ -141,6 +146,84 @@ test("copy, paste, and cut use the same command path for frozen and normal cells
}
});
+test("a delayed clipboard read cannot paste into a different same-named tab", async () => {
+ let releaseRead;
+ const readPending = new Promise((resolve) => { releaseRead = resolve; });
+ const restoreWindow = replaceGlobal("window", {
+ __TAURI__: { core: { invoke: async (command) => command === "read_clipboard_text" ? readPending : undefined } }
+ });
+ const base = editableDocument();
+ const excel = editableDocument();
+ base.name = excel.name = "skills.txt";
+ base.path = "E:/mod/base/skills.txt";
+ excel.path = "E:/mod/excel/skills.txt";
+ let active = base;
+ let executed = 0;
+ const range = { top: 1, left: 1, bottom: 1, right: 1 };
+ const state = { selection: { ranges: [range], focus: { row: 1, column: 1 } } };
+ const controller = createEditCommandController({
+ state,
+ grid: { draw() {} },
+ activeDoc: () => active,
+ hasOpenDocument: () => true,
+ execute(command) { executed += 1; command.redo(active); },
+ saveSelectionState() {},
+ promptNumber: async () => null,
+ showError(error) { throw new Error(String(error)); }
+ });
+
+ try {
+ const paste = controller.pasteSelection();
+ active = excel;
+ releaseRead("must-not-cross-tabs");
+ await paste;
+ assert.equal(executed, 0);
+ assert.equal(base.getCell(1, 1), "normal-value");
+ assert.equal(excel.getCell(1, 1), "normal-value");
+ } finally {
+ restoreWindow();
+ }
+});
+
+test("a delayed clipboard write cannot cut a different same-named tab", async () => {
+ let releaseWrite;
+ const writePending = new Promise((resolve) => { releaseWrite = resolve; });
+ const restoreWindow = replaceGlobal("window", {
+ __TAURI__: { core: { invoke: async (command) => command === "write_clipboard_text" ? writePending : undefined } }
+ });
+ const base = editableDocument();
+ const excel = editableDocument();
+ base.name = excel.name = "skills.txt";
+ base.path = "E:/mod/base/skills.txt";
+ excel.path = "E:/mod/excel/skills.txt";
+ let active = base;
+ let executed = 0;
+ const range = { top: 1, left: 1, bottom: 1, right: 1 };
+ const state = { selection: { ranges: [range], focus: { row: 1, column: 1 } } };
+ const controller = createEditCommandController({
+ state,
+ grid: { draw() {} },
+ activeDoc: () => active,
+ hasOpenDocument: () => true,
+ execute(command) { executed += 1; command.redo(active); },
+ saveSelectionState() {},
+ promptNumber: async () => null,
+ showError(error) { throw new Error(String(error)); }
+ });
+
+ try {
+ const cut = controller.cutSelection();
+ active = excel;
+ releaseWrite();
+ await cut;
+ assert.equal(executed, 0);
+ assert.equal(base.getCell(1, 1), "normal-value");
+ assert.equal(excel.getCell(1, 1), "normal-value");
+ } finally {
+ restoreWindow();
+ }
+});
+
function tokenList() {
const values = new Set();
return {
diff --git a/tests/core.test.js b/tests/core.test.js
index 9a4c241..976fafc 100644
--- a/tests/core.test.js
+++ b/tests/core.test.js
@@ -181,7 +181,7 @@ test("prewarm is disabled so background hover cannot block user hover", () => {
assert.equal(shouldCancelPrewarmForUserHover(), true);
});
-test("version metadata is bumped to TXTeditor 0.4.7", () => {
+test("release metadata remains 0.4.7 while the README introduction stays version-independent", () => {
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
const lock = JSON.parse(readFileSync(new URL("../package-lock.json", import.meta.url), "utf8"));
const cargoToml = readFileSync(new URL("../src-tauri/Cargo.toml", import.meta.url), "utf8");
@@ -196,5 +196,6 @@ test("version metadata is bumped to TXTeditor 0.4.7", () => {
assert.match(cargoToml, /description = "TXTeditor 0\.4\.7/);
assert.match(cargoLock, /name = "txteditor"\r?\nversion = "0\.4\.7"/);
assert.equal(tauri.version, "0.4.7");
- assert.match(readme, /TXTeditor 0\.4\.7 is/);
+ assert.match(readme, /^# TXTeditor\r?\n\r?\nTXTeditor is a Windows-focused desktop editor/);
+ assert.doesNotMatch(readme, /TXTeditor 0\.4\.7 is/);
});
diff --git a/tests/diagnostics-burst-regression.test.js b/tests/diagnostics-burst-regression.test.js
index f3c1d02..12c2685 100644
--- a/tests/diagnostics-burst-regression.test.js
+++ b/tests/diagnostics-burst-regression.test.js
@@ -57,6 +57,7 @@ function createBurstHarness({ generation = 7, getter, initialDiagnostics = [] }
const counters = {
commits: 0,
getters: 0,
+ gridRedraws: 0,
gridUpdates: 0,
problemsRebuilds: 0,
renders: 0,
@@ -87,9 +88,10 @@ function createBurstHarness({ generation = 7, getter, initialDiagnostics = [] }
}
};
- const updateGridDiagnostics = () => {
+ const updateGridDiagnostics = ({ redraw = true, updateRuler = true } = {}) => {
counters.gridUpdates += 1;
- counters.rulerUpdates += 1;
+ if (redraw) counters.gridRedraws += 1;
+ if (updateRuler) counters.rulerUpdates += 1;
};
const renderChrome = () => {
counters.renders += 1;
@@ -132,7 +134,7 @@ function createBurstHarness({ generation = 7, getter, initialDiagnostics = [] }
};
}
-test("unchanged versioned diagnostics update metadata without rebuilding Problems", async (context) => {
+test("unchanged versioned diagnostics update metadata without redrawing diagnostics UI", async (context) => {
const uri = diagnosticUri(0);
const raw = rawDiagnostics(0, 3);
const initialDiagnostics = raw.map((diagnostic, index) => mapLspDiagnosticToDisplay(diagnostic, {
@@ -152,6 +154,8 @@ test("unchanged versioned diagnostics update metadata without rebuilding Problem
assert.equal(harness.counters.renders, 0);
assert.equal(harness.counters.problemsRebuilds, 0);
assert.equal(harness.counters.gridUpdates, 1);
+ assert.equal(harness.counters.gridRedraws, 0);
+ assert.equal(harness.counters.rulerUpdates, 0);
});
function canonicalRaw(uri, diagnostic) {
@@ -193,7 +197,7 @@ function counterSummary(counters) {
return {
getters: counters.getters,
commits: counters.commits,
- grid: counters.gridUpdates,
+ grid: counters.gridRedraws,
ruler: counters.rulerUpdates,
renders: counters.renders,
problems: counters.problemsRebuilds
@@ -253,7 +257,7 @@ test("Burst A preserves 10,000-diagnostic fingerprint and commits one UI batch",
}
});
-test("Burst B keeps the latest same-URI snapshot when getters finish 2, 3, 1", async (context) => {
+test("Burst B coalesces same-URI snapshots while one getter is in flight", async (context) => {
const uri = diagnosticUri(0);
const gates = new Map();
const harness = createBurstHarness({
@@ -265,20 +269,24 @@ test("Burst B keeps the latest same-URI snapshot when getters finish 2, 3, 1", a
});
try {
- const pending = [];
- for (const sequence of [1, 2, 3]) {
+ const pending = [harness.controller.handleDiagnosticsChanged({
+ generation: harness.state.lsp.generation,
+ sequence: 1,
+ uri
+ })];
+ await waitFor(() => gates.has(1));
+ for (const sequence of [2, 3]) {
pending.push(harness.controller.handleDiagnosticsChanged({
generation: harness.state.lsp.generation,
sequence,
uri
}));
- await waitFor(() => gates.has(sequence));
}
- gates.get(2).resolve(rawDiagnostics(0, 2, 2));
- await new Promise((resolve) => setImmediate(resolve));
- gates.get(3).resolve(rawDiagnostics(0, 3, 3));
- await new Promise((resolve) => setImmediate(resolve));
+ assert.deepEqual([...gates.keys()], [1]);
gates.get(1).resolve(rawDiagnostics(0, 1, 1));
+ await waitFor(() => gates.has(3));
+ assert.equal(gates.has(2), false);
+ gates.get(3).resolve(rawDiagnostics(0, 3, 3));
await Promise.all(pending);
const summary = counterSummary(harness.counters);
@@ -287,7 +295,7 @@ test("Burst B keeps the latest same-URI snapshot when getters finish 2, 3, 1", a
harness.state.lint.diagnostics.map((diagnostic) => diagnostic.data.revision),
[3, 3, 3]
);
- assert.equal(harness.counters.getters, 3);
+ assert.equal(harness.counters.getters, 2);
assert.equal(harness.counters.commits, 1);
} finally {
harness.restore();
diff --git a/tests/diagnostics-ui-optimization-regression.test.js b/tests/diagnostics-ui-optimization-regression.test.js
index 89f2dd1..209ddcf 100644
--- a/tests/diagnostics-ui-optimization-regression.test.js
+++ b/tests/diagnostics-ui-optimization-regression.test.js
@@ -282,12 +282,12 @@ test("Stage 1-E diagnostics-only refresh preserves tab and file DOM while patchi
}
});
-test("product diagnostics commits rebuild Explorer so unopened workspace badges use current diagnostics", () => {
+test("product diagnostics commits use the narrow renderer that updates workspace badges in place", () => {
const appSource = readFileSync(new URL("../src/app.js", import.meta.url), "utf8");
assert.match(
appSource,
- /function renderDiagnosticsChrome\(\)\s*\{\s*return shellController\.renderChrome\(\);\s*\}/,
- "Vector-LSP diagnostics must rebuild the Explorer file list; the partial patch path missed unopened-file badges"
+ /function renderDiagnosticsChrome\(\)\s*\{\s*return shellController\.renderDiagnosticsChrome\(\);\s*\}/,
+ "Vector-LSP diagnostics should update badges and the Problems panel without rebuilding tabs or Explorer"
);
});
@@ -400,7 +400,7 @@ test("Stage 1-D bulk start and sync render once per batch without changing didOp
const startCalls = calls.splice(0);
assert.deepEqual(startCalls.filter(([command]) => command === "lsp_start"), [
- ["lsp_start", { workspacePath: "E:\\Data", generation: 1 }]
+ ["lsp_start", { workspacePath: "E:\\Data", generation: 1, locale: "enUS" }]
]);
assert.deepEqual(
startCalls.filter(([command]) => command === "lsp_open_file").map(([, args]) => ({
diff --git a/tests/grid-policy.test.js b/tests/grid-policy.test.js
index 60fde94..4bea50b 100644
--- a/tests/grid-policy.test.js
+++ b/tests/grid-policy.test.js
@@ -171,6 +171,8 @@ test("grid keyboard selection target preserves tab and arrow navigation semantic
assert.deepEqual(keyboardSelectionTarget({ ...base, key: "Tab" }), { row: 2, column: 3, extend: false });
assert.deepEqual(keyboardSelectionTarget({ ...base, key: "Tab", shiftKey: true }), { row: 2, column: 1, extend: false });
+ assert.equal(keyboardSelectionTarget({ ...base, key: "Tab", ctrlKey: true }), null);
+ assert.equal(keyboardSelectionTarget({ ...base, key: "Tab", ctrlKey: true, shiftKey: true }), null);
assert.deepEqual(keyboardSelectionTarget({ ...base, key: "ArrowDown", shiftKey: true }), { row: 3, column: 2, extend: true });
assert.deepEqual(keyboardSelectionTarget({ ...base, key: "ArrowRight", ctrlKey: true }), { row: 2, column: 3, extend: false });
assert.deepEqual(keyboardSelectionTarget({ ...base, key: "ArrowUp", ctrlKey: true }), { row: 0, column: 2, extend: false });
diff --git a/tests/i18n-source-audit.test.js b/tests/i18n-source-audit.test.js
new file mode 100644
index 0000000..136d875
--- /dev/null
+++ b/tests/i18n-source-audit.test.js
@@ -0,0 +1,84 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import test from "node:test";
+
+const source = (path) => readFileSync(new URL(`../${path}`, import.meta.url), "utf8");
+
+// This is deliberately an explicit location audit, not a broad English-word regex:
+// game identifiers, command IDs, protocol strings, and developer diagnostics remain allowed.
+const PRODUCT_SURFACE_AUDIT = [
+ ["index.html", [
+ 'data-command="open-file" data-i18n="toolbar.openFile"',
+ 'data-command="open-folder" data-i18n="toolbar.openFolder"',
+ 'data-command="close-all" data-i18n="toolbar.closeAll"',
+ 'data-i18n-aria-label="aria.tableEditor"',
+ 'data-i18n-placeholder="palette.placeholder"',
+ 'data-i18n="dialog.saveChanges"'
+ ]],
+ ["src/ui/controllers/command-surface-controller.js", [
+ 'tText("menu.columnOperations")', 'tText("menu.rowOperations")',
+ 'tText("menu.goToDefinition")', 'txteditor-locale-changed'
+ ]],
+ ["src/ui/controllers/shell-controller.js", [
+ 'tText("lint.on")', 'tText("theme.lightMode")', 'tText("activity.explorer")',
+ 'tText("tab.unsavedChanges")', 'tText("common.close")'
+ ]],
+ ["src/ui/problems-policy.js", [
+ 't("problems.jsonReadOnly")', 'tText("lint.summaryCountsProfile"',
+ '"lint.noProblemsFiles"'
+ ]],
+ ["src/ui/controllers/document-controller.js", [
+ 't("error.openFolderDesktop")', 't("error.noOpenFile")'
+ ]],
+ ["src/ui/controllers/edit-command-controller.js", [
+ 't("error.clipboardCopy"', 't("error.clipboardPaste"'
+ ]],
+ ["src/ui/controllers/grid-command-controller.js", [
+ 't("error.cloneRows")', 't("error.cloneColumns")'
+ ]],
+ ["src/ui/controllers/settings-controller.js", [
+ 'translate("settings.pluginFolder")', 'translate("settings.jsonDiagnostics")',
+ 'translate("settings.restartLsp")', 'txteditor-locale-changed',
+ 'lintRuleGroupsForProfile(state.lint.legacy.settings.profile, state.locale)'
+ ]],
+ ["src/ui/controllers/legacy-lint-controller.js", [
+ 'runLintWithWorkspaceIndex(index, state.lint.legacy.settings, { locale: state.locale })'
+ ]],
+ ["src/ui/controllers/shortcut-settings-controller.js", [
+ 'tText("shortcut.hint")', 'data-shortcut-i18n',
+ 'txteditor-locale-changed'
+ ]],
+ ["src/ui/shortcut-policy.js", [
+ 'tText("shortcut.validationNonModifier")',
+ 'tText("shortcut.validationReserved", { key })',
+ 'tText("shortcut.validationModifier")'
+ ]],
+ ["src/ui/codemirror-json-editor-entry.js", [
+ 'EditorState.phrases.of(jsonEditorPhrases())',
+ 'refreshJsonEditorLocale', 'tText("json.goToLine")'
+ ]],
+ ["src/ui/controllers/locale-controller.js", [
+ 'refreshJsonEditorLocale();'
+ ]],
+ ["src/core/lint-basic-rules.js", [
+ 'legacyMessage("basic.missileRangeInteger")',
+ 'legacyMessage("basic.invalidLevel"',
+ 'legacyMessage("basic.levelOrder"',
+ 'legacyMessage("basic.referenceNotFound"'
+ ]]
+];
+
+test("product-facing source audit requires stable i18n keys at audited UI and lint locations", () => {
+ for (const [path, required] of PRODUCT_SURFACE_AUDIT) {
+ const contents = source(path);
+ for (const token of required) assert.ok(contents.includes(token), `${path} must contain ${token}`);
+ }
+ const i18n = source("src/core/i18n.js");
+ assert.doesNotMatch(i18n, /sidebar-actions \[data-command/);
+ assert.match(i18n, /data-i18n-aria-label/);
+ assert.match(i18n, /"settings\.jsonDiagnostics"/);
+ assert.match(i18n, /"shortcut\.validationReserved"/);
+ assert.match(i18n, /"json\.goToLine"/);
+ assert.doesNotMatch(source("src/ui/controllers/settings-controller.js"), />Lint Options);
+ assert.doesNotMatch(source("src/ui/controllers/settings-controller.js"), />Restart LSP);
+});
diff --git a/tests/i18n.test.js b/tests/i18n.test.js
new file mode 100644
index 0000000..3903488
--- /dev/null
+++ b/tests/i18n.test.js
@@ -0,0 +1,143 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import {
+ DEFAULT_LOCALE,
+ LOCALE_STORAGE_KEY,
+ SUPPORTED_LOCALES,
+ catalogs,
+ catalogValidation,
+ normalizeLocale,
+ readLocale,
+ saveLocale,
+ setLocale,
+ t,
+ tText,
+ localizedMessage,
+ resolveLocalizedMessage,
+ validateCatalogs
+} from "../src/core/i18n.js";
+import { createLocaleController } from "../src/ui/controllers/locale-controller.js";
+
+function storage(seed = {}) {
+ const values = new Map(Object.entries(seed));
+ return {
+ getItem(key) { return values.get(key) ?? null; },
+ setItem(key, value) { values.set(key, String(value)); }
+ };
+}
+
+test("i18n supports the complete locale contract and falls back to enUS", () => {
+ assert.deepEqual(SUPPORTED_LOCALES, ["enUS", "zhTW", "deDE", "esES", "frFR", "itIT", "koKR", "plPL", "esMX", "jaJP", "ptBR", "ruRU", "zhCN"]);
+ assert.equal(DEFAULT_LOCALE, "enUS");
+ assert.equal(normalizeLocale("ko-KR"), "koKR");
+ assert.equal(normalizeLocale("not-a-locale"), "enUS");
+ assert.deepEqual(catalogValidation, { keys: Object.keys(catalogs.enUS).length, locales: 13 });
+ assert.doesNotThrow(() => validateCatalogs());
+ assert.notDeepEqual(catalogs.koKR, catalogs.enUS);
+ assert.notDeepEqual(catalogs.zhCN, catalogs.enUS);
+});
+
+test("i18n persists locale and escapes named parameters without changing source values", () => {
+ const memory = storage({ [LOCALE_STORAGE_KEY]: "bad" });
+ assert.equal(readLocale(memory), "enUS");
+ assert.equal(saveLocale("zhCN", memory), "zhCN");
+ assert.equal(readLocale(memory), "zhCN");
+ assert.equal(t("lint.lintingProfile", { profile: "" }, "koKR"), "<calc&itemtype> 검사 중...");
+ assert.equal(t("lint.lintingProfile", { profile: "gem" }, "zhCN").includes("gem"), true);
+});
+
+test("command, prompt, search, and shortcut catalogs do not silently copy English UI text", () => {
+ const productKeys = Object.keys(catalogs.enUS).filter((key) => /^(command|prompt|search|shortcut)\./.test(key)
+ && key !== "search.cellStatus");
+ for (const locale of SUPPORTED_LOCALES.filter((locale) => locale !== "enUS")) {
+ for (const key of productKeys) {
+ assert.notEqual(catalogs[locale][key], catalogs.enUS[key], `${locale}:${key} must be localized`);
+ }
+ }
+});
+
+test("locale catalogs only retain deliberate product, protocol, or universal UI tokens", () => {
+ const allowedEnglishCopies = new Set([
+ "app.title",
+ "settings.vectorEngine",
+ "search.cellStatus",
+ "common.ok"
+ ]);
+ for (const locale of SUPPORTED_LOCALES.filter((locale) => locale !== "enUS")) {
+ for (const [key, english] of Object.entries(catalogs.enUS)) {
+ if (allowedEnglishCopies.has(key)) continue;
+ assert.notEqual(catalogs[locale][key], english, `${locale}:${key} must not duplicate English`);
+ }
+ }
+});
+
+test("locale changes re-run Legacy lint and force a Vector-LSP restart without editing documents", async () => {
+ const memory = storage();
+ const ownerDocument = { documentElement: {}, querySelectorAll: () => [], querySelector: () => null };
+ const calls = [];
+ const state = {
+ locale: "enUS",
+ lsp: { started: true, workspacePath: "E:\\Workspace", contextMode: "workspace", referenceRootPath: "", includeSubfolders: true }
+ };
+ const controller = createLocaleController({
+ state,
+ storage: memory,
+ ownerDocument,
+ legacyActive: () => false,
+ scheduleLegacyLintFull: (...args) => calls.push(["legacy", ...args]),
+ lspController: {
+ invalidateHover: (...args) => calls.push(["hover", ...args]),
+ startWorkspace: async (...args) => calls.push(["start", ...args]),
+ ensureStandaloneSession: async (...args) => calls.push(["standalone", ...args])
+ },
+ activeDoc: () => ({ name: "items.txt" }),
+ setLintDiagnostics: (diagnostics) => calls.push(["diagnostics", diagnostics]),
+ updateGridDiagnostics: () => calls.push(["grid"]),
+ renderChrome: () => calls.push(["render"]),
+ refreshJsonEditorLocale: () => calls.push(["json-editor-locale"])
+ });
+
+ await controller.setLocale("zhCN");
+ assert.equal(state.locale, "zhCN");
+ assert.equal(ownerDocument.documentElement.lang, "zh-CN");
+ assert.equal(readLocale(memory), "zhCN");
+ assert.deepEqual(calls.find(([kind]) => kind === "start"), ["start", "E:\\Workspace", {
+ forceRestart: true,
+ contextMode: "workspace",
+ referenceRootPath: "",
+ includeSubfolders: true
+ }]);
+ assert.equal(calls.some(([kind]) => kind === "legacy"), false);
+ assert.equal(calls.some(([kind]) => kind === "json-editor-locale"), true);
+
+ const legacyCalls = [];
+ const legacy = createLocaleController({
+ state: { locale: "enUS", lsp: {} }, storage: memory, ownerDocument,
+ legacyActive: () => true,
+ scheduleLegacyLintFull: (...args) => legacyCalls.push(args),
+ lspController: {}, activeDoc: () => null,
+ setLintDiagnostics: () => {}, updateGridDiagnostics: () => {}, renderChrome: () => {}
+ });
+ await legacy.setLocale("koKR");
+ assert.deepEqual(legacyCalls, [["locale-changed", 0]]);
+ setLocale("enUS", { storage: memory });
+});
+
+test("Legacy lint messages use stable keys while keeping original column names intact", () => {
+ const params = { column: "unexpected" };
+ assert.equal(tText("legacy.nonStandardColumn", params, "koKR"), "비표준 열 \"unexpected\"을(를) 찾았습니다.");
+ assert.equal(tText("legacy.nonStandardColumn", params, "zhCN"), "发现非标准列 \"unexpected\"。");
+ assert.equal(t("legacy.nonStandardColumn", params, "koKR").includes("unexpected<calc>"), true);
+});
+
+test("localized diagnostic descriptors keep stable keys and named source values", () => {
+ const descriptor = localizedMessage("legacy.referenceNotFound", {
+ field: "calc",
+ value: "",
+ label: "rune"
+ });
+ assert.equal(descriptor.key, "legacy.referenceNotFound");
+ assert.deepEqual(descriptor.params, { field: "calc", value: "", label: "rune" });
+ assert.equal(resolveLocalizedMessage(descriptor, "koKR").includes(""), true);
+ assert.equal(t("legacy.referenceNotFound", descriptor.params, "koKR").includes("<itemtype>"), true);
+});
diff --git a/tests/issue-61-json-go-to-line.test.js b/tests/issue-61-json-go-to-line.test.js
new file mode 100644
index 0000000..96a3fff
--- /dev/null
+++ b/tests/issue-61-json-go-to-line.test.js
@@ -0,0 +1,28 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import test from "node:test";
+
+test("JSON editor routes Ctrl-g to the singleton go-to-line command before the search keymap", () => {
+ const source = readFileSync(
+ new URL("../src/ui/codemirror-json-editor-entry.js", import.meta.url),
+ "utf8"
+ );
+
+ assert.match(
+ source,
+ /import\s*\{[\s\S]*?\bgotoLine\b[\s\S]*?\}\s*from "@codemirror\/search";/
+ );
+ assert.match(
+ source,
+ /function openJsonGotoLine\(view\) \{[\s\S]*?gotoLine\(view\);[\s\S]*?\}/
+ );
+
+ const goToLineBinding = source.indexOf('{ key: "Ctrl-g", run: openJsonGotoLine }');
+ const searchKeymap = source.indexOf("...searchKeymap");
+ assert.notEqual(goToLineBinding, -1, "the JSON editor must bind Ctrl-g to the singleton wrapper");
+ assert.notEqual(searchKeymap, -1, "the search keymap must remain installed");
+ assert.ok(
+ goToLineBinding < searchKeymap,
+ "the explicit Ctrl-g binding must precede searchKeymap's Mod-g find-next binding"
+ );
+});
diff --git a/tests/issue-62-json-diagnostic-crlf.test.js b/tests/issue-62-json-diagnostic-crlf.test.js
new file mode 100644
index 0000000..e9e4a77
--- /dev/null
+++ b/tests/issue-62-json-diagnostic-crlf.test.js
@@ -0,0 +1,34 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import test from "node:test";
+
+import { lspRangeToJsonOffsets } from "../src/core/json-document.js";
+
+test("JSON diagnostic navigation uses CodeMirror offsets for CRLF documents", () => {
+ const rawText = "\u{feff}[\r\n {\"Key\":\"first\"},\r\n {\"Key\":\"??\"}\r\n]\r\n";
+ const editorText = rawText.replaceAll("\r\n", "\n");
+ const range = {
+ start: { line: 2, character: 10 },
+ end: { line: 2, character: 12 }
+ };
+ const expectedStart = editorText.indexOf("??");
+
+ assert.deepEqual(lspRangeToJsonOffsets(editorText, range), {
+ start: expectedStart,
+ end: expectedStart + 2
+ });
+ assert.deepEqual(lspRangeToJsonOffsets(rawText, range), {
+ start: expectedStart + 2,
+ end: expectedStart + 4
+ });
+
+ const controllerSource = readFileSync(
+ new URL("../src/ui/controllers/json-editor-controller.js", import.meta.url),
+ "utf8"
+ );
+ assert.match(
+ controllerSource,
+ /lspRangeToJsonOffsets\(\s*view\.state\.doc\.toString\(\),\s*range\s*\)/
+ );
+ assert.doesNotMatch(controllerSource, /lspRangeToJsonOffsets\(doc\.text, range\)/);
+});
diff --git a/tests/issue-66-json-panel-controls.test.js b/tests/issue-66-json-panel-controls.test.js
new file mode 100644
index 0000000..986eab7
--- /dev/null
+++ b/tests/issue-66-json-panel-controls.test.js
@@ -0,0 +1,60 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import test from "node:test";
+
+const source = readFileSync(
+ new URL("../src/ui/codemirror-json-editor-entry.js", import.meta.url),
+ "utf8"
+);
+
+test("JSON search and go-to-line panels use TXTeditor UI sizing", () => {
+ assert.ok(source.includes("fontFamily: '\"Segoe UI\", Arial, sans-serif'"));
+ assert.match(
+ source,
+ /"\.cm-panels": \{[\s\S]*?fontSize: "13px"/
+ );
+ assert.match(
+ source,
+ /"\.cm-panel \.cm-textfield": \{[\s\S]*?height: "28px"/
+ );
+ assert.match(
+ source,
+ /"\.cm-panel label": \{[\s\S]*?fontSize: "13px"/
+ );
+ assert.match(
+ source,
+ /"\.cm-panel button": \{[\s\S]*?minHeight: "28px"/
+ );
+});
+
+test("JSON panel keyboard handling closes panels and restores editor focus", () => {
+ assert.match(source, /ViewPlugin\.fromClass/);
+ assert.match(
+ source,
+ /addEventListener\("keydown", this\.handleKeydown, true\)/
+ );
+ assert.match(
+ source,
+ /removeEventListener\("keydown", this\.handleKeydown, true\)/
+ );
+ assert.match(
+ source,
+ /event\.key !== "Escape"[\s\S]*?isRepeatedJsonPanelShortcut/
+ );
+ assert.match(source, /closeSearchPanel\(view\)/);
+ assert.match(source, /querySelector\("\.cm-dialog-close"\)/);
+ assert.match(source, /view\.focus\(\)/);
+});
+
+test("JSON go-to-line reuses an existing dialog before invoking CodeMirror", () => {
+ assert.match(
+ source,
+ /function focusOpenJsonGotoLine\(view\) \{[\s\S]*?\.cm-dialog input\[name='line'\][\s\S]*?input\.focus\(\);[\s\S]*?input\.select\(\);/
+ );
+ assert.match(
+ source,
+ /function openJsonGotoLine\(view\) \{\s*if \(focusOpenJsonGotoLine\(view\)\) return true;\s*const opened = gotoLine\(view\);/
+ );
+ assert.match(source, /\{ key: "Ctrl-g", run: openJsonGotoLine \}/);
+ assert.match(source, /\{ key: "Mod-Alt-g", run: openJsonGotoLine \}/);
+});
diff --git a/tests/json-document-tabs.test.js b/tests/json-document-tabs.test.js
index ab50a5b..bb98498 100644
--- a/tests/json-document-tabs.test.js
+++ b/tests/json-document-tabs.test.js
@@ -494,6 +494,7 @@ test("JSON editor controller retains state and navigates an exact diagnostic ran
let selected = null;
let cleared = 0;
let measured = 0;
+ let replaceOpened = 0;
const fakeModule = {
createJsonEditorState({ text }) {
return { doc: { length: text.length, toString: () => text } };
@@ -511,6 +512,7 @@ test("JSON editor controller retains state and navigates an exact diagnostic ran
undoJsonEditor() { return true; },
redoJsonEditor() { return true; },
openJsonSearch() { return true; },
+ openJsonReplace() { replaceOpened += 1; return true; },
findNextJson() { return true; },
findPreviousJson() { return true; },
selectAllJson() { return true; }
@@ -540,6 +542,8 @@ test("JSON editor controller retains state and navigates an exact diagnostic ran
assert.equal(cleared, 0, "ordinary editor selection changes must not clear the problem highlight");
controller.refreshAppearance();
assert.equal(measured, 1);
+ assert.equal(controller.openReplace(), true);
+ assert.equal(replaceOpened, 1);
controller.reconcileDiagnosticHighlight([]);
assert.equal(cleared, 1);
assert.equal(doc.activeDiagnosticId, null);
diff --git a/tests/legacy-lint-i18n.test.js b/tests/legacy-lint-i18n.test.js
new file mode 100644
index 0000000..a605647
--- /dev/null
+++ b/tests/legacy-lint-i18n.test.js
@@ -0,0 +1,152 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import { test } from "node:test";
+import { createDefaultLintSettings, runLint } from "../src/core/lint-engine.js";
+import {
+ LEGACY_LINT_LOCALES,
+ legacyLintCatalogs,
+ legacyMessage,
+ legacyMessageText,
+ legacyRuleMetadata,
+ legacyTerm,
+ resolveLegacyMessage,
+ validateLegacyLintCatalogs
+} from "../src/core/legacy-lint-i18n.js";
+import { TableDocument } from "../src/core/table-model.js";
+
+const LINT_SOURCES = [
+ "src/core/lint-basic-rules.js",
+ "src/core/lint-cube-rules.js",
+ "src/core/lint-item-rules.js",
+ "src/core/lint-level-rules.js",
+ "src/core/lint-misc-rules.js",
+ "src/core/lint-treasure-rules.js"
+];
+
+function tokens(template) {
+ return [...String(template).matchAll(/\{([A-Za-z][A-Za-z0-9_]*)\}/g)].map((match) => match[1]).sort();
+}
+
+test("Legacy lint catalogs have complete locale and named-argument parity", () => {
+ const validation = validateLegacyLintCatalogs();
+ assert.equal(validation.locales, LEGACY_LINT_LOCALES.length);
+ for (const locale of LEGACY_LINT_LOCALES) {
+ assert.deepEqual(Object.keys(legacyLintCatalogs[locale]).sort(), Object.keys(legacyLintCatalogs.enUS).sort(), locale);
+ for (const [key, english] of Object.entries(legacyLintCatalogs.enUS)) {
+ assert.deepEqual(tokens(legacyLintCatalogs[locale][key]), tokens(english), `${locale}:${key}`);
+ if (locale !== "enUS") assert.notEqual(legacyLintCatalogs[locale][key], english, `${locale}:${key}`);
+ }
+ const metadata = legacyRuleMetadata("Cube/ValidInputs", locale);
+ assert.ok(metadata.label);
+ assert.ok(metadata.note);
+ }
+});
+
+test("Legacy lint message rendering preserves values, escapes only on request, and keeps identities locale-invariant", () => {
+ const descriptor = legacyMessage("basic.duplicate", { column: "id", value: "&", previousRow: 2 });
+ assert.match(resolveLegacyMessage(descriptor, "koKR"), /&/);
+ assert.match(resolveLegacyMessage(descriptor, "koKR", { escape: true }), /<mod>&/);
+
+ const document = TableDocument.fromText("monai.txt", "AI\n&\n&");
+ const settings = createDefaultLintSettings();
+ const english = runLint([document], settings, { locale: "enUS" });
+ const korean = runLint([document], settings, { locale: "koKR" });
+ assert.equal(korean.length, english.length);
+ assert.deepEqual(korean.map(({ id, messageKey, messageArgs }) => ({ id, messageKey, messageArgs })), english.map(({ id, messageKey, messageArgs }) => ({ id, messageKey, messageArgs })));
+ assert.notEqual(korean[0].message, english[0].message);
+});
+
+test("Korean Legacy Lint catalog has complete native copy without mechanical particles", () => {
+ const korean = legacyLintCatalogs.koKR;
+ assert.equal(Object.keys(korean).length, 86);
+ for (const [key, template] of Object.entries(korean)) {
+ assert.equal(template.includes("(은)"), false, key);
+ assert.equal(template.includes("(는)"), false, key);
+ assert.equal(template.includes("(이)"), false, key);
+ assert.equal(template.includes("(가)"), false, key);
+ assert.equal(template.includes("(을)"), false, key);
+ assert.equal(template.includes("(를)"), false, key);
+ assert.equal(["}은", "}는", "}이", "}가", "}을", "}를", "\"은", "\"는", "\"이", "\"가", "\"을", "\"를"].some((suffix) => template.includes(suffix)), false, key);
+ }
+ assert.match(korean["items.chargeCap"], /최대 충전 횟수/);
+ assert.match(korean["basic.unknownSummode"], /summode/);
+ assert.match(korean["cube.stopsAfterModifier"], /기본 아이템/);
+ assert.match(legacyRuleMetadata("TC/ValidNegativePicks", "koKR").label, /음수 Picks/);
+});
+
+test("High-risk Legacy Lint translations preserve identifiers and game semantics", () => {
+ const sentinels = {
+ zhTW: { range: "超出", charge: "最大充能次數", socket: "限制有效插槽數" },
+ deDE: { range: "außerhalb", charge: "Ladungen", socket: "begrenzt" },
+ esES: { range: "fuera", charge: "cargas máximas", socket: "limita" },
+ frFR: { range: "hors", charge: "nombre maximal de charges", socket: "limite" },
+ itIT: { range: "fuori", charge: "numero massimo di cariche", socket: "limita" },
+ koKR: { range: "벗어났습니다", storage: "벗어나", charge: "최대 충전 횟수", socket: "상한으로 제한" },
+ plPL: { range: "poza", charge: "liczba ładunków", socket: "ogranicza" },
+ esMX: { range: "fuera", charge: "cargas máximas", socket: "limita" },
+ jaJP: { range: "範囲外", charge: "最大チャージ数", socket: "制限" },
+ ptBR: { range: "fora", charge: "máximo de cargas", socket: "limita" },
+ ruRU: { range: "вне диапазона", charge: "число зарядов", socket: "ограничивает" },
+ zhCN: { range: "超出", charge: "最大充能次数", socket: "限制有效插槽数" }
+ };
+ for (const [locale, expected] of Object.entries(sentinels)) {
+ const catalog = legacyLintCatalogs[locale];
+ assert.match(catalog["basic.integerBacktick"], /`/, `${locale}:backtick`);
+ assert.match(catalog["basic.missileRangeInteger"], /missiles\.range/, `${locale}:missiles.range`);
+ assert.match(catalog["basic.unknownSummode"], /summode/, `${locale}:summode`);
+ assert.match(catalog["cube.inputQtyRange"], new RegExp(expected.range, "i"), `${locale}:outside-range`);
+ assert.match(catalog["cube.storageRange"], new RegExp(expected.storage ?? expected.range, "i"), `${locale}:storage-range`);
+ assert.match(catalog["cube.stopsAfterModifier"], /\{stoppedAt\}/, `${locale}:stops-after`);
+ assert.match(catalog["cube.stopsAfterModifierConditional"], /\{suffixLabel\}/, `${locale}:conditional-stops-after`);
+ assert.match(catalog["items.chargeCap"], new RegExp(expected.charge, "i"), `${locale}:charges`);
+ assert.match(catalog["items.typeSocketCap"], new RegExp(expected.socket, "i"), `${locale}:socket-clamp`);
+ assert.match(catalog["items.inventorySocketCap"], new RegExp(expected.socket, "i"), `${locale}:inventory-socket-clamp`);
+ }
+ for (const locale of ["zhTW", "zhCN"]) {
+ assert.match(legacyLintCatalogs[locale]["cube.outputModifierRange"], /Cube/, `${locale}:cube-identifier`);
+ assert.doesNotMatch(legacyLintCatalogs[locale]["cube.outputModifierRange"], /多維資料集|多维数据集/, `${locale}:not-data-cube`);
+ }
+});
+
+test("Dynamic Legacy Lint terms resolve in the selected locale instead of injecting English", () => {
+ const maximum = legacyMessage("items.valueMaximum", {
+ column: "max",
+ label: legacyTerm("chargedSkillLevel"),
+ value: 80,
+ maximum: 63
+ });
+ assert.match(resolveLegacyMessage(maximum, "koKR"), /충전 스킬 레벨/);
+ assert.doesNotMatch(resolveLegacyMessage(maximum, "koKR"), /charged-skill level/);
+
+ const cycle = legacyMessage("misc.chainCycle", {
+ id: "A",
+ hop: 2,
+ hopLabel: legacyTerm("hops"),
+ chain: "A -> A"
+ });
+ assert.match(resolveLegacyMessage(cycle, "koKR"), /2단계/);
+ assert.doesNotMatch(resolveLegacyMessage(cycle, "koKR"), /hops/);
+
+ const treasure = legacyMessageText("treasure.unknownReference", {
+ className: legacyTerm("treasureClass"),
+ value: "bad"
+ }, "koKR");
+ assert.match(treasure, /보물 등급/);
+ assert.doesNotMatch(treasure, /Treasure Class/);
+
+ const fixed4 = legacyMessage("basic.fixed4Unknown", {
+ value: "abc␠",
+ effective: "abc␠",
+ legend: legacyTerm("spaceLegend")
+ });
+ assert.match(resolveLegacyMessage(fixed4, "koKR"), /공백/);
+ assert.doesNotMatch(resolveLegacyMessage(fixed4, "koKR"), /space/);
+});
+
+test("Legacy lint rule sources use message descriptors instead of inline user-facing messages", () => {
+ for (const path of LINT_SOURCES) {
+ const source = readFileSync(path, "utf8");
+ assert.doesNotMatch(source, /ctx\.add\([^,\n]+,[^,\n]+,[^,\n]+,\s*(?:`|\")/);
+ assert.doesNotMatch(source, /rule\([^\n]*,\s*(?:`|\")[A-Z][^\n]*(?:`|\")/);
+ }
+});
diff --git a/tests/lsp-lifecycle-regression.test.js b/tests/lsp-lifecycle-regression.test.js
index fdfaa9f..bdfc639 100644
--- a/tests/lsp-lifecycle-regression.test.js
+++ b/tests/lsp-lifecycle-regression.test.js
@@ -84,6 +84,57 @@ function createState(doc, engine = "vector-lsp") {
};
}
+test("stopping a Vector session invalidates in-flight work and stops only its native generation", async () => {
+ const originalWindow = globalThis.window;
+ const doc = TableDocument.fromText("states.txt", "state\nnone", {
+ path: "E:\\excel\\states.txt",
+ dirty: false
+ });
+ const state = createState(doc);
+ state.lsp = {
+ started: true,
+ generation: 7,
+ workspacePath: "E:\\excel",
+ workspaceKey: "workspace",
+ contextMode: "workspace",
+ referenceRootPath: "",
+ includeSubfolders: true,
+ readiness: "ready",
+ openFileCount: 1
+ };
+ Object.assign(lspDocumentState(doc), {
+ opened: true,
+ openedUri: docToUri(doc),
+ sessionGeneration: 7,
+ version: 2
+ });
+ const calls = [];
+ globalThis.window = {
+ __TAURI__: {
+ core: {
+ invoke: async (command, args) => {
+ calls.push([command, args]);
+ return command === "lsp_stop" ? 1 : undefined;
+ }
+ }
+ }
+ };
+
+ try {
+ const controller = createLspHarness(state, doc);
+ assert.equal(await controller.stopSession("lint-disabled"), 1);
+ assert.deepEqual(calls, [["lsp_stop", { generation: 7 }]]);
+ assert.equal(state.lsp.started, false);
+ assert.equal(state.lsp.generation, 8);
+ assert.equal(state.lsp.readiness, "stopped");
+ assert.equal(state.lsp.workspacePath, "");
+ assert.equal(lspDocumentState(doc).opened, false);
+ } finally {
+ if (originalWindow === undefined) delete globalThis.window;
+ else globalThis.window = originalWindow;
+ }
+});
+
test("V-TXT-05 Legacy edits receive one full Vector resync without duplicate didOpen", async () => {
const originalWindow = globalThis.window;
const doc = TableDocument.fromText("skills.txt", "id\nOLD", {
@@ -712,6 +763,139 @@ test("V-TXT-07 workspace session policy distinguishes start, restart, and canoni
}).action, "sync");
});
+test("tab activation in the current workspace opens only the selected document", async () => {
+ const originalWindow = globalThis.window;
+ const doc = TableDocument.fromText("skills.txt", "id\n1", {
+ path: "E:\\Data\\skills.txt"
+ });
+ const state = createState(doc);
+ state.lsp = {
+ started: true,
+ generation: 17,
+ readiness: "ready",
+ workspacePath: "E:\\Data",
+ workspaceKey: "e:/data",
+ contextMode: "workspace",
+ referenceRootPath: "",
+ includeSubfolders: true,
+ openFileCount: 1
+ };
+ resetLspDocumentState(doc, { version: 1 });
+ Object.assign(lspDocumentState(doc), {
+ opened: true,
+ openedUri: docToUri(doc),
+ openedVersion: 1,
+ syncedRevision: tableFileState(doc).revision,
+ sessionGeneration: 17,
+ ready: true,
+ diagnosticsReady: true,
+ hoverReady: true
+ });
+ const calls = [];
+ globalThis.window = {
+ __TAURI__: {
+ core: {
+ invoke: async (command, args) => {
+ calls.push([command, args]);
+ throw new Error(`unexpected invoke: ${command}`);
+ }
+ },
+ event: { listen: async () => () => {} }
+ }
+ };
+
+ try {
+ const controller = createLspHarness(state, doc);
+ await controller.ensureStandaloneSession(doc);
+ assert.deepEqual(calls, []);
+ } finally {
+ resetLspDocumentState(doc);
+ if (originalWindow === undefined) delete globalThis.window;
+ else globalThis.window = originalWindow;
+ }
+});
+
+test("diagnostic events arriving during a batch drain without overlapping getters", async () => {
+ const originalWindow = globalThis.window;
+ const docs = ["a", "b"].map((stem) => TableDocument.fromText(
+ `${stem}.txt`,
+ "id\n1",
+ { path: `E:\\Data\\${stem}.txt` }
+ ));
+ const state = createState(docs[0]);
+ state.docs = docs;
+ state.lsp = { started: true, generation: 18, readiness: "ready", openFileCount: 2 };
+ for (const doc of docs) {
+ resetLspDocumentState(doc, { version: 1 });
+ Object.assign(lspDocumentState(doc), {
+ opened: true,
+ openedUri: docToUri(doc),
+ openedVersion: 1,
+ syncedRevision: tableFileState(doc).revision,
+ sessionGeneration: 18,
+ ready: true,
+ diagnosticsReady: true,
+ hoverReady: true
+ });
+ }
+ const firstGetterGate = deferred();
+ const firstGetterStarted = deferred();
+ let getterCalls = 0;
+ let gettersInFlight = 0;
+ let maximumInFlight = 0;
+ globalThis.window = {
+ __TAURI__: {
+ core: {
+ invoke: async (command, args) => {
+ if (command !== "lsp_get_diagnostics_batch") {
+ throw new Error(`unexpected invoke: ${command}`);
+ }
+ getterCalls += 1;
+ gettersInFlight += 1;
+ maximumInFlight = Math.max(maximumInFlight, gettersInFlight);
+ if (getterCalls === 1) {
+ firstGetterStarted.resolve();
+ await firstGetterGate.promise;
+ }
+ gettersInFlight -= 1;
+ return args.requests.map((request) => ({
+ generation: 18,
+ uri: request.uri,
+ version: 1,
+ sequence: request.sequence,
+ diagnostics: []
+ }));
+ }
+ },
+ event: { listen: async () => () => {} }
+ }
+ };
+
+ try {
+ const controller = createLspHarness(state, docs[0]);
+ const first = controller.handleDiagnosticsChanged({
+ uri: docToUri(docs[0]), generation: 18, version: 1, sequence: 1
+ });
+ await firstGetterStarted.promise;
+ const second = controller.handleDiagnosticsChanged({
+ uri: docToUri(docs[1]), generation: 18, version: 1, sequence: 2
+ });
+ await new Promise((resolve) => setImmediate(resolve));
+ assert.equal(getterCalls, 1);
+
+ firstGetterGate.resolve();
+ await Promise.all([first, second]);
+ assert.equal(getterCalls, 2);
+ assert.equal(maximumInFlight, 1);
+ assert.equal(controller.perf.diagnosticsPerf().getterMaxInFlight, 1);
+ } finally {
+ firstGetterGate.resolve();
+ for (const doc of docs) resetLspDocumentState(doc);
+ if (originalWindow === undefined) delete globalThis.window;
+ else globalThis.window = originalWindow;
+ }
+});
+
test("V-TXT-07 different workspace restarts while same workspace reuses bindings and cached diagnostics", async () => {
const originalWindow = globalThis.window;
const calls = [];
@@ -743,7 +927,7 @@ test("V-TXT-07 different workspace restarts while same workspace reuses bindings
};
const restartController = createLspHarness(restartState, null);
await restartController.startWorkspace("E:\\B");
- assert.deepEqual(calls.splice(0), [["lsp_start", { workspacePath: "E:\\B", generation: 2 }]]);
+ assert.deepEqual(calls.splice(0), [["lsp_start", { workspacePath: "E:\\B", generation: 2, locale: "enUS" }]]);
assert.equal(restartState.lsp.workspacePath, "E:\\B");
assert.equal(restartState.lsp.openFileCount, 0);
diff --git a/tests/lsp-policy.test.js b/tests/lsp-policy.test.js
index a5d3f2a..be1dce5 100644
--- a/tests/lsp-policy.test.js
+++ b/tests/lsp-policy.test.js
@@ -342,7 +342,7 @@ test("closing active standalone tab rebinds the revealed document to its differe
await controller.startWorkspace("E:\\Mods\\B", { contextMode: "sibling" });
assert.deepEqual(calls, [
- ["lsp_start", { workspacePath: "E:\\Mods\\B", contextMode: "sibling", generation: 1 }],
+ ["lsp_start", { workspacePath: "E:\\Mods\\B", contextMode: "sibling", generation: 1, locale: "enUS" }],
["lsp_open_file", { uri: docToUri(second), version: 1, text: second.toText(), generation: 1 }]
]);
@@ -383,7 +383,7 @@ test("closing active standalone tab rebinds the revealed document to its differe
await documentController.closeTab(1);
assert.deepEqual(calls.slice(2), [
["lsp_close_file", { uri: docToUri(second), generation: 1 }],
- ["lsp_start", { workspacePath: "E:\\Mods\\A", contextMode: "sibling", generation: 2 }],
+ ["lsp_start", { workspacePath: "E:\\Mods\\A", contextMode: "sibling", generation: 2, locale: "enUS" }],
["lsp_open_file", { uri: docToUri(first), version: 1, text: first.toText(), generation: 2 }]
]);
assert.deepEqual(state.docs, [first]);
@@ -696,7 +696,7 @@ test("workspace start full invalidation clears semantic Vector-LSP hover cache",
assert.equal(hoverCalls.length, 2);
assert.equal(gridCalls.filter((call) => call[0] === "hover").at(-1)[3], "SECOND");
assert.deepEqual(tauriCalls, [
- ["lsp_start", { workspacePath: "E:\\Data", generation: 1 }],
+ ["lsp_start", { workspacePath: "E:\\Data", generation: 1, locale: "enUS" }],
["lsp_open_file", { uri, version: 1, text: doc.toText(), generation: 1 }]
]);
} finally {
@@ -1205,6 +1205,22 @@ test("Vector-LSP tooltip does not repeat guidance already present in the message
}), "Invalid calculation: Function 'min()' expects 2 arguments, got 1\n\nWhat to do:\nUse exactly 2 arguments.");
});
+test("localized keyed diagnostics show localized structured guidance", () => {
+ const message = "SkillDesc 계산식에 소수가 포함되어 있습니다 (`-6.25`). 게임은 정수 부분만 사용하고 소수 부분은 무시합니다 (사용: `-6`, 무시: `.25`).";
+ assert.equal(diagnosticTooltipText({
+ severity: "warning",
+ message,
+ data: {
+ localizedMessage: true,
+ localizedGuidanceHeading: "수정 방법",
+ localizedGuidance: "의도에 맞는 정수 계산식으로 바꾸세요.",
+ messageKey: "plugin.calc.skilldesc-decimal-prefix",
+ kind: "decimal-policy",
+ hint: "Use an integer expression that matches your intent."
+ }
+ }), `${message}\n\n수정 방법:\n의도에 맞는 정수 계산식으로 바꾸세요.`);
+});
+
test("Vector-LSP tooltip uses structured missing-token data for insertion hints", () => {
const formula = "min(5,1";
const diagnostic = {
@@ -1642,6 +1658,7 @@ test("Vector-LSP hover can be disabled without clearing baseline hover behavior"
assert.equal(vectorLspHoverStorageValue(true), "on");
assert.equal(vectorLspHoverStorageValue(false), "off");
assert.equal(effectiveVectorLspHover({ engine: LINT_ENGINE_VECTOR, vectorLspHover: true }), true);
+ assert.equal(effectiveVectorLspHover({ engine: LINT_ENGINE_VECTOR, lintEnabled: false, vectorLspHover: true }), false);
assert.equal(effectiveVectorLspHover({ engine: LINT_ENGINE_VECTOR, vectorLspHover: false }), false);
assert.equal(effectiveVectorLspHover({ engine: LINT_ENGINE_LEGACY, vectorLspHover: true }), false);
const target = makeVectorHoverTarget({ uri: "file:///skills.txt", fileName: "skills.txt", row: 2, column: 0, cellValue: "cap" });
@@ -1724,6 +1741,8 @@ test("lint engine selector defaults to Vector-LSP and persists separately from l
assert.equal(documentChangeSyncRoute(LINT_ENGINE_LEGACY), "legacy-lint-edit");
assert.equal(documentOpenSyncRoute(LINT_ENGINE_VECTOR), "vector-open");
assert.equal(documentOpenSyncRoute(LINT_ENGINE_LEGACY), "legacy-lint-open");
+ assert.equal(documentChangeSyncRoute(LINT_ENGINE_VECTOR, false), "disabled");
+ assert.equal(documentOpenSyncRoute(LINT_ENGINE_VECTOR, false), "disabled");
assert.equal(vectorSessionAvailable({ engine: LINT_ENGINE_VECTOR, lspStarted: true }), true);
assert.equal(vectorSessionAvailable({ engine: LINT_ENGINE_VECTOR, lspStarted: false }), false);
assert.equal(vectorSessionAvailable({ engine: LINT_ENGINE_LEGACY, lspStarted: true }), false);
@@ -2249,7 +2268,7 @@ test("Vector-LSP startup failure replaces the connecting status", async () => {
});
await assert.rejects(() => controller.startWorkspace("E:\\Workspace"), /spawn failed/);
- assert.deepEqual(calls, [["lsp_start", { workspacePath: "E:\\Workspace", generation: 1 }]]);
+ assert.deepEqual(calls, [["lsp_start", { workspacePath: "E:\\Workspace", generation: 1, locale: "enUS" }]]);
assert.equal(state.lsp.started, false);
assert.equal(state.lsp.openFileCount, 0);
assert.equal(state.lint.status, "Vector-LSP startup failed");
diff --git a/tests/platform-tauri-contract.test.js b/tests/platform-tauri-contract.test.js
index ce23fc3..a759117 100644
--- a/tests/platform-tauri-contract.test.js
+++ b/tests/platform-tauri-contract.test.js
@@ -21,6 +21,7 @@ import {
lspOpenFile,
lspReadyListen,
lspStart,
+ lspStop,
lspStoppedListen,
lspUpdateFile,
lspUpdateFileIncremental,
@@ -210,6 +211,7 @@ test("large file payloads can parse through a worker before document constructio
test("Tauri command boundary preserves JS invoke names and Rust registrations", () => {
const platformSources = [
+ "../src/ui/app-runtime-utils.js",
"../src/core/platform/config.js",
"../src/core/platform/file-io.js",
"../src/core/platform/lsp-client.js",
@@ -234,17 +236,20 @@ test("Tauri command boundary preserves JS invoke names and Rust registrations",
"lsp_get_diagnostics",
"lsp_get_diagnostics_batch",
"lsp_hover",
- "lsp_open_file",
- "lsp_start",
- "lsp_update_file",
+ "lsp_open_file",
+ "lsp_start",
+ "lsp_stop",
+ "lsp_update_file",
"lsp_update_file_incremental",
"open_files_dialog",
"open_folder_dialog",
"pick_file_path",
+ "read_clipboard_text",
"read_text_files",
"save_config",
"save_file_dialog",
"startup_open_paths",
+ "write_clipboard_text",
"write_text_file_chunk_safe",
"write_text_file_safe"
]);
@@ -401,8 +406,9 @@ test("platform facade preserves Tauri command payload shapes", async () => {
assert.equal(await saveTextNative("export.txt", "id\n3"), true);
await lspStart("E:\\Workspace", 7);
- await lspStart("E:\\Mod\\TXT", 8, "sibling", "E:\\Workspace");
+ await lspStart("E:\\Mod\\TXT", 8, "sibling", "E:\\Workspace", true, "koKR");
await lspStart("E:\\Workspace", 9, "workspace", "", false);
+ await lspStop(9);
await lspOpenFile("file:///items.txt", 1, "id\n1", 7);
await lspUpdateFile("file:///items.txt", 2, "id\n2", 7);
await lspUpdateFileIncremental("file:///items.txt", 3, [{ range: { start: { line: 0, character: 0 } }, text: "id" }], 7);
@@ -456,9 +462,10 @@ test("platform facade preserves Tauri command payload shapes", async () => {
["invoke", "write_text_file_chunk_safe", { path: "E:\\SavedAs.txt", text: "id\n2", encoding: "utf-8", transactionId: chunkTransactionIds[1], first: true, last: true }],
["invoke", "save_file_dialog", { defaultName: "export.txt" }],
["invoke", "write_text_file_safe", { path: "E:\\Export.txt", text: "id\n3", encoding: "utf-8" }],
- ["invoke", "lsp_start", { workspacePath: "E:\\Workspace", generation: 7 }],
- ["invoke", "lsp_start", { workspacePath: "E:\\Mod\\TXT", contextMode: "sibling", referenceRootPath: "E:\\Workspace", generation: 8 }],
- ["invoke", "lsp_start", { workspacePath: "E:\\Workspace", includeSubfolders: false, generation: 9 }],
+ ["invoke", "lsp_start", { workspacePath: "E:\\Workspace", generation: 7, locale: "enUS" }],
+ ["invoke", "lsp_start", { workspacePath: "E:\\Mod\\TXT", contextMode: "sibling", referenceRootPath: "E:\\Workspace", generation: 8, locale: "koKR" }],
+ ["invoke", "lsp_start", { workspacePath: "E:\\Workspace", includeSubfolders: false, generation: 9, locale: "enUS" }],
+ ["invoke", "lsp_stop", { generation: 9 }],
["invoke", "lsp_open_file", { uri: "file:///items.txt", version: 1, text: "id\n1", generation: 7 }],
["invoke", "lsp_update_file", { uri: "file:///items.txt", version: 2, text: "id\n2", generation: 7 }],
["invoke", "lsp_update_file_incremental", { uri: "file:///items.txt", version: 3, changes: [{ range: { start: { line: 0, character: 0 } }, text: "id" }], generation: 7 }],
diff --git a/tests/replace-navigation-shortcuts.test.js b/tests/replace-navigation-shortcuts.test.js
new file mode 100644
index 0000000..af76ba3
--- /dev/null
+++ b/tests/replace-navigation-shortcuts.test.js
@@ -0,0 +1,164 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { TableDocument } from "../src/core/table-model.js";
+import { SelectionModel } from "../src/core/selection.js";
+import {
+ SEARCH_SCOPE_COLUMN_TITLES,
+ SEARCH_SCOPE_ROW_TITLES,
+ replaceAllInTable,
+ replaceNextInTable
+} from "../src/core/search.js";
+import { makeCellCommand } from "../src/core/undo.js";
+import { columnCommandItems, commandActionForId } from "../src/ui/command-registry.js";
+import { createGridCommandController } from "../src/ui/controllers/grid-command-controller.js";
+import { cyclicDocumentIndex } from "../src/ui/document-lifecycle-policy.js";
+import { globalShortcutAction } from "../src/ui/global-shortcut-policy.js";
+import { promptNumber } from "../src/ui/prompt-dialog.js";
+import { validateShortcutChord } from "../src/ui/shortcut-policy.js";
+
+test("table replace-next and replace-all are case-insensitive and scope-aware", () => {
+ const doc = TableDocument.fromText("skills.txt", "Name\tDescription\nBash\tbash bash\nZeal\tbash");
+
+ const next = replaceNextInTable(doc, "BASH", "hit", { row: 1, column: 0 });
+ assert.deepEqual(next.found, { row: 1, column: 0 });
+ assert.deepEqual(next.edits, [{ row: 1, column: 0, value: "hit" }]);
+ assert.equal(next.replacementCount, 1);
+
+ const all = replaceAllInTable(doc, "bash", "hit");
+ assert.equal(all.replacementCount, 4);
+ assert.deepEqual(all.edits, [
+ { row: 1, column: 0, value: "hit" },
+ { row: 1, column: 1, value: "hit hit" },
+ { row: 2, column: 1, value: "hit" }
+ ]);
+
+ assert.equal(replaceAllInTable(doc, "name", "id", { scope: SEARCH_SCOPE_COLUMN_TITLES }).replacementCount, 1);
+ assert.equal(replaceAllInTable(doc, "zeal", "frenzy", { scope: SEARCH_SCOPE_ROW_TITLES }).replacementCount, 1);
+});
+
+test("Replace All is recorded as one reversible cell command", () => {
+ const doc = TableDocument.fromText("skills.txt", "name\tdesc\nbash\tbash bash");
+ const result = replaceAllInTable(doc, "bash", "hit");
+ const command = makeCellCommand("Replace All", doc, result.edits);
+
+ command.redo(doc);
+ assert.equal(doc.getCell(1, 0), "hit");
+ assert.equal(doc.getCell(1, 1), "hit hit");
+ command.undo(doc);
+ assert.equal(doc.getCell(1, 0), "bash");
+ assert.equal(doc.getCell(1, 1), "bash bash");
+});
+
+test("new navigation shortcuts map to commands and modified Tab remains configurable", () => {
+ assert.equal(globalShortcutAction({ key: "F3" }), "find-next");
+ assert.equal(globalShortcutAction({ key: "F3", shiftKey: true }), "find-previous");
+ assert.equal(globalShortcutAction({ key: "h", ctrlKey: true, shiftKey: true }), "replace");
+ assert.equal(globalShortcutAction({ key: "g", ctrlKey: true }), "go-to-row");
+ assert.equal(globalShortcutAction({ key: "Tab", ctrlKey: true }), "next-tab");
+ assert.equal(globalShortcutAction({ key: "Tab", ctrlKey: true, shiftKey: true }), "previous-tab");
+ assert.equal(globalShortcutAction({ key: "F3" }, { editingCell: true }), "find-next");
+ assert.equal(globalShortcutAction({ key: "F3", shiftKey: true }, { editingCell: true }), "find-previous");
+ assert.equal(globalShortcutAction({ key: "h", ctrlKey: true }), "reset-row-heights");
+ assert.equal(validateShortcutChord("Ctrl+Tab").valid, true);
+ assert.equal(validateShortcutChord("Ctrl+Shift+Tab").valid, true);
+ assert.equal(validateShortcutChord("Tab").valid, false);
+ assert.equal(validateShortcutChord("Shift+Tab").valid, false);
+});
+
+test("new commands are registered with their intended handlers", () => {
+ assert.deepEqual(commandActionForId("find-previous"), { type: "handler", name: "findPrevious" });
+ assert.deepEqual(commandActionForId("replace"), { type: "handler", name: "showReplace" });
+ assert.deepEqual(commandActionForId("go-to-row"), { type: "handler", name: "goToRow" });
+ assert.deepEqual(commandActionForId("next-tab"), { type: "handler", name: "nextTab" });
+ assert.deepEqual(commandActionForId("previous-tab"), { type: "handler", name: "previousTab" });
+ assert.deepEqual(commandActionForId("clone-column"), { type: "handler", name: "cloneColumns" });
+ assert.deepEqual(columnCommandItems().at(-1), { id: "clone-column", label: "Clone Column(s)", disabled: false });
+});
+
+test("tab navigation wraps in both directions", () => {
+ assert.equal(cyclicDocumentIndex({ activeIndex: 0, documentCount: 3, delta: 1 }), 1);
+ assert.equal(cyclicDocumentIndex({ activeIndex: 2, documentCount: 3, delta: 1 }), 0);
+ assert.equal(cyclicDocumentIndex({ activeIndex: 0, documentCount: 3, delta: -1 }), 2);
+ assert.equal(cyclicDocumentIndex({ activeIndex: 0, documentCount: 0, delta: 1 }), -1);
+});
+
+test("Go to Row uses the displayed one-based row number", async () => {
+ const doc = TableDocument.fromText("skills.txt", "name\nrow1\nrow2\nrow3");
+ const calls = [];
+ const state = {
+ selection: {
+ focus: { row: 0, column: 0 },
+ set(row, column) { this.focus = { row, column }; }
+ }
+ };
+ const controller = createGridCommandController({
+ state,
+ grid: {
+ scrollCellToCenter: (row, column) => calls.push(["scroll", row, column]),
+ draw: () => calls.push(["draw"])
+ },
+ activeDoc: () => doc,
+ hasOpenDocument: () => true,
+ execute: () => {},
+ saveSelectionState: () => calls.push(["save"]),
+ renderChrome: () => calls.push(["render"]),
+ showError: (error) => { throw error; },
+ promptNumber: async (options) => {
+ assert.equal(options.min, 1);
+ assert.equal(options.max, 4);
+ return 3;
+ },
+ applyFreezeToDoc: () => {},
+ rowsForContextOperation: () => [],
+ columnsFromSelection: () => []
+ });
+
+ await controller.goToRow();
+ assert.deepEqual(state.selection.focus, { row: 2, column: 0 });
+ assert.deepEqual(calls, [["save"], ["scroll", 2, 0], ["draw"], ["render"]]);
+});
+
+test("Clone Row and Clone Column preserve the existing focus and viewport", () => {
+ const doc = TableDocument.fromText("skills.txt", "name\tid\tdesc\nbash\t1\tmelee\nzeal\t2\taura");
+ const selection = new SelectionModel();
+ selection.set(1, 1);
+ const executed = [];
+ const controller = createGridCommandController({
+ state: { selection },
+ grid: {
+ scrollCellIntoView: () => { throw new Error("Clone must not move the viewport."); },
+ draw: () => {}
+ },
+ activeDoc: () => doc,
+ hasOpenDocument: () => true,
+ execute: (command) => {
+ command.redo(doc);
+ executed.push(command.label);
+ },
+ saveSelectionState: () => {},
+ renderChrome: () => {},
+ showError: (error) => { throw new Error(String(error)); },
+ applyFreezeToDoc: () => {},
+ rowsForContextOperation: () => [1],
+ columnsFromSelection: () => [1]
+ });
+
+ controller.cloneRows();
+ assert.deepEqual(selection.focus, { row: 1, column: 1 });
+ assert.equal(doc.getCell(3, 0), "bash");
+ controller.cloneColumns();
+ assert.deepEqual(selection.focus, { row: 1, column: 1 });
+ assert.equal(doc.getCell(1, 3), "1");
+ assert.deepEqual(executed, ["Clone 1 Row(s)", "Clone 1 Column(s)"]);
+});
+
+test("numeric prompts can enforce an upper bound", async () => {
+ const result = await promptNumber({
+ title: "Go to Row",
+ message: "Row",
+ min: 1,
+ max: 3,
+ askText: async ({ validate }) => validate("4")
+ });
+ assert.deepEqual(result, { error: "Enter a number 3 or lower." });
+});
diff --git a/tests/search-controller-regression.test.js b/tests/search-controller-regression.test.js
index 494d9e3..de49dfd 100644
--- a/tests/search-controller-regression.test.js
+++ b/tests/search-controller-regression.test.js
@@ -48,6 +48,9 @@ function table(rows) {
columnCount: Math.max(...rows.map((row) => row.length)),
getCell(row, column) {
return rows[row]?.[column] ?? "";
+ },
+ setCell(row, column, value) {
+ rows[row][column] = String(value);
}
};
}
@@ -99,6 +102,10 @@ function searchHarness({
focus: () => {},
select: () => {}
});
+ const searchReplaceInput = eventTarget({ value: "replacement", focus: () => {}, select: () => {} });
+ const searchReplaceRow = { classList: classList(["hidden"]) };
+ const searchReplaceActions = { classList: classList(["hidden"]) };
+ const searchTitle = { textContent: "Find" };
const selection = {
focus: { ...focus },
set(row, column) {
@@ -110,11 +117,16 @@ function searchHarness({
selection
};
const scrolls = [];
+ const doc = table(rows);
const controller = createSearchController({
state,
els: {
host: { focus: () => {} },
searchInput,
+ searchReplaceInput,
+ searchReplaceRow,
+ searchReplaceActions,
+ searchTitle,
searchPanel: panel,
searchStatus: { textContent: "" }
},
@@ -123,10 +135,25 @@ function searchHarness({
scrollByWheel: () => {},
draw: () => {}
},
- activeDoc: () => table(rows),
- updateActiveProblemHighlight: () => {}
+ activeDoc: () => doc,
+ updateActiveProblemHighlight: () => {},
+ applyEdits: (edits) => edits.forEach(({ row, column, value }) => doc.setCell(row, column, value))
});
- return { controller, handle, modal, panel, scopeInput, searchInput, state, scrolls };
+ return {
+ controller,
+ handle,
+ modal,
+ panel,
+ scopeInput,
+ searchInput,
+ searchReplaceInput,
+ searchReplaceRow,
+ searchReplaceActions,
+ searchTitle,
+ state,
+ scrolls,
+ rows
+ };
}
test("opening Find starts again at the active cell and subsequent searches advance and wrap", () => {
@@ -238,3 +265,27 @@ test("search modal clamp keeps all edges within the configured margin", () => {
viewportHeight: 600
}), { left: 8, top: 372 });
});
+
+test("Find and Replace mode exposes replacement controls and applies edit batches", () => {
+ const harness = searchHarness({
+ rows: [["name", "description"], ["bash", "bash bash"], ["zeal", "bash"]],
+ focus: { row: 1, column: 0 },
+ query: "bash"
+ });
+ harness.searchReplaceInput.value = "hit";
+
+ harness.controller.showReplace();
+ assert.equal(harness.searchTitle.textContent, "Find and Replace");
+ assert.equal(harness.searchReplaceRow.classList.contains("hidden"), false);
+ assert.equal(harness.searchReplaceActions.classList.contains("hidden"), false);
+
+ assert.equal(harness.controller.replaceNext(), true);
+ assert.equal(harness.rows[1][0], "hit");
+ assert.equal(harness.controller.replaceAll(), 3);
+ assert.deepEqual(harness.rows, [["name", "description"], ["hit", "hit hit"], ["zeal", "hit"]]);
+
+ harness.controller.showSearch();
+ assert.equal(harness.searchTitle.textContent, "Find");
+ assert.equal(harness.searchReplaceRow.classList.contains("hidden"), true);
+ assert.equal(harness.searchReplaceActions.classList.contains("hidden"), true);
+});
diff --git a/tests/table-document-state.test.js b/tests/table-document-state.test.js
index 7939989..58a96bd 100644
--- a/tests/table-document-state.test.js
+++ b/tests/table-document-state.test.js
@@ -22,6 +22,7 @@ import {
import {
addColumnsCommand,
addRowsCommand,
+ cloneColumnsCommand,
cloneRowsCommand,
copyRange,
copyRanges,
@@ -230,23 +231,45 @@ test("insert and delete row are grouped undoable commands", () => {
assert.equal(doc.getCell(1, 1), "2");
});
-test("clone rows inserts body-row copies below the selected range and skips the header row", () => {
+test("clone rows appends body-row copies at the end and skips the header row", () => {
const doc = TableDocument.fromText("x.txt", "a\tb\n1\t2\n3\t4\n5\t6");
const undo = new UndoManager();
- const command = cloneRowsCommand(doc, [0, 1, 2], 3);
+ const command = cloneRowsCommand(doc, [0, 1, 2]);
command.redo(doc);
undo.push(command);
assert.equal(doc.dirty, true);
assert.equal(doc.rowCount, 6);
- assert.equal(doc.getCell(3, 0), "1");
- assert.equal(doc.getCell(4, 0), "3");
- assert.equal(doc.getCell(5, 0), "5");
+ assert.equal(doc.getCell(3, 0), "5");
+ assert.equal(doc.getCell(4, 0), "1");
+ assert.equal(doc.getCell(5, 0), "3");
undo.undo(doc);
assert.equal(doc.rowCount, 4);
assert.equal(doc.getCell(3, 0), "5");
undo.redo(doc);
- assert.equal(doc.getCell(3, 1), "2");
- assert.equal(doc.getCell(4, 1), "4");
+ assert.equal(doc.getCell(4, 1), "2");
+ assert.equal(doc.getCell(5, 1), "4");
+});
+
+test("clone columns copies headers, body values, and widths as one undoable command", () => {
+ const doc = TableDocument.fromText("x.txt", "a\tb\tc\n1\t2\t3\n4\t5\t6");
+ doc.columnWidths[0] = 170;
+ doc.columnWidths[2] = 230;
+ const undo = new UndoManager();
+ const command = cloneColumnsCommand(doc, [0, 2], 3);
+ command.redo(doc);
+ undo.push(command);
+ assert.equal(doc.columnCount, 5);
+ assert.deepEqual(doc.rows, [
+ ["a", "b", "c", "a", "c"],
+ ["1", "2", "3", "1", "3"],
+ ["4", "5", "6", "4", "6"]
+ ]);
+ assert.deepEqual(doc.columnWidths.slice(3), [170, 230]);
+ undo.undo(doc);
+ assert.equal(doc.columnCount, 3);
+ assert.deepEqual(doc.rows[1], ["1", "2", "3"]);
+ undo.redo(doc);
+ assert.deepEqual(doc.rows[2], ["4", "5", "6", "4", "6"]);
});
test("add row and add column append grouped undoable changes", () => {
@@ -471,29 +494,84 @@ test("single-cell paste fills a contiguous multi-cell selection as one undoable
test("single-cell paste fills non-contiguous cells while matrix paste remains focus-based", () => {
const doc = TableDocument.fromText("x.txt", "a\tb\tc\n1\t2\t3\n4\t5\t6");
const selected = [
- { top: 0, left: 2, bottom: 0, right: 2 },
+ { top: 0, left: 1, bottom: 0, right: 1 },
{ top: 2, left: 0, bottom: 2, right: 0 }
];
- const fill = pasteTextToRangesCommand(doc, selected, { row: 0, column: 2 }, "same");
+ const fill = pasteTextToRangesCommand(doc, selected, { row: 0, column: 1 }, "same");
fill.redo(doc);
- assert.equal(doc.getCell(0, 2), "same");
+ assert.equal(doc.getCell(0, 1), "same");
assert.equal(doc.getCell(2, 0), "same");
assert.equal(doc.getCell(1, 1), "2");
const matrix = pasteTextToRangesCommand(
doc,
- [{ top: 0, left: 0, bottom: 2, right: 2 }],
- { row: 1, column: 1 },
+ selected,
+ { row: 0, column: 1 },
"x\ty"
);
assert.equal(matrix.label, "Paste Range");
assert.equal(matrix.changes.length, 2);
matrix.redo(doc);
- assert.equal(doc.getCell(1, 1), "x");
- assert.equal(doc.getCell(1, 2), "y");
+ assert.equal(doc.getCell(0, 1), "x");
+ assert.equal(doc.getCell(0, 2), "y");
assert.equal(doc.getCell(0, 0), "a");
});
+test("matrix paste into a contiguous selection starts at top-left and remains one undo command", () => {
+ const doc = TableDocument.fromText("x.txt", "a\tb\tc\td\te\n1\t2\t3\t4\t5\n6\t7\t8\t9\t10\n11\t12\t13\t14\t15", { dirty: false });
+ const undo = new UndoManager();
+ const beforeRevision = tableFileState(doc).revision;
+ const command = pasteTextToRangesCommand(
+ doc,
+ [{ top: 1, left: 1, bottom: 3, right: 4 }],
+ { row: 3, column: 4 },
+ "w\tx\ny\tz"
+ );
+
+ assert.equal(command.label, "Paste Range");
+ assert.equal(command.changes.length, 4);
+ command.redo(doc);
+ undo.push(command);
+
+ assert.equal(tableFileState(doc).revision, beforeRevision + 1);
+ assert.equal(undo.undoStack.length, 1);
+ assert.deepEqual(doc.rows, [
+ ["a", "b", "c", "d", "e"],
+ ["1", "w", "x", "4", "5"],
+ ["6", "y", "z", "9", "10"],
+ ["11", "12", "13", "14", "15"]
+ ]);
+
+ undo.undo(doc);
+ assert.deepEqual(doc.rows, [
+ ["a", "b", "c", "d", "e"],
+ ["1", "2", "3", "4", "5"],
+ ["6", "7", "8", "9", "10"],
+ ["11", "12", "13", "14", "15"]
+ ]);
+});
+
+test("oversized matrix paste into a contiguous selection is a silent no-op", () => {
+ const doc = TableDocument.fromText("x.txt", "a\tb\tc\n1\t2\t3\n4\t5\t6", { dirty: false });
+ const undo = new UndoManager();
+ const beforeRows = doc.rows.map((row) => [...row]);
+ const beforeRevision = tableFileState(doc).revision;
+ const target = [{ top: 1, left: 0, bottom: 2, right: 1 }];
+
+ for (const text of ["w\tx\ty", "w\nx\ny"]) {
+ const command = pasteTextToRangesCommand(doc, target, { row: 2, column: 1 }, text);
+ assert.equal(command.isEmpty, true);
+ if (!command.isEmpty) {
+ command.redo(doc);
+ undo.push(command);
+ }
+ }
+
+ assert.deepEqual(doc.rows, beforeRows);
+ assert.equal(tableFileState(doc).revision, beforeRevision);
+ assert.equal(undo.undoStack.length, 0);
+});
+
test("fill copies the top-left selected value over the selection", () => {
const doc = TableDocument.fromText("x.txt", "a\tb\tc\n1\t2\t3\n4\t5\t6");
const command = fillSelectionCommand(doc, { top: 1, left: 0, bottom: 2, right: 2 });
@@ -775,6 +853,8 @@ test("only quick edit mode commits on arrow-key cell navigation", () => {
assert.deepEqual(editorKeyAction({ key: "ArrowDown", editMode: "explicit" }), { action: "none" });
assert.deepEqual(editorKeyAction({ key: "Enter", shiftKey: true, editMode: "explicit" }), { action: "commit-move", rowDelta: -1, columnDelta: 0 });
assert.deepEqual(editorKeyAction({ key: "Tab", shiftKey: false, editMode: "explicit" }), { action: "commit-move", rowDelta: 0, columnDelta: 1 });
+ assert.deepEqual(editorKeyAction({ key: "Tab", ctrlKey: true, editMode: "explicit" }), { action: "none" });
+ assert.deepEqual(editorKeyAction({ key: "Tab", ctrlKey: true, shiftKey: true, editMode: "explicit" }), { action: "none" });
assert.deepEqual(editorKeyAction({ key: "Escape", editMode: "quick" }), { action: "cancel" });
assert.deepEqual(editorKeyAction({ key: "a", editMode: "quick" }), { action: "none" });
});
diff --git a/tests/toolbar-order.test.js b/tests/toolbar-order.test.js
index 82b3153..ccb4759 100644
--- a/tests/toolbar-order.test.js
+++ b/tests/toolbar-order.test.js
@@ -4,13 +4,13 @@ import test from "node:test";
test("Shortcuts appears immediately before Settings without changing either command", () => {
const html = readFileSync(new URL("../index.html", import.meta.url), "utf8");
- const shortcuts = 'Shortcuts ';
- const settings = 'Settings ';
+ const shortcuts = 'Shortcuts ';
+ const settings = 'Settings ';
const shortcutsIndex = html.indexOf(shortcuts);
const settingsIndex = html.indexOf(settings);
assert.notEqual(shortcutsIndex, -1);
assert.notEqual(settingsIndex, -1);
assert.ok(shortcutsIndex < settingsIndex);
- assert.match(html.slice(shortcutsIndex, settingsIndex + settings.length), /Shortcuts<\/button>\s*Settings<\/button>/);
+ assert.match(html.slice(shortcutsIndex, settingsIndex + settings.length), /Shortcuts<\/button>\s*Settings<\/button>/);
});