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 @@
-
`).join(""); + const localeOptions = LOCALE_OPTIONS.map(([value, label]) => + `` + ).join(""); backdrop.innerHTML = ` `; 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, /'; - const settings = ''; + const shortcuts = ''; + const 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*