From 70e2546177216d3099b5787b77be53674f57b246 Mon Sep 17 00:00:00 2001 From: Manuel Gil Date: Fri, 25 Sep 2026 12:01:28 -0500 Subject: [PATCH 1/8] docs: update contributing and architecture documentation - Revised the CONTRIBUTING.md file to clarify resource disposal and added a note on Linux memory baseline considerations. - Updated README.md to specify that the chrome is translated for listed locales and refined descriptions regarding Fulvid's capabilities. - Enhanced ARCHITECTURE.md with new sections on memory footprint and future engineering rules, emphasizing resource management and performance considerations. - Made minor adjustments across various documentation files for clarity and consistency. --- CONTRIBUTING.md | 2 +- README.md | 10 +++---- assets/screenshots/README.md | 2 +- docs/ARCHITECTURE.md | 48 ++++++++++++++++++++++++++++-- docs/DISTRIBUTION.md | 8 ++--- docs/EXTENSION-AUTHOR-CONTRACT.md | 2 +- docs/EXTERNAL-OPEN.md | 2 +- docs/I18N.md | 2 +- docs/REPOSITORY.md | 17 ++++++++++- docs/compatibility.md | 4 +-- docs/releases/v1.0.0.md | 2 +- packaging/linux/README.md | 6 +++- packaging/linux/lib.sh | 3 +- packaging/linux/metainfo/README.md | 2 +- 14 files changed, 87 insertions(+), 23 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 24a5b21..2fd4a6b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,7 +20,7 @@ Keep pull requests focused. - UI selection goes through `selectDocument`. `activateDocument` is session-internal. Surfaces open a document through `openOrActivate`. Do not wire editor buffers to Focus changes. - Folder I/O uses `assertWithinWorkspace` (lexical) plus `assertCanonicallyContained` (symlink/realpath). Standalone Open and Save As use host dialogs and grants. No generic absolute-path read/write RPC. - Preview and Export HTML share `renderMarkdownPreview`. Do not add a second Markdown renderer. -- Dispose canvas, workers, and observers with their owner ([docs/ARCHITECTURE.md](docs/ARCHITECTURE.md#resources)). +- Dispose canvas, workers, and observers with their owner ([docs/ARCHITECTURE.md](docs/ARCHITECTURE.md#resources)). Packaged Linux memory baseline and when to re-measure: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md#memory-footprint-and-future-considerations). - Presentation tokens: [`src/mainview/styles/`](src/mainview/styles/). Chrome icons: [`AppIcon.vue`](src/mainview/shell/AppIcon.vue). Quick Actions rules (groups, overflow tiers, a11y): [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md#quick-actions-toolbar). Do not fork Monaco or edit `node_modules` for icons; widget Codicons are remapped in `monacoLucideIcons.ts`. Launcher icon: [`assets/README.md`](assets/README.md). - UI wording: [docs/I18N.md](docs/I18N.md). Settings hints should say what changes, when it applies, and give a concrete example. - Releases: [docs/DISTRIBUTION.md](docs/DISTRIBUTION.md). Actions is the main path. The Linux Makefile is a local helper. diff --git a/README.md b/README.md index 60a0bcb..65d62c8 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ Look at an Outline of headings, or at Document Context for references and facts Install **local Extensions** when you want small add-ons - a menu command, a note template, or a bounded selection transform - without giving them the filesystem, network, or the live editor. Packs live under your user data folder as ordinary files you can inspect. Details: [docs/EXTENSIONS.md](docs/EXTENSIONS.md). Catalog: sibling [`fulvid-extensions`](../fulvid-extensions/). -The chrome is English or Spanish. Document text, filenames, and link targets are never translated. +The chrome is translated for the locales listed in [docs/I18N.md](docs/I18N.md). Document text, filenames, and link targets are never translated. ![MDX opened as source in Fulvid](assets/screenshots/editor-mdx.png) @@ -154,7 +154,7 @@ Fulvid is a good fit if you: ## Who Fulvid is not for -Skip Fulvid if you need a personal knowledge manager, a cloud workspace, live collaboration, or a place that executes MDX as an application. It is not a generic IDE, not a plugin platform, and not a publishing pipeline. +Skip Fulvid if you need a personal knowledge manager, a cloud workspace, live collaboration, or a place that executes MDX as an application. It is not a generic IDE, not a plugin marketplace, and not a publishing pipeline. Those are other products. Fulvid stays a desktop editor for local Markdown and MDX files. @@ -162,7 +162,7 @@ Those are other products. Fulvid stays a desktop editor for local Markdown and M Fulvid is built as a desktop app for Linux, Windows, and macOS. There is no 32-bit build. -**Linux.** Packaging produces a Debian package (`fulvid__linux-x64.deb`) and a `.tar.gz` archive. Those files are meant for GitHub Releases. There is no Flathub, Snap, or AppImage package today. +**Linux.** Packaging produces a Debian package (`fulvid__linux-x64.deb`) and a `.tar.gz` archive for GitHub Releases. There is no Flathub, Snap, or AppImage package. **Windows.** Packaging produces a zip (`fulvid__win-x64-Setup.zip`) for 64-bit Windows. @@ -172,9 +172,9 @@ Unsigned local builds may need an OS security approval the first time they run. ## Installation -GitHub Releases is the public download channel. There is not a published release on that page yet, so the way to run Fulvid today is from source. +Download a published build from [GitHub Releases](https://github.com/ManuelGil/fulvid/releases) and pick the artifact for your platform (see [Platforms](#platforms)). That is the public install channel. -When a release is published, download it from [GitHub Releases](https://github.com/ManuelGil/fulvid/releases) and pick the file for your platform. Until then, use the steps below. +To run from source for development or contribution: You need [Bun](https://bun.sh) **1.4.2** or newer on the host (what `bun run doctor` checks). Electrobun's and Vite's CLIs also need [Node](https://nodejs.org/) 18 or newer on `PATH` (`#!/usr/bin/env node`). The packaged app embeds Electrobun 2.0.1 with Hutch's Bun **1.4.0** runtime - that packaged Bun version is independent of the host Bun you use to develop. diff --git a/assets/screenshots/README.md b/assets/screenshots/README.md index 471d74b..aca94c3 100644 --- a/assets/screenshots/README.md +++ b/assets/screenshots/README.md @@ -1,6 +1,6 @@ # Screenshots -Window captures from Fulvid (product UI). Older product shots are from 0.1.0; annotation shots match the current Add/Edit Quick Action UX. +Window captures from Fulvid (product UI). Some early product shots predate 1.0.0 chrome; annotation shots match the Add/Edit Quick Action UX. Treat this set as illustrative, not a full feature catalog. | File | Shows | | --- | --- | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1538aeb..459c345 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -4,7 +4,7 @@ Who owns behavior in Fulvid. Vocabulary: [CONCEPTS.md](./CONCEPTS.md). Rules: [INVARIANTS.md](./INVARIANTS.md). Graph pipeline: [GRAPH.md](./GRAPH.md). -Every user-visible behavior has **one owner**. Pages and the shell choose what is on screen; they do not own product rules. Reuse the document session, buffers, document-link parse/resolve (`documentLink` + `linkSemantics`), Preview renderer, and filesystem RPC. Do not add a second store, parser, renderer, resolver, or lifecycle. +Every user-visible behavior has **one owner**. Pages and the shell choose what is on screen; they do not own product rules. Reuse the document session, buffers, document-link parse/resolve (`documentLink` + `linkSemantics`), Preview renderer, and filesystem RPC. Do not add a second store, parser, renderer, resolver, or lifecycle. **No new owner without a concrete new observable behavior.** ## Owners @@ -88,7 +88,7 @@ The right sidebar shows one panel: Explorer, Search options (on `/search`), Docu ### Extension UI boundary -Presentation may become extension-capable later; **authority does not**. An extension is never an owner. +Presentation may be extension-capable; **authority does not**. An extension is never an owner. The Extension System orchestrates declared capabilities; it does not become the owner of filesystem, document, editor, window, search, graph, or renderer authority. @@ -131,3 +131,47 @@ If a component owns a canvas, worker, observer, or subscription, that resource d Ignore superseded async results. Do not keep workers or renderers alive across routes without an owner, cache disposed renderers, or teleport GPU/worker components without matching dispose. Vite/Monaco HMR is `bun run dev:hmr`: [CONTRIBUTING.md](../CONTRIBUTING.md#desktop-toolchain). + +## Memory footprint and future considerations + +### Baseline + +Linux packaged Fulvid **1.0.0** (Electrobun 2.0.1, Bun 1.4.0, WebKitGTK 2.52.6, Ubuntu 24.04.5 / Wayland / x86_64), cold launch, **5-run median** across the Fulvid process tree (`smaps_rollup`): + +| Mark | Median RSS | Median PSS | +| --- | --- | --- | +| Early peak (~5 s) | ~596.8 MiB | ~369.4 MiB | +| Idle (~60 s) | ~550.9 MiB | ~322.9 MiB | + +This is a **Linux packaged benchmark reference**, not a cross-platform guarantee and not a claim about Electrobun or WebKit in isolation. + +A diagnostic shell-floor build (Monaco removed from the running editor path) measured ~479.5 MiB RSS / ~256.0 MiB PSS idle. The Monaco stack therefore accounts for roughly **~67 MiB PSS** of the measured baseline. + +### Interpretation + +Prefer **PSS** over summed RSS when comparing physical footprint: shared library pages counted fully in each process make sum-RSS overstate unique usage. Do not treat virtual address space (`VmSize`) as RAM. + +The dominant measured idle cost is the **WebKitGTK content process** and the **Bun / native host**, not Graph, Preview, i18n catalogs, or empty-extension discovery. No evidence from this investigation established an application-level memory leak. + +Fulvid's current UX intentionally keeps the editor ready on launch. Deferring Monaco until first interaction may reduce idle memory, but that would be a product/UX change rather than a transparent implementation optimization. + +### Future engineering rules + +- Keep feature-specific functionality lazy when it is not required at startup. +- Do not eagerly initialize Graph/Sigma, Preview, extension runtimes, or other optional subsystems. +- Avoid loading large libraries on the critical startup path unless the feature is required for the initial editor experience. +- Do not duplicate document content or create parallel caches/indexes solely for convenience. +- Dispose listeners, timers, Monaco resources, WebView resources, and other lifecycle-bound objects at their existing owner boundary (see [Resources](#resources)). +- Avoid retaining hidden views, editors, models, parsed documents, graph data, or other large structures after their lifecycle ends. +- Prefer measuring before optimizing. Treat RSS and PSS separately. +- Do not trade security or editor behavior for arbitrary memory targets. + +### When to investigate again + +Revisit only with evidence such as: unbounded growth under a normal workload; a significant regression from the baseline above; retention after closing/discarding large documents or views; a new feature with a substantial persistent allocation; a WebKit/Bun/runtime upgrade that materially changes the footprint; or reproducible user reports of excessive consumption. + +When investigating: reproduce first; use the **packaged** build; isolate the responsible process; measure RSS and PSS; compare to this baseline; attribute the increase to application code, WebKit, runtime, or native dependencies; avoid speculative optimization. + +### Runtime maintenance + +WebKitGTK evolves independently of Fulvid and can include memory-management and security fixes. Supported-distro/runtime upgrades should be **benchmarked**, not assumed to improve or worsen memory. Example: WebKitGTK 2.52.6 included a memory-usage improvement for pages using font variations; that does not imply a later version is better without measurement. diff --git a/docs/DISTRIBUTION.md b/docs/DISTRIBUTION.md index d109457..355b3f9 100644 --- a/docs/DISTRIBUTION.md +++ b/docs/DISTRIBUTION.md @@ -1,6 +1,6 @@ # Distribution -GitHub Releases is the only public download channel. No version is published yet. Until then, run Fulvid from source (see the README). +GitHub Releases is the only public download channel. Install artifacts for tagged versions are published there. Running from source remains available for development (see the README). Release notes for a tag live under [releases/](./releases/) and are copied onto that GitHub Release by hand. The chronological list of user-facing changes is [CHANGELOG.md](../CHANGELOG.md). @@ -38,12 +38,12 @@ Canary packaging exists for development. Canary builds are not published as GitH ### Linux -The `.deb` and the `-Setup.tar.gz` archive are what Linux packaging produces today. The `.deb` sits next to the archive; it does not replace it. +The `.deb` and the `-Setup.tar.gz` archive are what Linux packaging produces. The `.deb` sits next to the archive; it does not replace it. | Format | Status | | --- | --- | -| Debian (`.deb`) | Produced by current packaging. Not downloadable until a GitHub Release exists. | -| Linux archive | Produced by current packaging. Same set as the `.deb`. | +| Debian (`.deb`) | Produced by packaging; published on GitHub Releases for tagged versions. | +| Linux archive | Produced by packaging; same set as the `.deb`. | | Snap | Notes and a draft only. No Store listing. | | AppImage | Notes only. No `.AppImage` is produced. | | Flatpak / Flathub | Notes only. Not submitted. No manifest. | diff --git a/docs/EXTENSION-AUTHOR-CONTRACT.md b/docs/EXTENSION-AUTHOR-CONTRACT.md index 2e854e8..82d483e 100644 --- a/docs/EXTENSION-AUTHOR-CONTRACT.md +++ b/docs/EXTENSION-AUTHOR-CONTRACT.md @@ -121,7 +121,7 @@ Guest has no `os` / `io` / `require` / `load` / `host.call`. | `editor.getSelection()` | bounded selection snapshot string | | `editor.replaceSelection(text)` | **only generic write primitive**; empty selection inserts at cursor; stale selection rejected | -Possible today: insert at cursor, replace/transform selection, seed a new buffer via `document.createUntitled`. +Possible: insert at cursor, replace/transform selection, seed a new buffer via `document.createUntitled`. **Not** available: arbitrary range rewrite / multi-cursor / full-buffer `setText`. Prefer selection + untitled composition over wishing for a larger editor API. ### `document` diff --git a/docs/EXTERNAL-OPEN.md b/docs/EXTERNAL-OPEN.md index 2193dee..5f1bd06 100644 --- a/docs/EXTERNAL-OPEN.md +++ b/docs/EXTERNAL-OPEN.md @@ -7,7 +7,7 @@ This is the internal layer, plus the Linux desktop `Exec` field that feeds it. launcher arguments to the Bun host, and there is no browser extension. What exists is the contract, the single host-side handler, one argv adapter, and a desktop entry that is ready to pass local paths once the launcher does. See -[Status](#status) for exactly what is wired today. +[Status](#status) for exactly what is wired. ## What an external open request is diff --git a/docs/I18N.md b/docs/I18N.md index fb60dce..c683add 100644 --- a/docs/I18N.md +++ b/docs/I18N.md @@ -159,7 +159,7 @@ English: short labels; extra detail in hints. Other locales: natural contemporar 4. Add the language endonym keys under `settings.*` in every catalog. 5. Run `bun run i18n:check`. -The auditor treats the first locale in sorted order as the structural reference (`de` today). +The auditor treats the first locale in sorted order as the structural reference (`de` when locales are sorted alphabetically). ## Validation diff --git a/docs/REPOSITORY.md b/docs/REPOSITORY.md index bfab703..0c11f91 100644 --- a/docs/REPOSITORY.md +++ b/docs/REPOSITORY.md @@ -21,7 +21,7 @@ src/mainview/pages/ Routes: editor/, search/, graph/, settings/ src/mainview/modules/ workspace/, editor/, search/, quickOpen/, graph/, document/, settings/ src/mainview/app/ Bootstrap, router, layout, folder lifecycle src/mainview/shell/ Menus, sidebars, AppIcon.vue -src/mainview/i18n/ EN/ES catalogs; see I18N.md +src/mainview/i18n/ UI locale catalogs (en/es/de/fr/it/nl/pt); see I18N.md src/mainview/styles/ tests/ Mirrors src/bun, src/mainview, and scripts/ tests/extensions/ Extension contract/security tests (+ disposable fixtures/) @@ -52,3 +52,18 @@ Tests mirror `src/` and `scripts/`. Names are `.unit.test.ts`, `.integration.tes Product copy uses **Folder**. Host code may keep `workspace*` identifiers. Distribution: [DISTRIBUTION.md](./DISTRIBUTION.md). + +## Documentation map + +| Need | Start here | +| --- | --- | +| Product vocabulary | [CONCEPTS.md](./CONCEPTS.md) | +| Ownership / architecture | [ARCHITECTURE.md](./ARCHITECTURE.md) | +| Enforceable rules | [INVARIANTS.md](./INVARIANTS.md) | +| Security contract | [SECURITY-AND-RESILIENCE.md](./SECURITY-AND-RESILIENCE.md) | +| Extensions | [EXTENSIONS.md](./EXTENSIONS.md) | +| Platforms / CI smoke | [compatibility.md](./compatibility.md) | +| Packaging / Releases | [DISTRIBUTION.md](./DISTRIBUTION.md) | +| Contributor actions | [../CONTRIBUTING.md](../CONTRIBUTING.md) | +| User-facing history | [../CHANGELOG.md](../CHANGELOG.md), [releases/](./releases/) | +| Historical security evidence | [security/](./security/) (not the living control list) | diff --git a/docs/compatibility.md b/docs/compatibility.md index 650a8df..eff3f3a 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -39,7 +39,7 @@ Electrobun's CLI (`electrobun.cjs`) and Vite's CLI start with `#!/usr/bin/env no | --- | --- | | Ubuntu 24.04 (`ubuntu-24.04`) | Tested. Build, `.deb`, launch under Xvfb, filesystem smoke | | Debian 13 (`debian:13` container) | Tested separately from Ubuntu. Same checks | -| Ubuntu 26.04 | Current Ubuntu LTS. Supported as a WebKitGTK 4.1 host. **Not tested** here: the GitHub runner image is still preview | +| Ubuntu 26.04 | Supported as a WebKitGTK 4.1 host. **Not tested** in this matrix (no GitHub-hosted job yet) | | Ubuntu 22.04, Debian 12 | Unsupported. Electrobun 2.0.1 ships Cottontail 0.5.0 (`GLIBC_2.38`, `GLIBCXX_3.4.32`) and `libNativeWrapper.so` (`GLIBC_2.38`, `GLIBCXX_3.4.32`). Ubuntu 22.04 is glibc 2.35; Debian 12 is glibc 2.36 | | Ubuntu 20.04, Debian 11, 32-bit | Unsupported | @@ -91,7 +91,7 @@ No Apple signing secrets on these jobs. | Windows x64 | Tested | **Verified** (Compatibility Windows CI) | | macOS arm64 | Tested | **Verified** (Compatibility macOS CI - 26 Apple Silicon) | -Evidence (Compatibility CI): [Windows run 34909418780](https://github.com/ManuelGil/fulvid/actions/runs/34909418780), [macOS run 34909421392](https://github.com/ManuelGil/fulvid/actions/runs/34909421392). Packaged Lua runtime is verified on the three supported desktop architectures above. +Evidence: Compatibility Linux / Windows / macOS workflows on `main` (see When compatibility CI runs). Packaged Lua runtime is verified on the three supported desktop architectures above. The Lua smoke checks packaged `bun/glue.wasm`, Wasm init, discovery + `ui.notify`, invalid-pack isolation, and lightweight interrupt/memory probes against the packaged module. It does not launch the UI and does not replace `smoke:compatibility`. diff --git a/docs/releases/v1.0.0.md b/docs/releases/v1.0.0.md index 9cc1a2b..cfd815d 100644 --- a/docs/releases/v1.0.0.md +++ b/docs/releases/v1.0.0.md @@ -10,7 +10,7 @@ First stable release. Graph keyboard scope, Settings keyboard navigation, Sponso - **Sponsors**: Settings -> About opens GitHub Sponsors in the system browser. - **Folder boundaries**: scan stays inside the open Folder (no symlink/junction follow-out); create/save refuse trailing spaces and reserved device stems more consistently. -## Download names (when published) +## Download names | Platform | Artifact | | --- | --- | diff --git a/packaging/linux/README.md b/packaging/linux/README.md index 079a242..577935b 100644 --- a/packaging/linux/README.md +++ b/packaging/linux/README.md @@ -1,6 +1,10 @@ # Linux packaging -Manual Linux release scripts. GitHub Actions does not call this directory for the `.deb`; it inlines the same Debian steps. Procedure: [linux-release.md](../../docs/linux-release.md). +Linux packaging scripts and desktop/metainfo stubs. + +- **Release workflow** (`release.yml`): inlines Debian packaging steps; uses `desktop/fulvid.desktop` and `runtime-libraries.tsv` from this tree. It does not call `make` or `package.sh`. +- **Compatibility Linux** (`compatibility-linux.yml`): runs `package.sh` (which sources `lib.sh` and `deb.sh`) for package + smoke. +- **Manual maintainer path**: `make` + scripts here. Procedure: [linux-release.md](../../docs/linux-release.md). ```text packaging/linux/ diff --git a/packaging/linux/lib.sh b/packaging/linux/lib.sh index 4b4dd31..4014131 100755 --- a/packaging/linux/lib.sh +++ b/packaging/linux/lib.sh @@ -1,4 +1,5 @@ -# Linux manual packaging helpers. Not used by GitHub Actions or other platforms. +# Linux packaging helpers (manual Make path and Compatibility Linux package.sh). +# release.yml inlines Debian steps and does not source this file. # shellcheck shell=bash ROOT="$(CDPATH='' cd -- "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" diff --git a/packaging/linux/metainfo/README.md b/packaging/linux/metainfo/README.md index 34ce2bc..a40e874 100644 --- a/packaging/linux/metainfo/README.md +++ b/packaging/linux/metainfo/README.md @@ -9,7 +9,7 @@ Present: name, summary, description, developer, licenses, URLs, categories, icon Omitted on purpose: - Adopted Flatpak App ID -- `` until a GitHub Release exists +- `` (candidate file is not wired into shipping packaging; keep AppStream releases out until this file is an adopted install path) - `` URLs (files exist in [assets/screenshots/](../../../assets/screenshots/); do not invent a public URL) `appstreamcli validate --no-net` succeeds. Pedantic check reports `releases-info-missing`. From 1bc45ecd504a53827fc428f9e24f86bfc73f6343 Mon Sep 17 00:00:00 2001 From: Manuel Gil Date: Fri, 25 Sep 2026 12:47:27 -0500 Subject: [PATCH 2/8] feat(i18n): enhance language preference handling and update locale hints - Introduced a new `resolveLocale.ts` module to manage language preference resolution based on system settings and user choices. - Updated locale hints in various language files to clarify the behavior of language selection and system language fallback. - Modified `SettingsPage.vue` to include an option for "system" language preference. - Adjusted settings store to support the new locale preference structure. - Added unit tests for locale resolution logic to ensure correct functionality. --- docs/I18N.md | 8 +- src/mainview/i18n/de.ts | 2 +- src/mainview/i18n/en.ts | 2 +- src/mainview/i18n/es.ts | 2 +- src/mainview/i18n/fr.ts | 2 +- src/mainview/i18n/index.ts | 12 +- src/mainview/i18n/it.ts | 2 +- src/mainview/i18n/nl.ts | 2 +- src/mainview/i18n/pt.ts | 2 +- src/mainview/i18n/resolveLocale.ts | 105 ++++++++++++++++++ .../modules/settings/settingsSearch.ts | 2 +- .../modules/settings/settingsStore.ts | 17 ++- src/mainview/pages/settings/SettingsPage.vue | 1 + .../mainview/i18n/resolveLocale.unit.test.ts | 74 ++++++++++++ 14 files changed, 216 insertions(+), 17 deletions(-) create mode 100644 src/mainview/i18n/resolveLocale.ts create mode 100644 tests/mainview/i18n/resolveLocale.unit.test.ts diff --git a/docs/I18N.md b/docs/I18N.md index c683add..07efad0 100644 --- a/docs/I18N.md +++ b/docs/I18N.md @@ -24,6 +24,12 @@ Language names in the selector stay as native endonyms in every locale. `pt` uses neutral Portuguese suitable for a global desktop product (not a strongly regional Brazilian or European variant). +## Default language + +By default, Fulvid follows the operating system language when that language is supported. English is used when the system language is not supported. An explicit language selected in Fulvid takes precedence. + +Settings stores a preference of `system` or a catalog code (`de`, `en`, ...). Resolution (preference -> OS -> English) runs before the UI mounts so the first paint matches the effective language. Regional OS tags such as `de-DE` or `pt_BR` match by primary language against the allowlist above. + ## Context Read the control, the label next to it, and the action it performs. The same idea may need a different shape as a button, tooltip, hint, empty state, error, menu, or statusbar item. @@ -155,7 +161,7 @@ English: short labels; extra detail in hints. Other locales: natural contemporar 1. Add `src/mainview/i18n/.ts` with the same key tree as `en.ts`. 2. Register it in `src/mainview/i18n/index.ts` `messages`. -3. Add the locale to `Locale`, `VALID_LOCALES`, and the Settings language select in `SettingsPage.vue`. +3. Add the locale to `SUPPORTED_LOCALES` in `resolveLocale.ts` and the Settings language select in `SettingsPage.vue`. 4. Add the language endonym keys under `settings.*` in every catalog. 5. Run `bun run i18n:check`. diff --git a/src/mainview/i18n/de.ts b/src/mainview/i18n/de.ts index a8fbf0c..955bb8b 100644 --- a/src/mainview/i18n/de.ts +++ b/src/mainview/i18n/de.ts @@ -733,7 +733,7 @@ export default { sponsorHint: "Öffnet die GitHub-Sponsors-Seite in deinem Browser.", locale: "Sprache", localeHint: - "Ändert Menüs, Einstellungen und Meldungen in Fulvid sofort. Deine Dokumente bleiben in der Sprache, in der du sie geschrieben hast.", + "System folgt der Betriebssystemsprache, wenn Fulvid sie unterstützt; sonst Englisch. Eine hier gewählte Sprache bleibt erhalten. Deine Dokumente bleiben in der Sprache, in der du sie geschrieben hast.", english: "English", spanish: "Español", german: "Deutsch", diff --git a/src/mainview/i18n/en.ts b/src/mainview/i18n/en.ts index 85db088..50101fc 100644 --- a/src/mainview/i18n/en.ts +++ b/src/mainview/i18n/en.ts @@ -717,7 +717,7 @@ export default { sponsorHint: "Opens the GitHub Sponsors page in your browser.", locale: "Language", localeHint: - "Changes Fulvid's menus, Settings, and messages right away. Your documents stay in whatever language you wrote them in.", + "System follows your operating system language when Fulvid supports it; otherwise English. Choosing a language here keeps that choice. Your documents stay in whatever language you wrote them in.", english: "English", spanish: "Español", german: "Deutsch", diff --git a/src/mainview/i18n/es.ts b/src/mainview/i18n/es.ts index c55ca51..80a11c7 100644 --- a/src/mainview/i18n/es.ts +++ b/src/mainview/i18n/es.ts @@ -729,7 +729,7 @@ export default { sponsorHint: "Abre la página de GitHub Sponsors en tu navegador.", locale: "Idioma", localeHint: - "Cambia al momento los menús, Ajustes y mensajes de Fulvid. Tus documentos se quedan en el idioma en que los escribiste.", + "Sistema sigue el idioma del sistema operativo si Fulvid lo admite; si no, inglés. Una elección aquí se conserva. Tus documentos se quedan en el idioma en que los escribiste.", english: "English", spanish: "Español", german: "Deutsch", diff --git a/src/mainview/i18n/fr.ts b/src/mainview/i18n/fr.ts index 069bcb7..d932dc1 100644 --- a/src/mainview/i18n/fr.ts +++ b/src/mainview/i18n/fr.ts @@ -739,7 +739,7 @@ export default { sponsorHint: "Ouvre la page GitHub Sponsors dans votre navigateur.", locale: "Langue", localeHint: - "Modifie immédiatement les menus, paramètres et messages de Fulvid. Vos documents restent dans la langue dans laquelle vous les avez écrits.", + "Système suit la langue du système d'exploitation lorsque Fulvid la prend en charge ; sinon l'anglais. Un choix ici est conservé. Vos documents restent dans la langue dans laquelle vous les avez écrits.", english: "English", spanish: "Español", german: "Deutsch", diff --git a/src/mainview/i18n/index.ts b/src/mainview/i18n/index.ts index 7a54c0f..4117258 100644 --- a/src/mainview/i18n/index.ts +++ b/src/mainview/i18n/index.ts @@ -1,5 +1,7 @@ /** - * Locale runtime. Catalogs are static modules; `settings.locale` is the source of truth. + * Locale runtime. Catalogs are static modules; `settings.locale` is the + * preference source of truth. Active UI locale is resolved before mount: + * explicit preference -> supported OS language -> English. */ import { createI18n } from "vue-i18n"; import { watch } from "vue"; @@ -12,10 +14,13 @@ import fr from "./fr"; import it from "./it"; import nl from "./nl"; import pt from "./pt"; +import { readOsLocaleTags, resolveLocalePreference } from "./resolveLocale"; + +const initialLocale = resolveLocalePreference(settings.value.locale, readOsLocaleTags()); export const i18n = createI18n({ legacy: false, - locale: settings.value.locale, + locale: initialLocale, fallbackLocale: "en", messages: { de, @@ -30,7 +35,8 @@ export const i18n = createI18n({ watch( () => settings.value.locale, - (locale) => { + (preference) => { + const locale = resolveLocalePreference(preference, readOsLocaleTags()); i18n.global.locale.value = locale; if (typeof document !== "undefined") { document.documentElement.lang = locale; diff --git a/src/mainview/i18n/it.ts b/src/mainview/i18n/it.ts index ec907e4..1de1485 100644 --- a/src/mainview/i18n/it.ts +++ b/src/mainview/i18n/it.ts @@ -731,7 +731,7 @@ export default { sponsorHint: "Apre la pagina GitHub Sponsors nel browser.", locale: "Lingua", localeHint: - "Cambia subito menu, Impostazioni e messaggi di Fulvid. I documenti restano nella lingua in cui li hai scritti.", + "Sistema segue la lingua del sistema operativo se Fulvid la supporta; altrimenti l'inglese. Una scelta qui viene mantenuta. I documenti restano nella lingua in cui li hai scritti.", english: "English", spanish: "Español", german: "Deutsch", diff --git a/src/mainview/i18n/nl.ts b/src/mainview/i18n/nl.ts index bf0fa8e..aefe07b 100644 --- a/src/mainview/i18n/nl.ts +++ b/src/mainview/i18n/nl.ts @@ -729,7 +729,7 @@ export default { sponsorHint: "Opent de GitHub Sponsors-pagina in je browser.", locale: "Taal", localeHint: - "Wijzigt de menu's, instellingen en berichten van Fulvid direct. Je documenten blijven in de taal waarin je ze hebt geschreven.", + "Systeem volgt de taal van het besturingssysteem als Fulvid die ondersteunt; anders Engels. Een hier gekozen taal blijft behouden. Je documenten blijven in de taal waarin je ze hebt geschreven.", english: "English", spanish: "Español", german: "Deutsch", diff --git a/src/mainview/i18n/pt.ts b/src/mainview/i18n/pt.ts index 0d963bd..b3808f8 100644 --- a/src/mainview/i18n/pt.ts +++ b/src/mainview/i18n/pt.ts @@ -731,7 +731,7 @@ export default { sponsorHint: "Abre a página do GitHub Sponsors no seu navegador.", locale: "Idioma", localeHint: - "Altera imediatamente os menus, as Definições e as mensagens do Fulvid. Os seus documentos mantêm o idioma em que foram escritos.", + "Sistema segue o idioma do sistema operativo quando o Fulvid o suporta; caso contrário, inglês. Uma escolha aqui é mantida. Os seus documentos mantêm o idioma em que foram escritos.", english: "English", spanish: "Español", german: "Deutsch", diff --git a/src/mainview/i18n/resolveLocale.ts b/src/mainview/i18n/resolveLocale.ts new file mode 100644 index 0000000..9baabfe --- /dev/null +++ b/src/mainview/i18n/resolveLocale.ts @@ -0,0 +1,105 @@ +/** + * Resolve Fulvid UI locale from preference + OS locale tags. + * + * Precedence: explicit preference -> OS language if in the allowlist -> English. + * Regional tags (de-DE, pt_BR) match by primary language subtag only. + */ + +export const SUPPORTED_LOCALES = ["de", "en", "es", "fr", "it", "nl", "pt"] as const; +export type Locale = (typeof SUPPORTED_LOCALES)[number]; + +export const LOCALE_PREFERENCES = ["system", ...SUPPORTED_LOCALES] as const; +export type LocalePreference = (typeof LOCALE_PREFERENCES)[number]; + +export const DEFAULT_LOCALE_PREFERENCE: LocalePreference = "system"; +export const FALLBACK_LOCALE: Locale = "en"; + +/** + * Normalize BCP 47 / POSIX-ish locale tags to lowercase hyphen form. + * Returns "" when the value cannot yield a language subtag. + */ +export function normalizeLocaleTag(raw: unknown): string { + if (typeof raw !== "string") { + return ""; + } + const trimmed = raw.trim(); + if (!trimmed) { + return ""; + } + // Drop encoding / charset and glibc modifiers: es_ES.UTF-8@euro -> es_ES + const withoutSuffix = trimmed.split(/[.@]/, 2)[0] ?? ""; + const normalized = withoutSuffix + .replace(/[_\s./]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, "") + .toLowerCase(); + if (!normalized || normalized === "c" || normalized === "posix") { + return ""; + } + const primary = normalized.split("-", 1)[0] ?? ""; + if (!/^[a-z]{2,3}$/.test(primary)) { + return ""; + } + return normalized; +} + +/** Map a locale tag to a Fulvid catalog locale, or null when unsupported. */ +export function matchSupportedLocale(raw: unknown): Locale | null { + const tag = normalizeLocaleTag(raw); + if (!tag) { + return null; + } + const primary = tag.split("-", 1)[0] ?? ""; + return (SUPPORTED_LOCALES as readonly string[]).includes(primary) ? (primary as Locale) : null; +} + +/** + * Resolve the active UI locale. + * Explicit catalog preferences win; "system" (and unknown values) walk osTags. + */ +export function resolveLocalePreference( + preference: unknown, + osTags: readonly unknown[] = [], +): Locale { + if ( + typeof preference === "string" && + preference !== "system" && + (SUPPORTED_LOCALES as readonly string[]).includes(preference) + ) { + return preference as Locale; + } + for (const tag of osTags) { + const match = matchSupportedLocale(tag); + if (match) { + return match; + } + } + return FALLBACK_LOCALE; +} + +/** + * OS / WebView locale candidates. Prefer navigator.languages order, then + * navigator.language. Inject `source` in tests; do not invent tags. + */ +export function readOsLocaleTags( + source: { languages?: readonly string[]; language?: string } | undefined = typeof navigator !== + "undefined" + ? navigator + : undefined, +): string[] { + if (!source) { + return []; + } + const tags: string[] = []; + if (Array.isArray(source.languages)) { + for (const tag of source.languages) { + if (typeof tag === "string" && tag.trim()) { + tags.push(tag); + } + } + } + if (typeof source.language === "string" && source.language.trim()) { + tags.push(source.language); + } + return tags; +} diff --git a/src/mainview/modules/settings/settingsSearch.ts b/src/mainview/modules/settings/settingsSearch.ts index fb07374..5aa9475 100644 --- a/src/mainview/modules/settings/settingsSearch.ts +++ b/src/mainview/modules/settings/settingsSearch.ts @@ -33,7 +33,7 @@ export const SETTINGS_SEARCH_ENTRIES: readonly SettingsSearchEntry[] = [ category: "language", labelKey: "settings.locale", hintKey: "settings.localeHint", - terms: ["language", "idioma", "i18n"], + terms: ["language", "idioma", "i18n", "system"], }, { id: "general.reset", diff --git a/src/mainview/modules/settings/settingsStore.ts b/src/mainview/modules/settings/settingsStore.ts index c163126..9b249d1 100644 --- a/src/mainview/modules/settings/settingsStore.ts +++ b/src/mainview/modules/settings/settingsStore.ts @@ -18,8 +18,14 @@ import { THEME_PREFERENCES, type ThemePreference, } from "../editor/monaco/monacoThemes"; +import { + DEFAULT_LOCALE_PREFERENCE, + LOCALE_PREFERENCES, + type Locale, + type LocalePreference, +} from "../../i18n/resolveLocale"; -export type Locale = "de" | "en" | "es" | "fr" | "it" | "nl" | "pt"; +export type { Locale, LocalePreference }; export type LinkMode = LinkSyntax; export type WorkspaceStartup = "none" | "last"; export type StatusbarIndicator = @@ -90,7 +96,8 @@ export interface EditorSettings { } export interface FulvidSettings { - locale: Locale; + /** Explicit catalog locale, or "system" to follow a supported OS language. */ + locale: LocalePreference; appearance: { theme: ThemePreference; interfaceTextScale: InterfaceTextScale; @@ -127,7 +134,7 @@ export interface FulvidSettings { /** Single source of defaults for load, sanitize fallbacks, and reset. */ const DEFAULT_SETTINGS: FulvidSettings = { - locale: "en", + locale: DEFAULT_LOCALE_PREFERENCE, appearance: { theme: DEFAULT_THEME, interfaceTextScale: "normal", @@ -189,7 +196,7 @@ const STORAGE_KEY = "fulvid.settings.v1"; /** Storage key for tests and recovery tooling - not a second settings authority. */ export const SETTINGS_STORAGE_KEY = STORAGE_KEY; -const VALID_LOCALES: readonly Locale[] = ["de", "en", "es", "fr", "it", "nl", "pt"]; +const VALID_LOCALE_PREFERENCES: readonly LocalePreference[] = LOCALE_PREFERENCES; const VALID_EDITOR_FONT_FAMILIES: readonly EditorFontFamily[] = ["monospace", "system", "serif"]; const VALID_EDITOR_LINE_HEIGHTS: readonly EditorLineHeight[] = ["auto", "compact", "comfortable"]; const VALID_EDITOR_TAB_SIZES: readonly EditorTabSize[] = [2, 4, 8]; @@ -251,7 +258,7 @@ export function sanitizeSettings(value: unknown): FulvidSettings { const defaults = DEFAULT_SETTINGS; return { - locale: oneOf(VALID_LOCALES, source.locale, defaults.locale), + locale: oneOf(VALID_LOCALE_PREFERENCES, source.locale, defaults.locale), appearance: { theme: oneOf(THEME_PREFERENCES, appearance.theme, DEFAULT_THEME), interfaceTextScale: oneOf( diff --git a/src/mainview/pages/settings/SettingsPage.vue b/src/mainview/pages/settings/SettingsPage.vue index ccca73f..553dfce 100644 --- a/src/mainview/pages/settings/SettingsPage.vue +++ b/src/mainview/pages/settings/SettingsPage.vue @@ -705,6 +705,7 @@ async function onOpenSponsorPage(): Promise { setLocale(($event.target as HTMLSelectElement).value as FulvidSettings['locale']) " > + diff --git a/tests/mainview/i18n/resolveLocale.unit.test.ts b/tests/mainview/i18n/resolveLocale.unit.test.ts new file mode 100644 index 0000000..42e8867 --- /dev/null +++ b/tests/mainview/i18n/resolveLocale.unit.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from "bun:test"; + +import { + FALLBACK_LOCALE, + matchSupportedLocale, + normalizeLocaleTag, + readOsLocaleTags, + resolveLocalePreference, +} from "../../../src/mainview/i18n/resolveLocale"; + +// Intent: OS -> Fulvid locale mapping is allowlist-only, preference-aware, and fail-closed to English. +describe("resolveLocale", () => { + test("normalizes tags and matches only the supported allowlist", () => { + expect(normalizeLocaleTag("en-US")).toBe("en-us"); + expect(normalizeLocaleTag("en_GB")).toBe("en-gb"); + expect(normalizeLocaleTag("pt-BR")).toBe("pt-br"); + expect(normalizeLocaleTag("es_ES.UTF-8")).toBe("es-es"); + expect(normalizeLocaleTag("fr_FR.UTF-8@euro")).toBe("fr-fr"); + expect(normalizeLocaleTag(" NL_nl ")).toBe("nl-nl"); + expect(normalizeLocaleTag("C")).toBe(""); + expect(normalizeLocaleTag("POSIX")).toBe(""); + expect(normalizeLocaleTag("")).toBe(""); + expect(normalizeLocaleTag(null)).toBe(""); + expect(normalizeLocaleTag(42)).toBe(""); + expect(normalizeLocaleTag("!!!")).toBe(""); + + expect(matchSupportedLocale("de")).toBe("de"); + expect(matchSupportedLocale("de-DE")).toBe("de"); + expect(matchSupportedLocale("de_AT")).toBe("de"); + expect(matchSupportedLocale("fr-FR")).toBe("fr"); + expect(matchSupportedLocale("es-MX")).toBe("es"); + expect(matchSupportedLocale("pt-BR")).toBe("pt"); + expect(matchSupportedLocale("it_IT.UTF-8")).toBe("it"); + expect(matchSupportedLocale("nl-NL")).toBe("nl"); + expect(matchSupportedLocale("en-US")).toBe("en"); + + expect(matchSupportedLocale("ja")).toBeNull(); + expect(matchSupportedLocale("zh-CN")).toBeNull(); + expect(matchSupportedLocale("")).toBeNull(); + expect(matchSupportedLocale(undefined)).toBeNull(); + expect(matchSupportedLocale("not a locale")).toBeNull(); + expect(matchSupportedLocale("123")).toBeNull(); + }); + + test("resolves explicit preference over OS, then OS over English", () => { + expect(resolveLocalePreference("system", ["de-DE"])).toBe("de"); + expect(resolveLocalePreference("system", ["fr_CA.UTF-8"])).toBe("fr"); + expect(resolveLocalePreference("system", ["ja-JP", "es-MX"])).toBe("es"); + expect(resolveLocalePreference("system", ["ja-JP"])).toBe(FALLBACK_LOCALE); + expect(resolveLocalePreference("system", [])).toBe(FALLBACK_LOCALE); + expect(resolveLocalePreference("system", [null, "", "!!!"])).toBe(FALLBACK_LOCALE); + expect(resolveLocalePreference("system")).toBe(FALLBACK_LOCALE); + + expect(resolveLocalePreference("en", ["de-DE"])).toBe("en"); + expect(resolveLocalePreference("it", ["fr-FR"])).toBe("it"); + const persisted = "de" as const; + expect(resolveLocalePreference(persisted, ["en-US"])).toBe("de"); + expect(resolveLocalePreference(persisted, ["zh-CN"])).toBe("de"); + + expect(resolveLocalePreference("ja", ["nl-NL"])).toBe("nl"); + expect(resolveLocalePreference(undefined, ["pt-BR"])).toBe("pt"); + expect(resolveLocalePreference({}, [])).toBe(FALLBACK_LOCALE); + + expect( + readOsLocaleTags({ + languages: ["de-DE", "en-US"], + language: "fr-FR", + }), + ).toEqual(["de-DE", "en-US", "fr-FR"]); + expect(readOsLocaleTags({ language: "es-ES" })).toEqual(["es-ES"]); + expect(readOsLocaleTags({ languages: ["", " "], language: "" })).toEqual([]); + expect(readOsLocaleTags(undefined)).toEqual([]); + }); +}); From 2d7dbcdab4d6e139fa1b95c1cf4c8eab04e886d9 Mon Sep 17 00:00:00 2001 From: Manuel Gil Date: Fri, 25 Sep 2026 12:47:42 -0500 Subject: [PATCH 3/8] refactor(tests): streamline unit tests and enhance clarity - Removed unused imports and redundant test cases in `extensionAdversarial.unit.test.ts` to improve test clarity. - Updated test descriptions for better accuracy in `extensionEditorCapability.unit.test.ts` and `graphKeyScope.unit.test.ts`. - Enhanced assertions in `untitledDraftStore.unit.test.ts` to cover additional edge cases for empty and whitespace content. - Adjusted comments in `extensionLuaRuntime.unit.test.ts` to clarify test intent regarding execution limits. - Improved test structure and descriptions across various test files for consistency and readability. --- .../extensionAdversarial.unit.test.ts | 16 +----------- .../extensionDecorationOwnership.unit.test.ts | 14 ++++++++++ .../extensionEditorCapability.unit.test.ts | 15 +++-------- .../extensionLuaRuntime.unit.test.ts | 18 ++----------- .../document/documentFileNames.unit.test.ts | 1 - .../document/untitledDraftStore.unit.test.ts | 25 ++++++------------ .../markdown/markdownStructure.unit.test.ts | 26 ++++++------------- .../modules/graph/graphKeyScope.unit.test.ts | 5 +--- .../settings/settingsStore.unit.test.ts | 6 ++++- 9 files changed, 42 insertions(+), 84 deletions(-) diff --git a/tests/extensions/extensionAdversarial.unit.test.ts b/tests/extensions/extensionAdversarial.unit.test.ts index 8de7652..749c2ee 100644 --- a/tests/extensions/extensionAdversarial.unit.test.ts +++ b/tests/extensions/extensionAdversarial.unit.test.ts @@ -20,7 +20,6 @@ import { LUA_EXTENSION_LIMITS, setLuaExecutionBudgetForTests, } from "../../src/bun/extensions/lua/luaLimits.ts"; -import { parseExtensionDecorationRanges } from "../../src/mainview/extensions/decorationCapability.ts"; import { loadValidatedLuaPack, luaManifest, @@ -90,7 +89,7 @@ describe("adversarial lua sandbox", () => { }); describe("adversarial resource bounds", () => { - test("rejects oversized titles, notify flood, decoration coords, and reveal", async () => { + test("rejects oversized titles, notify flood, and reveal", async () => { const root = await tempExtensionRoot("bounds"); const title = "T".repeat(LUA_EXTENSION_LIMITS.maxCommandTitleChars.value + 1); const titlePack = await writeExtensionPack( @@ -136,19 +135,6 @@ commands.register({ expect(flood.error).toMatch(/size limit/i); } - const max = LUA_EXTENSION_LIMITS.maxRevealPosition.value; - expect( - parseExtensionDecorationRanges([ - { - startLine: max + 1, - startColumn: 1, - endLine: max + 1, - endColumn: 2, - style: "info", - }, - ]), - ).toEqual({ ok: false, error: "invalid decoration range" }); - const revealPack = await writeExtensionPack( root, "test.adv-reveal", diff --git a/tests/extensions/extensionDecorationOwnership.unit.test.ts b/tests/extensions/extensionDecorationOwnership.unit.test.ts index 65f3f6f..ed4f2ef 100644 --- a/tests/extensions/extensionDecorationOwnership.unit.test.ts +++ b/tests/extensions/extensionDecorationOwnership.unit.test.ts @@ -3,6 +3,7 @@ */ import { describe, expect, test } from "bun:test"; +import { LUA_EXTENSION_LIMITS } from "../../src/bun/extensions/lua/luaLimits.ts"; import { appearanceClassKey, cssClassForExtensionAppearance, @@ -28,6 +29,19 @@ describe("extension-owned decoration appearance", () => { parseDecorationAppearance({ backgroundColor: "#abc", color: "expression(alert(1))" }).ok, ).toBe(false); + const maxPos = LUA_EXTENSION_LIMITS.maxRevealPosition.value; + expect( + parseExtensionDecorationRanges([ + { + startLine: maxPos + 1, + startColumn: 1, + endLine: maxPos + 1, + endColumn: 2, + style: "info", + }, + ]), + ).toEqual({ ok: false, error: "invalid decoration range" }); + expect( parseExtensionDecorationRanges([ { startLine: 1, startColumn: 1, endLine: 1, endColumn: 4, style: "warn" }, diff --git a/tests/extensions/extensionEditorCapability.unit.test.ts b/tests/extensions/extensionEditorCapability.unit.test.ts index 87f863e..cdf7b9f 100644 --- a/tests/extensions/extensionEditorCapability.unit.test.ts +++ b/tests/extensions/extensionEditorCapability.unit.test.ts @@ -234,7 +234,7 @@ commands.register({ ).toEqual({ ok: true, notifications: ["plain-data"] }); }); - test("stale apply, no editor, oversized, and capability denial fail closed", async () => { + test("stale apply, no editor, oversized, and nil-without-cap fail closed", async () => { const root = await tempExtensionRoot("fail"); const pack = join(root, "test.contract-lua-editor"); await cp(EDITOR_FIXTURE, pack, { recursive: true }); @@ -303,7 +303,8 @@ commands.register({ expect(bigResult.error).toMatch(/exceeds size limit/); } - // Capability denial covered in adversarial; keep nil-without-cap smoke for editor surface. + // Capability denial with an editor snapshot is covered in adversarial. + // Keep the editor-surface smoke: without the capability, editor stays nil. const noed = await writeExtensionPack( root, "test.contract-lua-noed", @@ -325,16 +326,6 @@ commands.register({ }, ); await loadValidatedLuaPack(noed); - expect( - await invokeLuaExtensionCommand({ - namespacedId: "test.contract-lua-noed.ping", - editor: editorSnap("secret"), - }), - ).toEqual({ - ok: false, - error: "editor capability not granted", - failureKind: "commandFailed", - }); expect(await invokeLuaExtensionCommand("test.contract-lua-noed.ping")).toEqual({ ok: true, notifications: ["ok"], diff --git a/tests/extensions/extensionLuaRuntime.unit.test.ts b/tests/extensions/extensionLuaRuntime.unit.test.ts index 295cf01..5acfff2 100644 --- a/tests/extensions/extensionLuaRuntime.unit.test.ts +++ b/tests/extensions/extensionLuaRuntime.unit.test.ts @@ -135,22 +135,8 @@ describe("lua execution and memory budgets", () => { setLuaExecutionBudgetForTests(100); const root = await tempExtensionRoot("loop"); - const loopLoad = await writeExtensionPack( - root, - "test.contract-lua-loop", - luaManifest("test.contract-lua-loop"), - { - "entry.lua": ` -commands.register({ id = "one", title = "One", run = function() end }) -while true do end -`, - }, - ); - await expect(loadValidatedLuaPack(loopLoad)).rejects.toMatchObject({ - reason: "execution limit exceeded", - }); - expect(findLuaCommand("test.contract-lua-loop.one")).toBeNull(); - + // Load-time infinite loop is covered in extensionAdversarial. Keep invoke-time + // hang and discovery neighbor isolation here. const good = await writeExtensionPack( root, "test.contract-lua-good", diff --git a/tests/mainview/modules/editor/document/documentFileNames.unit.test.ts b/tests/mainview/modules/editor/document/documentFileNames.unit.test.ts index ae6ba6d..18d4a6a 100644 --- a/tests/mainview/modules/editor/document/documentFileNames.unit.test.ts +++ b/tests/mainview/modules/editor/document/documentFileNames.unit.test.ts @@ -15,7 +15,6 @@ describe("document file names", () => { expect(resolveNewDocumentFileName(" ", "md")).toBeNull(); expect(resolveNewDocumentFileName("notes.md ", "md")).toBeNull(); expect(resolveNewDocumentFileName("notes.md.", "md")).toBeNull(); - expect(resolveNewDocumentFileName(" CON.md", "md")).toBeNull(); expect(resolveNewDocumentFileName("CON.md", "md")).toBeNull(); expect(resolveNewDocumentFileName("note:ads.md", "md")).toBeNull(); diff --git a/tests/mainview/modules/editor/document/untitledDraftStore.unit.test.ts b/tests/mainview/modules/editor/document/untitledDraftStore.unit.test.ts index b482288..49945a6 100644 --- a/tests/mainview/modules/editor/document/untitledDraftStore.unit.test.ts +++ b/tests/mainview/modules/editor/document/untitledDraftStore.unit.test.ts @@ -18,7 +18,7 @@ describe("untitled draft store", () => { resetUntitledDraftStoreForTests(); }); - test("persists latest content, rejects bad records, and deletes by recovery id", async () => { + test("persists latest content, rejects empty/bad records, and deletes by recovery id", async () => { resetUntitledDraftStoreForTests(); await putUntitledDraft({ recoveryId: "", content: "x", updatedAt: 1 } as never); await putUntitledDraft({ @@ -26,6 +26,10 @@ describe("untitled draft store", () => { content: "x".repeat(MAX_UNTITLED_DRAFT_CHARS + 1), updatedAt: 1, }); + await putUntitledDraft({ recoveryId: "empty", content: "", updatedAt: 1 }); + await putUntitledDraft({ recoveryId: "ws", content: "\n\n ", updatedAt: 2 }); + scheduleUntitledDraftPersist("sched-empty", " \n"); + await flushUntitledDraftWrites(); expect(await listUntitledDrafts()).toEqual([]); scheduleUntitledDraftPersist("r1", "first"); @@ -39,29 +43,16 @@ describe("untitled draft store", () => { await deleteUntitledDraft("r2"); expect((await listUntitledDrafts()).map((draft) => draft.recoveryId)).toEqual(["r1"]); - }); - - test("empty and whitespace content never persists and clears an existing draft", async () => { - resetUntitledDraftStoreForTests(); - - await putUntitledDraft({ recoveryId: "empty", content: "", updatedAt: 1 }); - await putUntitledDraft({ recoveryId: "ws", content: "\n\n ", updatedAt: 2 }); - scheduleUntitledDraftPersist("sched-empty", " \n"); - await flushUntitledDraftWrites(); - expect(await listUntitledDrafts()).toEqual([]); - - await putUntitledDraft({ recoveryId: "r4", content: "hello", updatedAt: 1 }); - expect((await listUntitledDrafts()).map((draft) => draft.recoveryId)).toEqual(["r4"]); - scheduleUntitledDraftPersist("r4", ""); + scheduleUntitledDraftPersist("r1", ""); await flushUntitledDraftWrites(); expect(await listUntitledDrafts()).toEqual([]); - scheduleUntitledDraftPersist("r4", "hello again"); + scheduleUntitledDraftPersist("r1", "hello again"); await flushUntitledDraftWrites(); expect(await listUntitledDrafts()).toEqual([ { - recoveryId: "r4", + recoveryId: "r1", content: "hello again", updatedAt: expect.any(Number), }, diff --git a/tests/mainview/modules/editor/markdown/markdownStructure.unit.test.ts b/tests/mainview/modules/editor/markdown/markdownStructure.unit.test.ts index ab7bf41..095f183 100644 --- a/tests/mainview/modules/editor/markdown/markdownStructure.unit.test.ts +++ b/tests/mainview/modules/editor/markdown/markdownStructure.unit.test.ts @@ -5,10 +5,11 @@ import { parseMarkdownStructure, } from "../../../../../src/mainview/modules/editor/markdown/markdownStructure.ts"; -// Intent: keep outline parsing aligned with heading, fence, and frontmatter rules. +// Intent: keep outline parsing aligned with heading, fence, frontmatter, and +// setext-vs-thematic-break rules. // Growth boundary: add cases only for a new Markdown structural form. describe("Markdown structure", () => { - test("parses outline forms and resolves ATX and Setext anchors", () => { + test("parses outline forms and keeps setext underlines paragraph-only", () => { const structure = parseMarkdownStructure(`--- title: Note --- @@ -33,17 +34,9 @@ Configuration const content = "# API Reference\n\nSetup\n=====\n"; expect(findMarkdownHeading(content, "api-reference")?.lineNumber).toBe(1); expect(findMarkdownHeading(content, "Setup")?.lineNumber).toBe(3); - }); -}); -// Intent: a `-` or `=` run is a setext underline only under paragraph text. -// Growth boundary: add a case only for a block form that changes that answer. -describe("Markdown structure setext boundaries", () => { - test("does not read a thematic break after a non-paragraph block as a heading", () => { - const headings = (content: string): string[] => - parseMarkdownStructure(content).headings.map( - (heading) => `h${heading.depth}:${heading.text}`, - ); + const headings = (source: string): string[] => + parseMarkdownStructure(source).headings.map((heading) => `h${heading.depth}:${heading.text}`); // Representatives: list, quote, table (same non-paragraph rule). expect(headings("# Real\n\n- item\n---\n\n## After\n")).toEqual(["h1:Real", "h2:After"]); @@ -53,18 +46,15 @@ describe("Markdown structure setext boundaries", () => { // Paragraph text still underlines, including a line that merely starts with a hyphen. expect(headings("Title\n---\n\n## After\n")).toEqual(["h2:Title", "h2:After"]); expect(headings("-notadash\n---\n\n## After\n")).toEqual(["h2:-notadash", "h2:After"]); - }); - - test("reports a multi-line setext heading at the first line of its paragraph", () => { - const structure = parseMarkdownStructure("alpha\nbeta\n---\n\n## After\n"); - expect(structure.headings[0]).toMatchObject({ + const multiline = parseMarkdownStructure("alpha\nbeta\n---\n\n## After\n"); + expect(multiline.headings[0]).toMatchObject({ lineNumber: 1, depth: 2, text: "alpha beta", anchor: "alpha-beta", }); - expect(structure.headings[1]?.lineNumber).toBe(5); + expect(multiline.headings[1]?.lineNumber).toBe(5); expect(findMarkdownHeading("alpha\nbeta\n---\n", "alpha-beta")?.lineNumber).toBe(1); }); }); diff --git a/tests/mainview/modules/graph/graphKeyScope.unit.test.ts b/tests/mainview/modules/graph/graphKeyScope.unit.test.ts index da766c4..871d272 100644 --- a/tests/mainview/modules/graph/graphKeyScope.unit.test.ts +++ b/tests/mainview/modules/graph/graphKeyScope.unit.test.ts @@ -16,11 +16,8 @@ function elementWithClosest(match: string | null): Element { // Intent: graph shortcuts must survive focus restore to #main-content, not only // nodes inside `.graph-workbench`, while remaining off app chrome. describe("graph key scope", () => { - test("accepts targets inside #main-content", () => { + test("accepts #main-content targets and refuses sidebar/chrome", () => { expect(isGraphKeyTargetInScope(elementWithClosest("#main-content"))).toBe(true); - }); - - test("refuses sidebar/chrome targets outside #main-content", () => { expect(isGraphKeyTargetInScope(elementWithClosest(null))).toBe(false); expect(isGraphKeyTargetInScope(null)).toBe(false); }); diff --git a/tests/mainview/modules/settings/settingsStore.unit.test.ts b/tests/mainview/modules/settings/settingsStore.unit.test.ts index caf545a..185f0f5 100644 --- a/tests/mainview/modules/settings/settingsStore.unit.test.ts +++ b/tests/mainview/modules/settings/settingsStore.unit.test.ts @@ -39,7 +39,9 @@ function installMemoryLocalStorage(): void { describe("settings sanitize", () => { test("accepts current fields, rejects unsupported enums, and resets to defaults", () => { expect(defaultSettings().appearance.theme).toBe("system"); + expect(defaultSettings().locale).toBe("system"); expect(sanitizeSettings({}).appearance.theme).toBe("system"); + expect(sanitizeSettings({}).locale).toBe("system"); const next = sanitizeSettings({ locale: "es", @@ -73,7 +75,9 @@ describe("settings sanitize", () => { expect(next).not.toHaveProperty("templates"); expect(next).not.toHaveProperty("contextRoot"); expect(next).not.toHaveProperty("contextRoots"); - expect(sanitizeSettings({ locale: "ja" }).locale).toBe("en"); + expect(sanitizeSettings({ locale: "ja" }).locale).toBe("system"); + expect(sanitizeSettings({ locale: "system" }).locale).toBe("system"); + expect(sanitizeSettings({ locale: "en" }).locale).toBe("en"); expect(sanitizeSettings({ appearance: { theme: "constructor" } }).appearance.theme).toBe( "system", ); From b203ad89398aac87495348cce5249f557abda146 Mon Sep 17 00:00:00 2001 From: Manuel Gil Date: Fri, 25 Sep 2026 14:19:42 -0500 Subject: [PATCH 4/8] feat(tabs): implement tab reordering functionality with keyboard and drag support - Added the ability to move document tabs left or right using Alt+Arrow keys. - Implemented drag-and-drop functionality for reordering tabs. - Updated internationalization files to include new shortcut descriptions for tab movement. - Enhanced unit tests to cover the new tab reordering features and ensure correct behavior. --- src/mainview/i18n/de.ts | 1 + src/mainview/i18n/en.ts | 1 + src/mainview/i18n/es.ts | 1 + src/mainview/i18n/fr.ts | 1 + src/mainview/i18n/it.ts | 1 + src/mainview/i18n/nl.ts | 1 + src/mainview/i18n/pt.ts | 1 + src/mainview/modules/editor/EditorTabs.vue | 143 +++++++++++++++++- .../editor/document/documentBuffers.ts | 33 ++++ .../editor/document/documentSession.ts | 22 +++ .../modules/editor/editorTabReorder.ts | 39 +++++ src/mainview/pages/settings/SettingsPage.vue | 4 + .../document/documentBuffers.unit.test.ts | 66 +++++++- 13 files changed, 307 insertions(+), 7 deletions(-) create mode 100644 src/mainview/modules/editor/editorTabReorder.ts diff --git a/src/mainview/i18n/de.ts b/src/mainview/i18n/de.ts index 955bb8b..3279eb6 100644 --- a/src/mainview/i18n/de.ts +++ b/src/mainview/i18n/de.ts @@ -705,6 +705,7 @@ export default { shortcutClose: "Den aktiven Tab schließen", shortcutCloseOthers: "Alle anderen Tabs schließen", shortcutTabs: "Zwischen geöffneten Dokument-Tabs wechseln", + shortcutMoveTab: "Den fokussierten Dokument-Tab nach links oder rechts verschieben", shortcutContext: "Dokumentkontext für das ausgewählte Dokument öffnen (I).", shortcutGraph: "Wo ist dieses Dokument?", shortcutEscape: diff --git a/src/mainview/i18n/en.ts b/src/mainview/i18n/en.ts index 50101fc..fde1988 100644 --- a/src/mainview/i18n/en.ts +++ b/src/mainview/i18n/en.ts @@ -691,6 +691,7 @@ export default { shortcutClose: "Close the active tab", shortcutCloseOthers: "Close every other tab", shortcutTabs: "Move between open document tabs", + shortcutMoveTab: "Move the focused open document tab left or right", shortcutContext: "Open Document Context for the selected document (I).", shortcutGraph: "Where is this document?", shortcutEscape: diff --git a/src/mainview/i18n/es.ts b/src/mainview/i18n/es.ts index 80a11c7..19ce2b3 100644 --- a/src/mainview/i18n/es.ts +++ b/src/mainview/i18n/es.ts @@ -702,6 +702,7 @@ export default { shortcutClose: "Cerrar la pestaña activa", shortcutCloseOthers: "Cerrar todas las demás pestañas", shortcutTabs: "Moverse entre documentos abiertos", + shortcutMoveTab: "Mover a izquierda o derecha la pestaña del documento enfocada", shortcutContext: "Abrir el contexto del documento seleccionado (I).", shortcutGraph: "¿Dónde está este documento?", shortcutEscape: diff --git a/src/mainview/i18n/fr.ts b/src/mainview/i18n/fr.ts index d932dc1..1923588 100644 --- a/src/mainview/i18n/fr.ts +++ b/src/mainview/i18n/fr.ts @@ -712,6 +712,7 @@ export default { shortcutClose: "Fermer l'onglet actif", shortcutCloseOthers: "Fermer tous les autres onglets", shortcutTabs: "Passer d'un onglet de document ouvert à l'autre", + shortcutMoveTab: "Déplacer l'onglet de document focalisé vers la gauche ou la droite", shortcutContext: "Ouvrir le Contexte du document sélectionné (I).", shortcutGraph: "Où est ce document ?", shortcutEscape: diff --git a/src/mainview/i18n/it.ts b/src/mainview/i18n/it.ts index 1de1485..bc99144 100644 --- a/src/mainview/i18n/it.ts +++ b/src/mainview/i18n/it.ts @@ -705,6 +705,7 @@ export default { shortcutClose: "Chiudi la scheda attiva", shortcutCloseOthers: "Chiudi tutte le altre schede", shortcutTabs: "Spostati tra le schede dei documenti aperti", + shortcutMoveTab: "Sposta a sinistra o a destra la scheda del documento attiva", shortcutContext: "Apri il Contesto del documento selezionato (I).", shortcutGraph: "Dov'è questo documento?", shortcutEscape: diff --git a/src/mainview/i18n/nl.ts b/src/mainview/i18n/nl.ts index aefe07b..5eccce5 100644 --- a/src/mainview/i18n/nl.ts +++ b/src/mainview/i18n/nl.ts @@ -704,6 +704,7 @@ export default { shortcutClose: "Het actieve tabblad sluiten", shortcutCloseOthers: "Alle andere tabbladen sluiten", shortcutTabs: "Wisselen tussen geopende documenttabbladen", + shortcutMoveTab: "Verplaats het gefocuste documenttabblad naar links of rechts", shortcutContext: "Documentcontext voor het geselecteerde document openen (I).", shortcutGraph: "Waar is dit document?", shortcutEscape: diff --git a/src/mainview/i18n/pt.ts b/src/mainview/i18n/pt.ts index b3808f8..82a8890 100644 --- a/src/mainview/i18n/pt.ts +++ b/src/mainview/i18n/pt.ts @@ -704,6 +704,7 @@ export default { shortcutClose: "Fechar o separador ativo", shortcutCloseOthers: "Fechar todos os outros separadores", shortcutTabs: "Alternar entre separadores de documentos abertos", + shortcutMoveTab: "Mover o separador do documento focado para a esquerda ou para a direita", shortcutContext: "Abrir o Contexto do documento selecionado (I).", shortcutGraph: "Onde está este documento?", shortcutEscape: diff --git a/src/mainview/modules/editor/EditorTabs.vue b/src/mainview/modules/editor/EditorTabs.vue index fa3c4d2..c697d28 100644 --- a/src/mainview/modules/editor/EditorTabs.vue +++ b/src/mainview/modules/editor/EditorTabs.vue @@ -3,11 +3,12 @@ import { computed, nextTick, ref, useId } from "vue"; import { useI18n } from "vue-i18n"; import type { DocumentBuffer } from "./document/documentBuffers"; -import { isDocumentDirty } from "./document/documentBuffers"; +import { isDocumentDirty, reorderOpenDocuments } from "./document/documentBuffers"; import { documentLocationFromBuffer, tabLabelsForBuffers } from "./document/documentLocation"; import ContextMenu from "../../shell/ContextMenu.vue"; import AppIcon from "../../shell/AppIcon.vue"; import { canCloseOtherEditorTabs, editorTabContextActions } from "./editorTabContextMenu"; +import { adjacentTabReorderIndex, tabDropReorderIndex } from "./editorTabReorder"; const { t } = useI18n(); const tabIdPrefix = useId(); @@ -29,6 +30,12 @@ const menuOpen = ref(false); const menuX = ref(0); const menuY = ref(0); const menuTarget = ref(null); +/** Index of the dragged tab while a pointer reorder is in progress. */ +const dragFromIndex = ref(null); +/** Insertion marker: line before this index, or `buffers.length` for end. */ +const dropMarkerIndex = ref(null); +/** Suppress the click that follows a completed drag. */ +const suppressNextActivate = ref(false); const tabLabels = computed(() => tabLabelsForBuffers(props.buffers)); @@ -69,6 +76,21 @@ function focusActiveTab(): void { } } +function moveFocusedTab(index: number, direction: -1 | 1): void { + const toIndex = adjacentTabReorderIndex(index, direction, props.buffers.length); + if (toIndex === null) { + return; + } + const buffer = props.buffers[index]; + if (!buffer) { + return; + } + if (!reorderOpenDocuments(index, toIndex)) { + return; + } + focusTab(buffer.id); +} + function onTabKeydown(event: KeyboardEvent, index: number): void { if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) { const buffer = props.buffers[index]; @@ -80,6 +102,16 @@ function onTabKeydown(event: KeyboardEvent, index: number): void { openTabMenu(buffer.id, bounds.left, bounds.bottom); return; } + if ( + event.altKey && + !event.ctrlKey && + !event.metaKey && + (event.key === "ArrowLeft" || event.key === "ArrowRight") + ) { + event.preventDefault(); + moveFocusedTab(index, event.key === "ArrowLeft" ? -1 : 1); + return; + } if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) { return; } @@ -102,6 +134,14 @@ function onTabKeydown(event: KeyboardEvent, index: number): void { focusTab(nextBuffer.id); } +function onTabActivate(id: string): void { + if (suppressNextActivate.value) { + suppressNextActivate.value = false; + return; + } + emit("activate", id); +} + function onTabContextMenu(event: MouseEvent, id: string): void { event.preventDefault(); (event.currentTarget as HTMLElement).querySelector('[role="tab"]')?.focus(); @@ -142,6 +182,65 @@ function closeTab(id: string): void { focusActiveTab(); } +function clearDragState(): void { + dragFromIndex.value = null; + dropMarkerIndex.value = null; +} + +function onTabDragStart(event: DragEvent, index: number): void { + const target = event.target; + if (target instanceof Element && target.closest(".editor-tabs__close")) { + event.preventDefault(); + return; + } + if (!event.dataTransfer || props.buffers.length < 2) { + event.preventDefault(); + return; + } + dragFromIndex.value = index; + dropMarkerIndex.value = index; + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("text/plain", props.buffers[index]?.id ?? ""); +} + +function onTabDragOver(event: DragEvent, overIndex: number): void { + if (dragFromIndex.value === null) { + return; + } + event.preventDefault(); + if (event.dataTransfer) { + event.dataTransfer.dropEffect = "move"; + } + const item = event.currentTarget as HTMLElement; + const rect = item.getBoundingClientRect(); + const placeAfter = event.clientX > rect.left + rect.width / 2; + dropMarkerIndex.value = placeAfter ? overIndex + 1 : overIndex; +} + +function onTabDrop(event: DragEvent, overIndex: number): void { + event.preventDefault(); + const fromIndex = dragFromIndex.value; + if (fromIndex === null) { + clearDragState(); + return; + } + const item = event.currentTarget as HTMLElement; + const rect = item.getBoundingClientRect(); + const placeAfter = event.clientX > rect.left + rect.width / 2; + const toIndex = tabDropReorderIndex(fromIndex, overIndex, placeAfter); + clearDragState(); + if (toIndex === fromIndex) { + return; + } + if (reorderOpenDocuments(fromIndex, toIndex)) { + suppressNextActivate.value = true; + } +} + +function onTabDragEnd(): void { + clearDragState(); +} + defineExpose({ focusActiveTab }); @@ -154,14 +253,24 @@ defineExpose({ focusActiveTab }); :aria-label="t('tabs.openDocuments')" > +