diff --git a/CHANGELOG.md b/CHANGELOG.md index fe44e31..2750d55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,27 @@ This file is updated as part of the change, not reconstructed when a version is ## [Unreleased] +## [1.1.0] - 2026-09-26 + +### Added + +- **Tab organization**: reorder open document tabs by dragging inside the tab strip, or with `Alt+Left` / `Alt+Right` while a tab has keyboard focus. Middle-click closes a tab, and the strip scrolls the active tab back into view when tabs overflow. Tab order is session chrome - it does not move files or change the Folder. +- **Link hover source peek**: hovering a document link shows where it resolves (path, heading status, other matching documents) and a short excerpt of the target's Markdown source. The excerpt is shown as source, not as rendered HTML, and uses the document text that hover already reads. Not Preview and not a second renderer. + +### Changed + +- Tab navigation follows visible tab order: `Ctrl/Cmd+Tab` and `Ctrl/Cmd+Shift+Tab` activate the next or previous tab in the strip and wrap at the ends, instead of walking most-recently-used order. `Ctrl/Cmd+PageDown` / `Ctrl/Cmd+PageUp` keep doing the same and now appear as accelerators in Navigate and in Settings keyboard help. +- Language defaults to **System**: Fulvid follows the operating system language when it has that language, and English otherwise. A language chosen in Settings -> Language still wins and is kept, and regional system tags (`de-DE`, `pt_BR`) match by primary language. Guide: [docs/I18N.md](docs/I18N.md). + +### Fixed + +- Saving a document keeps that file's own permissions. A save used to replace them with the process default, so a document kept private (`0600`) came back readable by other accounts on the machine. +- Document text shown in editor hover tooltips (link labels, headings, paths, candidate matches) stays literal text. It could previously be read as Markdown, so a crafted heading or filename could turn a tooltip into a clickable external link or an image request. +- A Folder scan stops at a ceiling on total document text as well as on document count and depth, so a very large tree degrades to a partial Folder - still reported as partial - instead of loading document bodies without bound. +- Typing with Preview open no longer walks the whole Folder scan on every keystroke, which was noticeable in Folders with thousands of documents. +- A Graph background worker that fails to start is terminated instead of being left running. The layout is still computed in-process in that case, as before. +- `Ctrl/Cmd+Shift+Tab` and the Tab trap in dialogs register on WebKit builds that report the chord as `ISO_Left_Tab` (Linux). + ## [1.0.0] - 2026-09-19 First stable release of Fulvid. @@ -190,7 +211,8 @@ First release of Fulvid, a standalone desktop editor for Markdown and MDX. - Inert Preview and Export HTML from the same renderer. Export writes a `.html` file and cannot overwrite a Markdown or MDX note. - English and Spanish application chrome. Document text, filenames, and link targets are not translated. -[Unreleased]: https://github.com/ManuelGil/fulvid/compare/v1.0.0...HEAD +[Unreleased]: https://github.com/ManuelGil/fulvid/compare/v1.1.0...HEAD +[1.1.0]: https://github.com/ManuelGil/fulvid/releases/tag/v1.1.0 [1.0.0]: https://github.com/ManuelGil/fulvid/releases/tag/v1.0.0 [0.12.0]: https://github.com/ManuelGil/fulvid/releases/tag/v0.12.0 [0.11.0]: https://github.com/ManuelGil/fulvid/releases/tag/v0.11.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 24a5b21..28a60ff 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,8 +20,10 @@ 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). +- A bound the reader can see belongs to one owner: the store that clamps a value also exports the range its control offers (`EDITOR_FONT_SIZE_LIMITS`, `layoutStore`'s `*_LIMITS`, `LAYOUT_RESIZE_STEP_PX`). Do not restate a min/max on an input. +- Name timer delays and size caps where they are enforced (`*_MS`, `MAX_*`) rather than inlining the number at the call. A number a reader cannot explain is the thing the next contributor changes by accident. - 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. - User-facing changes: add an entry under `Unreleased` in [CHANGELOG.md](CHANGELOG.md) in the same change. When a version is released, move those entries under that version, open a new empty `Unreleased` section, bump `package.json` / `electrobun.config.ts`, and add `docs/releases/vX.Y.Z.md`. Do not reconstruct a version from git history at the last minute, log every commit, or rewrite a published version except to fix a factual error. @@ -66,7 +68,8 @@ Controlled mitigation matrix (about 55s each, same machine/WebKitGTK 2.52.6/Vite | minimal HTTP wait (`/`, `/@vite/client`, `/main.ts`) | readiness gate only | keep small | | `forwardConsole: false` | no change in reconnect rate | keep for Cursor-agent console hygiene (Vite auto-enables forwardConsole when an agent is detected) | | `__electrobun` stub | not an HMR metric | keep for rare Vite-HTTP preload race | -| `WEBKIT_DISABLE_COMPOSITING_MODE=1` | separate from HMR; targets `GLXBadWindow` | Linux HMR only | +| `WEBKIT_DISABLE_COMPOSITING_MODE=1` | helps `views://` / GLXBadWindow; **breaks Vite HMR HTTP** (no `[vite] connected`, white window) | `bun run start` / `bun run dev` and Linux compatibility CI only - **not** `dev:hmr` | +| `GDK_BACKEND=x11` | avoids Wayland GDK blank paint | Linux local runners (`electrobunDev.ts`, `devHmr.ts`) unless `FULVID_KEEP_GDK_BACKEND=1` | What Fulvid does: @@ -74,10 +77,10 @@ What Fulvid does: - `scripts/devHmr.ts` waits only for `/`, `/@vite/client`, and `/main.ts` before `electrobun dev` (do not expand this list without new measurements). - `vite.config.ts` sets `server.forwardConsole: false` so Cursor-agent sessions do not pipe console over the HMR socket (Vite's default is agent-detected `true`, otherwise `false`). This is not a proven reconnect fix. - `electrobunClient.ts` installs a minimal `window.__electrobun` bridge if preload has not yet, so Electroview.init does not throw under Vite HTTP. -- `scripts/devHmr.ts` defaults `WEBKIT_DISABLE_COMPOSITING_MODE=1` on Linux when unset (same profile as Linux compatibility CI) for `GLXBadWindow`, not for `WebLoaderStrategy` failures. +- Shared `scripts/linuxWebViewEnv.ts`: both runners use `electrobunDevProcessEnv`. On Linux, force `GDK_BACKEND=x11` unless `FULVID_KEEP_GDK_BACKEND=1`. Only the `views://` profile defaults `WEBKIT_DISABLE_COMPOSITING_MODE=1` when unset. The HMR profile clears an inherited compositing disable on Linux only. Windows and macOS runners do not set or clear these variables. - Does **not** filter or hide WebKit/GLX messages. - Does **not** switch to CEF, add a WebView watchdog, or auto-restart the renderer. -- Does **not** set compositing env in packaged production code; override locally if needed: `WEBKIT_DISABLE_COMPOSITING_MODE=1`. +- Does **not** set compositing env in packaged production code; override locally if needed: `WEBKIT_DISABLE_COMPOSITING_MODE=1` (avoid on `dev:hmr`). Upstream direction: Electrobun native Wayland support (remove forced `GDK_BACKEND=x11`) plus WebKitGTK NetworkProcess stability under heavy ESM load. Re-test HMR after Electrobun/WebKitGTK upgrades. No exact upstream bug matching this Vite+Electrobun HMR scenario was identified; related WebKit work exists around NetworkProcess kills under load (RealtimeKit). @@ -105,13 +108,13 @@ Static checks and integration carry most of the signal. A unit test is an except | Format / lint / types | `bun run format:check`, `bun run lint`, `bun run typecheck` | Prettier, ESLint, `vue-tsc` | | Unit | `bun run test:unit` | `*.unit.test.ts` | | Integration | `bun run test:integration` | `*.integration.test.ts` | -| Smoke | `bun run smoke` | `*.smoke.test.ts` plus built shell; optional desktop launch | +| Smoke | `bun run smoke` | Built shell; also re-runs the lifecycle integration test; optional desktop launch | | Compatibility smoke | `bun run smoke:compatibility` | Packaged-app CI check (`FULVID_SMOKE_LAUNCH=1` to start the binary) | | Aggregate | `bun run test` | Unit plus integration (what CI validate runs) | Write a unit test when the property is deterministic, lives in one module, and integration would bury it. Do not add a unit test to raise coverage, restate TypeScript, freeze private structure, wrap a trivial helper, or duplicate an integration test. -Use integration when the behavior crosses modules, the filesystem, document lifecycle, or the RPC trust boundary. `documentLifecycle.smoke.test.ts` is the reference for a real editing loop. Graph canvas behavior is smoke or manual. +Use integration when the behavior crosses modules, the filesystem, document lifecycle, or the RPC trust boundary. `documentLifecycle.integration.test.ts` is the reference for a real editing loop and runs in `bun run test` / `validate`. Graph canvas behavior is smoke or manual. ### Cross-platform contract diff --git a/README.md b/README.md index 60a0bcb..c3ee306 100644 --- a/README.md +++ b/README.md @@ -41,9 +41,11 @@ It is open source (MIT). The source in this repository is the product. Open a Markdown or MDX file and edit it. Untitled tabs work when you are still deciding where the file lives. Save and Save As write source, not a converted note format. +Keep several documents open in the tab strip. `Ctrl/Cmd+Tab` and `Ctrl/Cmd+PageDown` activate the next tab in tab order, `Ctrl/Cmd+Shift+Tab` and `Ctrl/Cmd+PageUp` the previous one, and the strip scrolls the active tab back into view when the tabs overflow. Change the order by dragging a tab, or with `Alt+Left` / `Alt+Right` while a tab has keyboard focus. Middle-click closes a tab. + Open a folder when you want Explorer, Quick Open, Global Search, Graph, and Document Context. Those views read the same files you are editing. They are optional. Writing still comes first. -Follow links that are actually in the source. You choose one link mode for the session: Markdown (default) or Wikilink. Broken links stay visible. Fulvid does not create a file because a link points at a missing path. +Follow links that are actually in the source. You choose one link mode for the session: Markdown (default) or Wikilink. Hovering a document link shows where it resolves: the path, the heading when the link points at one, and a short peek of its Markdown source, as source rather than a rendered page. Broken links stay visible, and the hover says the link matches no document and lists the documents that came close. Fulvid does not create a file because a link points at a missing path. Search in two places. Local find is the editor's own find (`Ctrl/Cmd+F`). Global Search (`Ctrl/Cmd+Shift+F`) looks through document content in the open folder. Quick Open (`Ctrl/Cmd+P`) jumps to a document by title, filename, or path in that folder - it does not search content. @@ -57,7 +59,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). It follows your operating system language when Fulvid has that language and English when it does not, or you can choose one in Settings. Document text, filenames, and link targets are never translated. ![MDX opened as source in Fulvid](assets/screenshots/editor-mdx.png) @@ -114,7 +116,7 @@ A document on disk is a `.md`, `.markdown`, or `.mdx` file. Untitled buffers liv Folder reads and writes stay inside the folder you opened. Opening a single file, or using Save As, goes through the operating system's file dialog. Later saves of that standalone file use the grant issued at dialog time. -A save that did not reach disk does not pretend it did. If the file's modification time changed since Fulvid last read or saved it, you get a conflict instead of a silent overwrite. +A save that did not reach disk does not pretend it did. If the file's modification time changed since Fulvid last read or saved it, you get a conflict instead of a silent overwrite. A save replaces the text in the file and leaves the file's own permissions alone, so a document you keep private stays private. You can keep using git, another editor, or a static generator on the same tree. Fulvid is a guest on the filesystem, not the owner of it. @@ -154,7 +156,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 +164,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 +174,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/CONCEPTS.md b/docs/CONCEPTS.md index e6bbcbd..d03b776 100644 --- a/docs/CONCEPTS.md +++ b/docs/CONCEPTS.md @@ -44,7 +44,7 @@ Product copy uses **Folder**, never Workspace. Host code may still use `workspac Settings choose one `linkMode`: `"markdown"` (default) or `"wikilink"`. That mode governs scan, resolution, editor providers, Preview, references, rename, and Graph. -When several Folder documents match the same stem, alias, or title, resolution stays **first-wins** (deterministic scan order). Surfaces may show the other matches as honesty (`alsoMatches` on hover; Context notes that Fulvid opens the first match). That list is derived from the scan - not a second identity or persisted state. An unresolved link with exactly one near-match may offer that candidate as a soft open (definition / Inspector); it does not change first-wins for resolved links. +When several Folder documents match the same stem, alias, or title, resolution stays **first-wins** (deterministic scan order). Surfaces may show the other matches as honesty (`alsoMatches` on hover; Context notes that Fulvid opens the first match). That list is derived from the scan - not a second identity or persisted state. An unresolved link with exactly one near-match may offer that candidate as a soft open (definition / Inspector); it does not change first-wins for resolved links. Editor link hover is an honesty/inspector surface (path, anchor status, matches) plus a short **source** Markdown peek of the target - not Preview, not HTML, and not a second renderer. **Find references** (`Shift+F12`) lists heading and fragment locations. **Rename heading** (`F2`) rewrites that heading and known fragment targets as text. F2 does not rename files. diff --git a/docs/DISTRIBUTION.md b/docs/DISTRIBUTION.md index d109457..723091e 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. | @@ -97,7 +97,7 @@ if: startsWith(github.ref, 'refs/tags/v') Who can create or move `v*` tags is a GitHub repository permission. -Release body notes live under [releases/](./releases/) (`v1.0.0.md` for the current release notes; use `vX.Y.Z.md` for the version you are shipping). Actions generates a commit list automatically; replace or append the curated notes on the Release when needed. +Release body notes live under [releases/](./releases/) (`v1.1.0.md` for the current release notes; use `vX.Y.Z.md` for the version you are shipping). Actions generates a commit list automatically; replace or append the curated notes on the Release when needed. ## Verification 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..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,11 +161,11 @@ 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`. -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/INVARIANTS.md b/docs/INVARIANTS.md index fd1728d..1e966de 100644 --- a/docs/INVARIANTS.md +++ b/docs/INVARIANTS.md @@ -67,7 +67,7 @@ The renderer is untrusted. It may ask for a document inside a folder the person | Desktop actions | Reveal and copy accept only an approved root, something inside an authorized root, or a granted document | | Grants | A grant token maps to one absolute path, is shaped like a UUID, and the grant table is bounded | | Error containment | Failures cross the boundary as codes from `filesystemErrors`. No host path, errno, or stack reaches the UI | -| Scan ceilings | A folder scan is bounded in document count and depth, and per-file analysis is capped. A partial scan is reported, never silent | +| Scan ceilings | A folder scan is bounded in document count and depth, per-file analysis is capped, and the total document text one scan carries is capped. A partial scan is reported, never silent | | External open | An external request is intent, never privilege. It earns exactly what the equivalent dialog earns, through the same authorities. See [EXTERNAL-OPEN.md](./EXTERNAL-OPEN.md) | | Save integrity | A save that did not reach disk never clears dirty. Creating a document is an exclusive create, so a concurrent create is reported rather than overwritten. In-flight writes capture path and content at start; abandoned buffers skip post-write mutation. Rename/delete/detach/quit drain that buffer's save queue before moving or removing the filesystem identity | diff --git a/docs/REPOSITORY.md b/docs/REPOSITORY.md index bfab703..fb36597 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/) @@ -47,8 +47,23 @@ docs/ Includes EXTENSIONS.md + EXTENSION-PRODUCT-CONTRACT.md | Outline | `modules/editor/outline/OutlinePanel.vue` | | Preview | `modules/editor/preview/PreviewPane.vue` | -Tests mirror `src/` and `scripts/`. Names are `.unit.test.ts`, `.integration.test.ts`, and `.smoke.test.ts`. Unit tests are the exception; see [CONTRIBUTING.md](../CONTRIBUTING.md#testing). Smoke is `bun run smoke`, not part of `bun run test`. +Tests mirror `src/` and `scripts/`. Names are `.unit.test.ts` and `.integration.test.ts` (what `bun run test` / `validate` run). `bun run smoke` checks the built shell and may re-run the lifecycle integration test; optional desktop launch is separate. See [CONTRIBUTING.md](../CONTRIBUTING.md#testing). 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/SECURITY-AND-RESILIENCE.md b/docs/SECURITY-AND-RESILIENCE.md index b809fdf..4d06aa6 100644 --- a/docs/SECURITY-AND-RESILIENCE.md +++ b/docs/SECURITY-AND-RESILIENCE.md @@ -133,8 +133,8 @@ Unreadable or vanished entries during a scan are skipped and counted. That is a Automated coverage that is actually in the repository: - **Unit tests** - Preview inertness and density timing; link resolution scale and semantics; RPC parameter shape and size; `selectDocument` paired with Focus; Writing Focus is independent of native Full Screen; Extension contract tests under `tests/extensions/` ([EXTENSIONS.md](./EXTENSIONS.md#tests-as-security-contracts)) -- **Integration tests** - folder containment (lexical and canonical, including symlinks); grants; External Open resolve path; scan skip of unreadable or vanished entries; scan ceilings; document I/O and exclusive create; RPC error containment -- **Smoke** - real editing loop; optional packaged launch (`bun run smoke:compatibility`); Lua packaged runtime (`bun run smoke:lua-packaged`) +- **Integration tests** - folder containment (lexical and canonical, including symlinks); grants; External Open resolve path; scan skip of unreadable or vanished entries; scan ceilings; document I/O and exclusive create; the composed editing loop (`documentLifecycle.integration.test.ts`); RPC error containment +- **Smoke** - built shell checks and optional packaged launch (`bun run smoke` / `bun run smoke:compatibility`); Lua packaged runtime (`bun run smoke:lua-packaged`) - **Compatibility CI** - package and launch on the images in [compatibility.md](./compatibility.md). That is runtime compatibility, not a filesystem red team - **Contributor gate** - `bun run validate` (format, translations, lint, types, tests, web build, doctor) - **Dependency health** - frozen lockfile and advisory audit (`bun run deps:check`) diff --git a/docs/compatibility.md b/docs/compatibility.md index 650a8df..beb5bb8 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 | @@ -51,7 +51,7 @@ Local smoke after a Linux package: FULVID_SMOKE_LAUNCH=1 bun run smoke:compatibility ``` -Without `FULVID_SMOKE_LAUNCH`, the script still checks `dist/` and the filesystem editing loop. +Without `FULVID_SMOKE_LAUNCH`, the script still checks `dist/` and re-runs the filesystem editing-loop integration test. Linux compatibility CI launches under Xvfb with `GDK_BACKEND=x11` and `WEBKIT_DISABLE_COMPOSITING_MODE=1`. On a local Wayland desktop, Electrobun 2.0.1 still forces X11 (XWayland); `GLXBadWindow` and occasional WebKit `internallyFailedLoadTimerFired` lines during `bun run dev:hmr` are documented under [CONTRIBUTING.md](../CONTRIBUTING.md#linux-wayland--devhmr-console-noise). They are not treated as Fulvid application regressions. @@ -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/github-distribution.md b/docs/github-distribution.md index 77475cb..78c2a6e 100644 --- a/docs/github-distribution.md +++ b/docs/github-distribution.md @@ -35,7 +35,7 @@ if: startsWith(github.ref, 'refs/tags/v') It downloads `fulvid-linux/`, `fulvid-windows/`, and `fulvid-macos/`, renames shared metadata (`SHA256SUMS`, `release-manifest.json`, `build.log`) with a platform suffix, and calls `softprops/action-gh-release` with `GITHUB_TOKEN`. That job is the only one with `contents: write`. - If no Release exists for the tag, one is created (`Fulvid `). -- The action sets `generate_release_notes: true` (GitHub's commit list). When attaching notes by hand, use the matching file under [releases/](./releases/) (`v1.0.0.md` for the current release notes; use `vX.Y.Z.md` for the version you are shipping). +- The action sets `generate_release_notes: true` (GitHub's commit list). When attaching notes by hand, use the matching file under [releases/](./releases/) (`v1.1.0.md` for the current release notes; use `vX.Y.Z.md` for the version you are shipping). - If a Release already exists, new assets are uploaded. `overwrite_files: false`, so a name already on the Release fails the upload. - A re-run does not replace existing assets. diff --git a/docs/linux-release.md b/docs/linux-release.md index 8d35c6d..81a3729 100644 --- a/docs/linux-release.md +++ b/docs/linux-release.md @@ -61,4 +61,4 @@ gpg --verify artifacts/SHA256SUMS.asc artifacts/SHA256SUMS (cd artifacts && sha256sum -c SHA256SUMS) ``` -Copy the files you need onto a GitHub Release yourself. Use the matching file under [releases/](./releases/) as the body (`v1.0.0.md` for the current release notes; use `vX.Y.Z.md` for the version you are shipping). +Copy the files you need onto a GitHub Release yourself. Use the matching file under [releases/](./releases/) as the body (`v1.1.0.md` for the current release notes; use `vX.Y.Z.md` for the version you are shipping). 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/docs/releases/v1.1.0.md b/docs/releases/v1.1.0.md new file mode 100644 index 0000000..a26aebf --- /dev/null +++ b/docs/releases/v1.1.0.md @@ -0,0 +1,29 @@ +# Fulvid 1.1.0 + +Tab organization and a source peek on document-link hover, with the application language following the operating system, on top of the 1.0.0 editor surface. + +## Highlights + +- **Tab organization**: reorder open document tabs by dragging inside the tab strip, or with `Alt+Left` / `Alt+Right` while a tab has keyboard focus. Middle-click closes a tab, and the strip scrolls the active tab back into view when tabs overflow. +- **Tab navigation in visible order**: `Ctrl/Cmd+Tab` and `Ctrl/Cmd+Shift+Tab` walk the strip and wrap at the ends instead of most-recently-used order. `Ctrl/Cmd+PageDown` / `Ctrl/Cmd+PageUp` do the same and now appear as accelerators in Navigate and in Settings keyboard help. +- **Link hover source peek**: hovering a document link shows where it resolves (path, heading status, other matching documents) and a short excerpt of the target's Markdown source, shown as source rather than rendered HTML. +- **System language**: Settings -> Language defaults to System and follows the operating system language when Fulvid has that language, English otherwise. A language chosen there still wins. +- **Save keeps file permissions**: saving a document no longer replaces that file's own permissions with the process default. + +## Download names + +| Platform | Artifact | +| --- | --- | +| Linux x64 (Debian) | `fulvid_1.1.0_linux-x64.deb` | +| Linux x64 (archive) | `fulvid_1.1.0_linux-x64-Setup.tar.gz` | +| macOS Apple Silicon | `fulvid_1.1.0_macos-arm64.dmg` | +| Windows x64 | `fulvid_1.1.0_win-x64-Setup.zip` | + +Automatic GitHub Release publication is enabled for `v*` tags; see DISTRIBUTION.md. + +## Notes + +- Editor hover tooltips show document text (link labels, headings, paths, candidate matches) as literal text, so a heading or filename cannot turn a tooltip into a link or an image request. +- A Folder scan also stops at a ceiling on total document text; a tree past it still loads as a partial Folder and is still reported as partial. +- Tab order is session chrome. It does not move files or change the Folder. +- MDX remains inert source. Export HTML behavior is unchanged. diff --git a/electrobun.config.ts b/electrobun.config.ts index 4538767..df8193a 100644 --- a/electrobun.config.ts +++ b/electrobun.config.ts @@ -16,7 +16,7 @@ export default { app: { name: "Fulvid", identifier: "fulvid.imgil.dev", - version: "1.0.0", + version: "1.1.0", }, build: { mainProcess: "bun", diff --git a/package.json b/package.json index a8de261..a7b73b5 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "fulvid", "description": "A standalone desktop editor for Markdown and MDX", - "version": "1.0.0", + "version": "1.1.0", "type": "module", "private": true, "icon": "assets/fulvid.png", @@ -33,9 +33,9 @@ "url": "https://github.com/ManuelGil/fulvid/issues" }, "scripts": { - "dev": "bun run build && electrobun dev --watch", + "dev": "bun run build && bun run scripts/electrobunDev.ts dev --watch", "dev:hmr": "bun run prepare:electrobun && concurrently --kill-others-on-fail \"vite --port 5173\" \"bun run scripts/devHmr.ts\"", - "start": "bun run build && electrobun dev", + "start": "bun run build && bun run scripts/electrobunDev.ts dev", "prepare:electrobun": "electrobun prepare --env=dev", "build": "bun run prepare:electrobun && vite build", "build:canary": "electrobun prepare --env=canary && vite build && electrobun build --env=canary", 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`. diff --git a/packaging/linux/snap/intended-snapcraft.yaml b/packaging/linux/snap/intended-snapcraft.yaml index 8ab3ce6..02b6355 100644 --- a/packaging/linux/snap/intended-snapcraft.yaml +++ b/packaging/linux/snap/intended-snapcraft.yaml @@ -16,7 +16,7 @@ name: fulvid base: core24 -version: "1.0.0" +version: "1.1.0" summary: A standalone desktop editor for Markdown and MDX description: | Fulvid is a standalone desktop editor for Markdown and MDX. diff --git a/scripts/compatibilitySmoke.ts b/scripts/compatibilitySmoke.ts index 186b93c..0c15c46 100644 --- a/scripts/compatibilitySmoke.ts +++ b/scripts/compatibilitySmoke.ts @@ -341,7 +341,7 @@ verifyBuiltShell(); console.log(" dist/index.html and bundled assets look usable.\n"); console.log("-> Filesystem editing loop\n"); -run("bun", ["test", "tests/bun/filesystem/io/documentLifecycle.smoke.test.ts"]); +run("bun", ["test", "tests/bun/filesystem/io/documentLifecycle.integration.test.ts"]); if (launchRequested) { console.log("\n-> Packaged launch\n"); diff --git a/scripts/devHmr.ts b/scripts/devHmr.ts index 26b9c65..af514dd 100644 --- a/scripts/devHmr.ts +++ b/scripts/devHmr.ts @@ -5,13 +5,14 @@ * WebKitGTK `internallyFailedLoadTimerFired` and HMR reconnect rates. * Vite `server.warmup.clientFiles` is the measured win for transform churn. * - * On Linux, Electrobun 2.0.1 forces GDK_BACKEND=x11 (XWayland on Wayland - * sessions). Accelerated compositing then often logs GLXBadWindow. Linux - * compatibility CI already launches with WEBKIT_DISABLE_COMPOSITING_MODE=1. - * Inherit an explicit value; otherwise default to the same profile for HMR - * only. That env targets GLX/compositing, not the HMR WebSocket path. This - * does not silence stderr and does not change packaged builds. + * On Linux, force `GDK_BACKEND=x11` (unless `FULVID_KEEP_GDK_BACKEND=1`) so + * an inherited Wayland GDK backend does not blank the window. Do **not** set + * `WEBKIT_DISABLE_COMPOSITING_MODE` here: with Vite HTTP that flag stops page + * JS from evaluating (no `[vite] connected`). Compositing disable stays on + * the `views://` path in `electrobunDev.ts` / Linux compatibility CI. */ +import { electrobunDevProcessEnv } from "./linuxWebViewEnv"; + const devServerUrl = "http://127.0.0.1:5173"; const warmupPaths = ["/", "/@vite/client", "/main.ts"]; const timeoutMs = 30_000; @@ -44,25 +45,13 @@ async function waitForVite(): Promise { await waitForVite(); -/** Linux HMR only: do not inject this env on Windows/macOS or into packaged builds. */ -function linuxWebKitCompositingEnv( - platform: NodeJS.Platform, - existing: string | undefined, -): Record { - if (platform !== "linux" || existing !== undefined) { - return {}; - } - return { WEBKIT_DISABLE_COMPOSITING_MODE: "1" }; -} - const electrobun = Bun.spawn(["bun", "x", "electrobun", "dev"], { stdin: "inherit", stdout: "inherit", stderr: "inherit", - env: { - ...process.env, - ...linuxWebKitCompositingEnv(process.platform, process.env.WEBKIT_DISABLE_COMPOSITING_MODE), - }, + env: electrobunDevProcessEnv(process.platform, process.env, { + disableCompositing: false, + }), }); const forwardSignal = (signal: NodeJS.Signals): void => { diff --git a/scripts/electrobunDev.ts b/scripts/electrobunDev.ts new file mode 100644 index 0000000..555d0c4 --- /dev/null +++ b/scripts/electrobunDev.ts @@ -0,0 +1,32 @@ +/** + * Launch Electrobun for local `bun run start` / `bun run dev`. + * + * On Linux, apply the `views://` paint profile: `WEBKIT_DISABLE_COMPOSITING_MODE=1` + * when unset, and `GDK_BACKEND=x11`. An inherited Wayland GDK backend can leave + * a blank window while the DOM still mounts (body background only). + * + * Packaged builds are unchanged. Set `FULVID_KEEP_GDK_BACKEND=1` to skip the + * GDK override when deliberately testing Wayland. + */ +import { electrobunDevProcessEnv } from "./linuxWebViewEnv"; + +const electrobunArgs = process.argv.slice(2); +if (electrobunArgs.length === 0) { + throw new Error("Usage: bun run scripts/electrobunDev.ts "); +} + +const electrobun = Bun.spawn(["bun", "x", "electrobun", ...electrobunArgs], { + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + env: electrobunDevProcessEnv(process.platform, process.env), +}); + +const forwardSignal = (signal: NodeJS.Signals): void => { + electrobun.kill(signal); +}; + +process.on("SIGINT", () => forwardSignal("SIGINT")); +process.on("SIGTERM", () => forwardSignal("SIGTERM")); + +process.exit(await electrobun.exited); diff --git a/scripts/linuxWebViewEnv.ts b/scripts/linuxWebViewEnv.ts new file mode 100644 index 0000000..71b1a1b --- /dev/null +++ b/scripts/linuxWebViewEnv.ts @@ -0,0 +1,60 @@ +/** + * Linux WebView launch env for local Electrobun runners. + * + * `GDK_BACKEND=x11` avoids a blank window when an inherited Wayland GDK + * backend paints only the body background. Skip with `FULVID_KEEP_GDK_BACKEND=1`. + * + * `WEBKIT_DISABLE_COMPOSITING_MODE=1` helps the packaged `views://` path + * (`bun run start` / `bun run dev`) against GLXBadWindow. Do **not** enable + * it for Vite HMR (`http://127.0.0.1:5173`): on Linux/WebKitGTK that flag + * prevents the page JS from evaluating (no `[vite] connected`, white window). + */ +export type LinuxWebViewEnvOptions = { + /** + * When true (default), set `WEBKIT_DISABLE_COMPOSITING_MODE=1` if unset. + * Pass false for the HMR HTTP loader path. + */ + disableCompositing?: boolean; +}; + +export function linuxWebViewPaintEnv( + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, + options: LinuxWebViewEnvOptions = {}, +): Record { + if (platform !== "linux") { + return {}; + } + + const disableCompositing = options.disableCompositing ?? true; + const next: Record = {}; + + if (disableCompositing && env.WEBKIT_DISABLE_COMPOSITING_MODE === undefined) { + next.WEBKIT_DISABLE_COMPOSITING_MODE = "1"; + } + if (env.FULVID_KEEP_GDK_BACKEND === undefined) { + next.GDK_BACKEND = "x11"; + } + + return next; +} + +/** + * Env object for `Bun.spawn` of local Electrobun runners. + * Windows/macOS: copy of `env` unchanged (no Linux paint keys added or removed). + * Linux HMR (`disableCompositing: false`): also clears inherited compositing disable. + */ +export function electrobunDevProcessEnv( + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, + options: LinuxWebViewEnvOptions = {}, +): NodeJS.ProcessEnv { + const next: NodeJS.ProcessEnv = { + ...env, + ...linuxWebViewPaintEnv(platform, env, options), + }; + if (platform === "linux" && options.disableCompositing === false) { + delete next.WEBKIT_DISABLE_COMPOSITING_MODE; + } + return next; +} diff --git a/scripts/smoke.ts b/scripts/smoke.ts index cccf401..b1faa14 100644 --- a/scripts/smoke.ts +++ b/scripts/smoke.ts @@ -1,8 +1,9 @@ /** - * Integration and runtime smoke checks for critical Fulvid flows. + * Runtime smoke checks for critical Fulvid flows. * - * Runs real filesystem integration tests, verifies the built shell, and when a - * display server is available launches the desktop app briefly on Linux. + * Re-runs the filesystem editing-loop integration test, verifies the built + * shell, and when a display server is available launches the desktop app + * briefly on Linux. * * Run with: bun run smoke */ @@ -98,8 +99,8 @@ async function launchDesktopSmoke(): Promise { console.log("\nFulvid smoke\n"); -console.log("-> Integration tests\n"); -run("bun", ["test", "tests/bun/filesystem/io/documentLifecycle.smoke.test.ts"]); +console.log("-> Filesystem editing loop (integration)\n"); +run("bun", ["test", "tests/bun/filesystem/io/documentLifecycle.integration.test.ts"]); console.log("\n-> Built shell\n"); verifyBuiltShell(); diff --git a/src/bun/extensions/discoverExtensions.ts b/src/bun/extensions/discoverExtensions.ts index cd31752..c0608b5 100644 --- a/src/bun/extensions/discoverExtensions.ts +++ b/src/bun/extensions/discoverExtensions.ts @@ -75,7 +75,28 @@ export function getDiscoveredExtensions(): ExtensionDiscoveryResult { function boundReason(raw: string): string { const reason = raw.split(/\r?\n/, 1)[0]?.trim() || raw; - return reason.length > 300 ? `${reason.slice(0, 300)}...` : reason; + const limit = EXTENSION_PACK_LIMITS.maxFailureReasonChars; + return reason.length > limit ? `${reason.slice(0, limit)}...` : reason; +} + +/** + * The bounded reason a pack failed, from whichever error type reported it. + * + * `ExtensionPackError` and `LuaExtensionLoadError` both carry a written reason; + * anything else contributes only its message, and `fallback` covers a throw + * that is not an Error at all. + */ +function extensionFailureReason(error: unknown, fallback: string): string { + if (error instanceof ExtensionPackError) { + return boundReason(error.reason); + } + if (error instanceof LuaExtensionLoadError) { + return boundReason(error.reason); + } + if (error instanceof Error) { + return boundReason(error.message); + } + return boundReason(fallback); } function emptyResult(): ExtensionDiscoveryResult { @@ -380,13 +401,7 @@ export async function installExtensionFromDirectory( manifest = await readManifest(sourceReal); await preflightEntry(sourceReal, manifest); } catch (error) { - const reason = - error instanceof ExtensionPackError - ? error.reason - : error instanceof Error - ? error.message - : "invalid extension pack"; - return lifecycleError(boundReason(reason)); + return lifecycleError(extensionFailureReason(error, "invalid extension pack")); } const destination = join(root, manifest.id); @@ -409,15 +424,9 @@ export async function installExtensionFromDirectory( } catch (error) { await rm(staging, { recursive: true, force: true }).catch(() => undefined); await rm(destination, { recursive: true, force: true }).catch(() => undefined); - const reason = - error instanceof ExtensionPackError - ? error.reason - : error instanceof Error - ? error.message - : "extension install failed"; return { status: "error", - reason: boundReason(reason), + reason: extensionFailureReason(error, "extension install failed"), discovery: await discoverExtensionsUnlocked(), }; } @@ -455,12 +464,9 @@ export async function uninstallExtensionPack( try { await rm(packPath, { recursive: true, force: false }); } catch (error) { - const reason = boundReason( - error instanceof Error ? error.message : "could not delete extension pack", - ); return { status: "error", - reason, + reason: extensionFailureReason(error, "could not delete extension pack"), discovery: await discoverExtensionsUnlocked(), }; } @@ -577,13 +583,7 @@ async function preloadExtensionPack( } await preflightEntry(packRoot, manifest); } catch (error) { - const reason = boundReason( - error instanceof ExtensionPackError - ? error.reason - : error instanceof Error - ? error.message - : "unknown extension load error", - ); + const reason = extensionFailureReason(error, "unknown extension load error"); const dot = directoryName.indexOf("."); return { id: directoryName, @@ -633,15 +633,7 @@ async function preloadExtensionPack( commands, }; } catch (error) { - const reason = boundReason( - error instanceof ExtensionPackError - ? error.reason - : error instanceof LuaExtensionLoadError - ? error.reason - : error instanceof Error - ? error.message - : "unknown extension load error", - ); + const reason = extensionFailureReason(error, "unknown extension load error"); return baseDiscovered(manifest, packRoot, "failed", reason); } } diff --git a/src/bun/extensions/lua/luaEngine.ts b/src/bun/extensions/lua/luaEngine.ts index de5c23b..a4aa75d 100644 --- a/src/bun/extensions/lua/luaEngine.ts +++ b/src/bun/extensions/lua/luaEngine.ts @@ -8,6 +8,7 @@ import { join } from "node:path"; import { LuaFactory, LuaTimeoutError, type LuaEngine } from "wasmoon"; +import { EXTENSION_PACK_LIMITS } from "../../../mainview/extensions/extensionManifest"; import { luaExecutionBudgetMs, luaMemoryBudgetBytes } from "./luaLimits"; const requireWasmoonAsset = createRequire(import.meta.url); @@ -105,7 +106,8 @@ export function describeLuaRuntimeFailure(error: unknown): string { // wasmoon embeds the chunk text as [string "..."]:line: message - drop the source. const withoutChunk = firstLine.replace(/^\[string "[\s\S]*"\]:(\d+):\s*/, "lua:$1: "); const bounded = withoutChunk.trim() || "lua runtime failed"; - return bounded.length > 300 ? `${bounded.slice(0, 300)}...` : bounded; + const limit = EXTENSION_PACK_LIMITS.maxFailureReasonChars; + return bounded.length > limit ? `${bounded.slice(0, limit)}...` : bounded; } /** Create a reduced guest engine with real execution and memory budgets. */ diff --git a/src/bun/extensions/lua/luaExtensionRuntime.ts b/src/bun/extensions/lua/luaExtensionRuntime.ts index e5ce5b3..b789909 100644 --- a/src/bun/extensions/lua/luaExtensionRuntime.ts +++ b/src/bun/extensions/lua/luaExtensionRuntime.ts @@ -474,6 +474,14 @@ export async function loadLuaExtensionPack( } } +/** + * Run one registered Lua command. + * + * The RPC handler always passes the request object; a bare `namespacedId` is + * accepted so a contract test can invoke a command that needs no editor or + * document snapshot. Either way the id and both snapshots are revalidated here - + * nothing about the shape of the argument grants anything. + */ export async function invokeLuaExtensionCommand( request: LuaInvokeRequest | string, ): Promise { diff --git a/src/bun/filesystem/io/documentIo.ts b/src/bun/filesystem/io/documentIo.ts index 00d50af..c496cc4 100644 --- a/src/bun/filesystem/io/documentIo.ts +++ b/src/bun/filesystem/io/documentIo.ts @@ -13,7 +13,7 @@ * edited even when the folder scan truncated it. */ import { randomUUID } from "node:crypto"; -import { mkdir, readFile, rename, rm, stat, unlink, writeFile } from "node:fs/promises"; +import { chmod, mkdir, readFile, rename, rm, stat, unlink, writeFile } from "node:fs/promises"; import { basename, dirname, extname, isAbsolute, relative, resolve } from "node:path"; import { @@ -63,6 +63,9 @@ function assertStandaloneDocumentPath(targetPath: string): string { return absolutePath; } +/** Longest single filename common filesystems accept. */ +const MAX_BASENAME_LENGTH = 255; + function requireSafeBasename(value: string): string { if (typeof value !== "string") { throw new WorkspaceBoundaryError("unsafeName"); @@ -70,7 +73,7 @@ function requireSafeBasename(value: string): string { // Refuse padded/illegal/reserved names rather than repair them. if ( !value || - value.length > 255 || + value.length > MAX_BASENAME_LENGTH || value.includes("/") || value.includes("\\") || value.includes("\0") || @@ -190,6 +193,19 @@ async function assertExpectedMtime( } } +/** Permission bits of an existing file, or null when nothing is there yet. */ +async function fileModeOrNull(targetPath: string): Promise { + try { + const information = await stat(targetPath); + return information.mode & 0o777; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return null; + } + throw error; + } +} + /** * Replace `targetPath` by writing a unique temp file (`wx`) then renaming over * it. Same-filesystem rename is atomic; it is not compare-and-replace. @@ -205,12 +221,31 @@ async function assertExpectedMtime( */ async function writeAtomically(targetPath: string, content: string): Promise { const temporaryPath = `${targetPath}.${randomUUID()}.tmp`; + // The rename installs a new inode, so without this the saved document would + // carry the process umask instead of its own permissions: a note kept at 0600 + // came back 0644, readable by every other account on the machine. + // + // The mode is set twice on purpose. At creation, so the temp file next to the + // document is never wider than the document itself, not even for the moment + // before the rename. After, because `open` also subtracts the umask, which + // would drop a bit the document legitimately had. + const existingMode = await fileModeOrNull(targetPath); try { await writeFile(temporaryPath, content, { encoding: "utf8", flag: "wx", + ...(existingMode === null ? {} : { mode: existingMode }), }); + if (existingMode !== null) { + try { + await chmod(temporaryPath, existingMode); + } catch { + // Filesystems that fix permissions at mount time (FAT, some network + // shares) refuse chmod, and there the mode was never the document's to + // keep. The content still has to reach disk. + } + } await rename(temporaryPath, targetPath); } finally { await rm(temporaryPath, { force: true }); diff --git a/src/bun/filesystem/scanning/noteAnalyzer.ts b/src/bun/filesystem/scanning/noteAnalyzer.ts index 770b0da..b551591 100644 --- a/src/bun/filesystem/scanning/noteAnalyzer.ts +++ b/src/bun/filesystem/scanning/noteAnalyzer.ts @@ -74,15 +74,33 @@ function countWords(text: string): number { return trimmed.split(/\s+/).length; } +/** Longest UTF-8 encoding, so only the final few bytes can be a partial sequence. */ +const MAX_UTF8_SEQUENCE_BYTES = 4; + +/** How many bytes the sequence starting at this lead byte occupies. */ +function utf8SequenceLength(leadByte: number): number { + if (leadByte >= 0xf0) { + return 4; + } + if (leadByte >= 0xe0) { + return 3; + } + if (leadByte >= 0xc0) { + return 2; + } + return 1; +} + /** Drop a trailing partial UTF-8 sequence left by a byte-level cut. */ function trimPartialUtf8(bytes: Uint8Array): Uint8Array { - for (let index = bytes.length - 1; index >= 0 && index > bytes.length - 5; index -= 1) { + const firstPossibleLead = bytes.length - MAX_UTF8_SEQUENCE_BYTES; + for (let index = bytes.length - 1; index >= 0 && index >= firstPossibleLead; index -= 1) { const byte = bytes[index]; if ((byte & 0b1100_0000) === 0b1000_0000) { continue; } // A lead byte: keep its sequence only when all of it was read. - const expected = byte < 0x80 ? 1 : byte >= 0xf0 ? 4 : byte >= 0xe0 ? 3 : byte >= 0xc0 ? 2 : 1; + const expected = utf8SequenceLength(byte); return bytes.length - index >= expected ? bytes : bytes.subarray(0, index); } return bytes; diff --git a/src/bun/filesystem/scanning/scanDirectory.ts b/src/bun/filesystem/scanning/scanDirectory.ts index 41c82ad..0146376 100644 --- a/src/bun/filesystem/scanning/scanDirectory.ts +++ b/src/bun/filesystem/scanning/scanDirectory.ts @@ -64,6 +64,22 @@ export type ScanOptions = { */ export const MAX_SCANNED_DOCUMENTS = 5_000; const MAX_SCAN_DEPTH = 24; +/** + * Total document body one scan may hand the renderer, in UTF-16 code units of + * `ScannedNote.content`. + * + * The document count alone does not bound memory: every note carries its body + * for Search, capped per file at `MAX_ANALYZED_BYTES` (2 MiB), so the count + * ceiling on its own permits gigabytes across one RPC reply. This keeps the + * aggregate in the same order as the folders Fulvid is for, and a folder past it + * is reported as partial through `truncated` instead of loading silently. + * + * It bounds body text, which is what dominates a reply; the parsed metadata + * beside it stays bounded by the per-file analysis cap and the document count. + * The budget is checked before each note, so the notes that load are always + * whole and a reply can exceed it by at most one note's body. + */ +const MAX_SCANNED_CONTENT_CHARS = 128 * 1024 * 1024; type MarkdownPathCollection = { paths: string[]; @@ -179,6 +195,12 @@ export async function scanWorkspace( testFaults?: { beforeReadDirectory?: (directory: string) => void | Promise; beforeAnalyzeFile?: (filePath: string) => void | Promise; + /** + * Lower the content budget so a test can reach it without writing 128 MiB. + * Clamped with `Math.min`: this seam can only tighten the ceiling, never + * widen it, and no RPC handler passes this argument. + */ + contentBudgetChars?: number; }, ): Promise<{ scannedNotes: ScannedNote[]; truncated: boolean; skipped: number }> { const linkMode = options.linkMode ?? "markdown"; @@ -193,11 +215,25 @@ export async function scanWorkspace( collected.paths.sort(compareDocumentPaths); const scannedNotes: ScannedNote[] = []; let skipped = collected.skipped; + let truncated = collected.truncated; + let analyzedChars = 0; + const contentBudget = Math.min( + MAX_SCANNED_CONTENT_CHARS, + testFaults?.contentBudgetChars ?? MAX_SCANNED_CONTENT_CHARS, + ); for (const entryPath of collected.paths) { + if (analyzedChars >= contentBudget) { + // The folder holds more text than one scan may carry. Stop here and say + // so: the notes already collected stay usable. + truncated = true; + break; + } try { await testFaults?.beforeAnalyzeFile?.(entryPath); - scannedNotes.push(await scannedNoteFromFile(rootPath, entryPath, linkMode)); + const note = await scannedNoteFromFile(rootPath, entryPath, linkMode); + analyzedChars += note.content?.length ?? 0; + scannedNotes.push(note); } catch (error) { if (!isSkippableScanError(error)) { throw error; @@ -208,7 +244,7 @@ export async function scanWorkspace( } } - return { scannedNotes, truncated: collected.truncated, skipped }; + return { scannedNotes, truncated, skipped }; } /** List the supported filesystem entries directly under a workspace folder. */ diff --git a/src/bun/index.ts b/src/bun/index.ts index 6646b75..ccdb233 100644 --- a/src/bun/index.ts +++ b/src/bun/index.ts @@ -161,6 +161,11 @@ mainWindowHolder.window.on("will-close", (event: unknown) => { mainRPC.send.windowCloseRequested({}); }); +// Electrobun 2.0.1 emits no move/resize event, so the frame is sampled instead. +// Often enough that a normal quit keeps the last placement, rare enough that an +// idle window is not writing to userData constantly. +const WINDOW_FRAME_SAMPLE_MS = 2_500; + setInterval(() => { try { const mainWindow = mainWindowHolder.window; @@ -170,6 +175,6 @@ setInterval(() => { } catch { // Window may be closing. } -}, 2500); +}, WINDOW_FRAME_SAMPLE_MS); console.log("Fulvid started"); diff --git a/src/mainview/app/App.vue b/src/mainview/app/App.vue index 941660d..315e256 100644 --- a/src/mainview/app/App.vue +++ b/src/mainview/app/App.vue @@ -23,7 +23,7 @@ import { workspaceName, validatedFocus, } from "./workspaceState"; -import { APP_ROUTE_NAMES } from "./router"; +import { APP_ROUTE_NAMES, type AppRouteName } from "./router"; import { notify } from "./notify"; import ToastHost from "./ToastHost.vue"; import DialogHost from "./DialogHost.vue"; @@ -42,6 +42,7 @@ import { openOrActivate, selectDocument, } from "../modules/editor/document/documentBuffers"; +import { adjacentOpenDocumentIndex } from "../modules/editor/editorTabNavigation"; import { documentTemplateTitleFromParentPath, renderDocumentTemplate, @@ -53,11 +54,7 @@ import { documentLocationFromBuffer, windowTitleForDocumentLocation, } from "../modules/editor/document/documentLocation"; -import { - activeId, - nextMruDocument, - pendingReveal, -} from "../modules/editor/document/documentSession"; +import { activeId, pendingReveal } from "../modules/editor/document/documentSession"; import { closeInspector, inspectorOpen, @@ -66,10 +63,12 @@ import { peekDocument, } from "../modules/workspace/focus/focusState"; import { isTextEntryTarget } from "./isTypingTarget"; +import { isTabKey } from "./isTabKey"; import { closeLeftSidebar, closeRightSidebar, CONTEXTUAL_WIDTH_LIMITS, + LAYOUT_RESIZE_STEP_PX, leftSidebarOpen, layout, openLeftSidebar, @@ -352,11 +351,10 @@ function closeContextPanel(): void { function activateAdjacentDocument(direction: -1 | 1): void { const currentIndex = openBuffers.value.findIndex((buffer) => buffer.id === activeId.value); - if (currentIndex < 0 || openBuffers.value.length < 2) { + const nextIndex = adjacentOpenDocumentIndex(currentIndex, direction, openBuffers.value.length); + if (nextIndex === null) { return; } - const nextIndex = - (currentIndex + direction + openBuffers.value.length) % openBuffers.value.length; const nextBuffer = openBuffers.value[nextIndex]; if (!nextBuffer) { return; @@ -364,16 +362,6 @@ function activateAdjacentDocument(direction: -1 | 1): void { selectDocument(nextBuffer.id); } -function activateMruDocument(direction: 1 | -1): void { - const nextId = nextMruDocument(direction); - if (!nextId) { - activateAdjacentDocument(direction); - return; - } - // No-op when the id is no longer open. - selectDocument(nextId); -} - function overlayFocusableElements(): HTMLElement[] { return [ ...document.querySelectorAll( @@ -387,6 +375,9 @@ function overlayFocusableElements(): HTMLElement[] { ); } +/** Ctrl/Cmd chords the native application menu owns when it draws the bar itself. */ +const NATIVE_MENU_ACCELERATOR_KEYS = new Set(["n", "o", "s", "w", "f", "h", "p"]); + function handleModifierShortcut(event: KeyboardEvent, insideMonaco: boolean): boolean { const modifier = event.metaKey || event.ctrlKey; if (!modifier) { @@ -402,11 +393,14 @@ function handleModifierShortcut(event: KeyboardEvent, insideMonaco: boolean): bo } const key = event.key.toLowerCase(); + // A native menu bar already delivers its own accelerators, so handling these + // here too would run the command twice. The HTML fallback bar draws no + // accelerators, so on that platform this handler stays the only route. if ( usesNativeApplicationMenu() && !event.shiftKey && !event.altKey && - ["n", "o", "s", "w", "f", "h", "p"].includes(key) + NATIVE_MENU_ACCELERATOR_KEYS.has(key) ) { return false; } @@ -459,9 +453,9 @@ function handleModifierShortcut(event: KeyboardEvent, insideMonaco: boolean): bo void runCommand("openFile"); return true; } - if (event.key === "Tab") { + if (isTabKey(event)) { event.preventDefault(); - activateMruDocument(event.shiftKey ? -1 : 1); + activateAdjacentDocument(event.shiftKey ? -1 : 1); return true; } if (event.key === "PageDown") { @@ -483,7 +477,7 @@ function handleModifierShortcut(event: KeyboardEvent, insideMonaco: boolean): bo } function trapOverlayTab(event: KeyboardEvent): boolean { - if (!overlayOpen.value || event.key !== "Tab") { + if (!overlayOpen.value || !isTabKey(event)) { return false; } @@ -507,33 +501,28 @@ function trapOverlayTab(event: KeyboardEvent): boolean { return true; } +/** Unmodified digits that jump straight to a route. */ +const ROUTE_BY_NAVIGATION_DIGIT = new Map([ + ["1", APP_ROUTE_NAMES.editor], + ["2", APP_ROUTE_NAMES.search], + ["3", APP_ROUTE_NAMES.graph], +]); + function handleNavigationShortcut(event: KeyboardEvent): boolean { - const key = event.key.toLowerCase(); - if ( - event.metaKey || - event.ctrlKey || - event.altKey || - isTextEntryTarget(event.target) || - !["/", "1", "2", "3", "i", "g"].includes(key) - ) { + if (event.metaKey || event.ctrlKey || event.altKey || isTextEntryTarget(event.target)) { return false; } + const key = event.key.toLowerCase(); if (key === "/") { event.preventDefault(); openGlobalSearch(); return true; } - if (key === "1" || key === "2" || key === "3") { + const digitRoute = ROUTE_BY_NAVIGATION_DIGIT.get(key); + if (digitRoute) { event.preventDefault(); - void router.push({ - name: - key === "1" - ? APP_ROUTE_NAMES.editor - : key === "2" - ? APP_ROUTE_NAMES.search - : APP_ROUTE_NAMES.graph, - }); + void router.push({ name: digitRoute }); return true; } if (key === "i") { @@ -548,9 +537,12 @@ function handleNavigationShortcut(event: KeyboardEvent): boolean { } return true; } - event.preventDefault(); - void router.push({ name: APP_ROUTE_NAMES.graph }); - return true; + if (key === "g") { + event.preventDefault(); + void router.push({ name: APP_ROUTE_NAMES.graph }); + return true; + } + return false; } function closePanelsOnEscape(event: KeyboardEvent): boolean { @@ -1303,8 +1295,10 @@ onBeforeUnmount(() => { :aria-valuemin="CONTEXTUAL_WIDTH_LIMITS.min" :aria-valuemax="CONTEXTUAL_WIDTH_LIMITS.max" @pointerdown="startContextualResize" - @keydown.left.prevent="setContextualWidth(layout.contextualWidth + 8)" - @keydown.right.prevent="setContextualWidth(layout.contextualWidth - 8)" + @keydown.left.prevent="setContextualWidth(layout.contextualWidth + LAYOUT_RESIZE_STEP_PX)" + @keydown.right.prevent=" + setContextualWidth(layout.contextualWidth - LAYOUT_RESIZE_STEP_PX) + " />
{{ contextPanelLabel }} diff --git a/src/mainview/app/isTabKey.ts b/src/mainview/app/isTabKey.ts new file mode 100644 index 0000000..f8f4ff6 --- /dev/null +++ b/src/mainview/app/isTabKey.ts @@ -0,0 +1,10 @@ +/** + * Tab identity for shortcut handlers. + * + * Some WebKit builds report Shift+Tab / Ctrl+Shift+Tab as + * `key: "ISO_Left_Tab"` while `code` stays `"Tab"`. Matching only `"Tab"` + * drops those chords; `code === "Tab"` covers layout-specific `key` values. + */ +export function isTabKey(event: Pick): boolean { + return event.key === "Tab" || event.key === "ISO_Left_Tab" || event.code === "Tab"; +} diff --git a/src/mainview/app/layoutStore.ts b/src/mainview/app/layoutStore.ts index 4ae7aa4..59b0a9f 100644 --- a/src/mainview/app/layoutStore.ts +++ b/src/mainview/app/layoutStore.ts @@ -9,18 +9,25 @@ import { computed, ref, watch } from "vue"; const STORAGE_KEY = "fulvid.layout.v1"; -const SIDEBAR_MIN = 200; -const SIDEBAR_MAX = 360; -const SIDEBAR_DEFAULT = 252; -const INSPECTOR_MIN = 260; -const INSPECTOR_MAX = 440; -const INSPECTOR_DEFAULT = 320; -const CONTEXTUAL_MIN = 260; -const CONTEXTUAL_MAX = 440; -const CONTEXTUAL_DEFAULT = 320; -const PREVIEW_MIN = 0.25; -const PREVIEW_MAX = 0.65; -const PREVIEW_DEFAULT = 0.42; +/** + * One resizable dimension: the bounds it is clamped to and the width it starts + * at. Declared once so a sanitize fallback, a setter, and the slider a surface + * exposes cannot drift apart. + */ +type LayoutRange = { + readonly min: number; + readonly max: number; + readonly default: number; +}; + +export const SIDEBAR_WIDTH_LIMITS: LayoutRange = { min: 200, max: 360, default: 252 }; +export const INSPECTOR_WIDTH_LIMITS: LayoutRange = { min: 260, max: 440, default: 320 }; +export const CONTEXTUAL_WIDTH_LIMITS: LayoutRange = { min: 260, max: 440, default: 320 }; +/** Fraction of the editor pane given to Preview, not a pixel width. */ +export const PREVIEW_RATIO_LIMITS: LayoutRange = { min: 0.25, max: 0.65, default: 0.42 }; + +/** Arrow-key step for every width resize handle. */ +export const LAYOUT_RESIZE_STEP_PX = 8; /** Shell collapses sidebars/overlays at this width. Keep CSS `@media` in sync. */ export const NARROW_VIEWPORT_MAX_PX = 900; @@ -41,28 +48,19 @@ function clamp(value: number, min: number, max: number): number { return Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : min; } -function clampedNumber(value: unknown, fallback: number, min: number, max: number): number { - return clamp(typeof value === "number" ? value : fallback, min, max); +/** A persisted width or ratio held inside its range, otherwise the default. */ +function clampedNumber(value: unknown, range: LayoutRange): number { + return clamp(typeof value === "number" ? value : range.default, range.min, range.max); } function sanitize(value: unknown): LayoutIntent { const source = value && typeof value === "object" ? (value as Partial) : {}; return { - sidebarWidth: clampedNumber(source.sidebarWidth, SIDEBAR_DEFAULT, SIDEBAR_MIN, SIDEBAR_MAX), - inspectorWidth: clampedNumber( - source.inspectorWidth, - INSPECTOR_DEFAULT, - INSPECTOR_MIN, - INSPECTOR_MAX, - ), - contextualWidth: clampedNumber( - source.contextualWidth, - CONTEXTUAL_DEFAULT, - CONTEXTUAL_MIN, - CONTEXTUAL_MAX, - ), - previewRatio: clampedNumber(source.previewRatio, PREVIEW_DEFAULT, PREVIEW_MIN, PREVIEW_MAX), + sidebarWidth: clampedNumber(source.sidebarWidth, SIDEBAR_WIDTH_LIMITS), + inspectorWidth: clampedNumber(source.inspectorWidth, INSPECTOR_WIDTH_LIMITS), + contextualWidth: clampedNumber(source.contextualWidth, CONTEXTUAL_WIDTH_LIMITS), + previewRatio: clampedNumber(source.previewRatio, PREVIEW_RATIO_LIMITS), leftSidebarOpen: typeof source.leftSidebarOpen === "boolean" ? source.leftSidebarOpen : true, rightSidebar: source.rightSidebar === "explorer" || @@ -100,26 +98,6 @@ export const rightSidebar = computed({ }, }); -export const SIDEBAR_WIDTH_LIMITS = { - min: SIDEBAR_MIN, - max: SIDEBAR_MAX, -} as const; - -export const INSPECTOR_WIDTH_LIMITS = { - min: INSPECTOR_MIN, - max: INSPECTOR_MAX, -} as const; - -export const CONTEXTUAL_WIDTH_LIMITS = { - min: CONTEXTUAL_MIN, - max: CONTEXTUAL_MAX, -} as const; - -export const PREVIEW_RATIO_LIMITS = { - min: PREVIEW_MIN, - max: PREVIEW_MAX, -} as const; - function applyLayout(intent: LayoutIntent): void { if (typeof document === "undefined") { return; @@ -133,28 +111,28 @@ function applyLayout(intent: LayoutIntent): void { export function setSidebarWidth(width: number): void { layout.value = { ...layout.value, - sidebarWidth: clamp(width, SIDEBAR_MIN, SIDEBAR_MAX), + sidebarWidth: clamp(width, SIDEBAR_WIDTH_LIMITS.min, SIDEBAR_WIDTH_LIMITS.max), }; } export function setInspectorWidth(width: number): void { layout.value = { ...layout.value, - inspectorWidth: clamp(width, INSPECTOR_MIN, INSPECTOR_MAX), + inspectorWidth: clamp(width, INSPECTOR_WIDTH_LIMITS.min, INSPECTOR_WIDTH_LIMITS.max), }; } export function setContextualWidth(width: number): void { layout.value = { ...layout.value, - contextualWidth: clamp(width, CONTEXTUAL_MIN, CONTEXTUAL_MAX), + contextualWidth: clamp(width, CONTEXTUAL_WIDTH_LIMITS.min, CONTEXTUAL_WIDTH_LIMITS.max), }; } export function setPreviewRatio(ratio: number): void { layout.value = { ...layout.value, - previewRatio: clamp(ratio, PREVIEW_MIN, PREVIEW_MAX), + previewRatio: clamp(ratio, PREVIEW_RATIO_LIMITS.min, PREVIEW_RATIO_LIMITS.max), }; } diff --git a/src/mainview/extensions/extensionManifest.ts b/src/mainview/extensions/extensionManifest.ts index bdfb910..cf77f9c 100644 --- a/src/mainview/extensions/extensionManifest.ts +++ b/src/mainview/extensions/extensionManifest.ts @@ -66,6 +66,19 @@ export const EXTENSION_PACK_LIMITS = { maxKeywordChars: 32, maxLicenseChars: 64, maxUrlChars: 500, + /** Author string, or each field of the author object. */ + maxAuthorChars: 200, + /** Menu placements one pack may declare - matches `maxCommandsPerExtension`. */ + maxActions: 16, + /** Placement title override - matches the Lua `maxCommandTitleChars` budget. */ + maxActionTitleChars: 200, + /** Placement sort key: a bounded integer, not an arbitrary number. */ + maxActionOrder: 10_000, + /** + * Load/invoke failure reason shown in Settings. Bounded so a guest error, a + * traceback, or a pasted source line cannot become the inventory row. + */ + maxFailureReasonChars: 300, } as const; /** @@ -287,7 +300,7 @@ function parseAuthor( ): { ok: true; author: ExtensionAuthor } | { ok: false; reason: string } { if (typeof value === "string") { const trimmed = value.trim(); - if (trimmed.length === 0 || trimmed.length > 200) { + if (trimmed.length === 0 || trimmed.length > EXTENSION_PACK_LIMITS.maxAuthorChars) { return { ok: false, reason: "invalid author" }; } return { ok: true, author: trimmed }; @@ -295,7 +308,7 @@ function parseAuthor( if (!isRecord(value) || typeof value.name !== "string" || value.name.trim().length === 0) { return { ok: false, reason: "invalid author" }; } - if (value.name.length > 200) { + if (value.name.length > EXTENSION_PACK_LIMITS.maxAuthorChars) { return { ok: false, reason: "author exceeds size limit" }; } const author: { name: string; email?: string; url?: string } = { name: value.name.trim() }; @@ -303,7 +316,7 @@ function parseAuthor( if ( typeof value.email !== "string" || value.email.trim().length === 0 || - value.email.length > 200 + value.email.length > EXTENSION_PACK_LIMITS.maxAuthorChars ) { return { ok: false, reason: "invalid author email" }; } @@ -477,7 +490,7 @@ export function validateExtensionManifest(value: unknown): ManifestValidationRes if (!Array.isArray(value.actions)) { return { reason: "actions must be an array" }; } - if (value.actions.length > 16) { + if (value.actions.length > EXTENSION_PACK_LIMITS.maxActions) { return { reason: "too many actions" }; } const seenActionIds = new Set(); @@ -512,7 +525,7 @@ export function validateExtensionManifest(value: unknown): ManifestValidationRes typeof rawAction.order !== "number" || !Number.isInteger(rawAction.order) || rawAction.order < 0 || - rawAction.order > 10_000 + rawAction.order > EXTENSION_PACK_LIMITS.maxActionOrder ) { return { reason: "invalid action order: integer 0..10000 required" }; } @@ -522,7 +535,7 @@ export function validateExtensionManifest(value: unknown): ManifestValidationRes if (typeof rawAction.title !== "string" || rawAction.title.trim().length === 0) { return { reason: "invalid action title: non-empty string required" }; } - if (rawAction.title.length > 200) { + if (rawAction.title.length > EXTENSION_PACK_LIMITS.maxActionTitleChars) { return { reason: "action title exceeds size limit" }; } placement.title = rawAction.title.trim(); diff --git a/src/mainview/i18n/de.ts b/src/mainview/i18n/de.ts index a8fbf0c..1dbc7ac 100644 --- a/src/mainview/i18n/de.ts +++ b/src/mainview/i18n/de.ts @@ -704,7 +704,11 @@ export default { shortcutSaveAs: "Das aktive Dokument als neue Datei speichern", shortcutClose: "Den aktiven Tab schließen", shortcutCloseOthers: "Alle anderen Tabs schließen", - shortcutTabs: "Zwischen geöffneten Dokument-Tabs wechseln", + shortcutTabs: "Den nächsten oder vorherigen geöffneten Tab in Tab-Reihenfolge aktivieren", + shortcutTabsOrder: "Den nächsten oder vorherigen geöffneten Tab in Tab-Reihenfolge aktivieren", + shortcutTabList: + "Den vorherigen oder nächsten Tab aktivieren, wenn der Tab-Streifen fokussiert ist", + 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: @@ -733,7 +737,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", @@ -854,7 +858,7 @@ export default { missingAnchor: 'Die Überschrift "{anchor}" existiert in diesem Dokument nicht.', candidates: "Mögliche Treffer: {items}", alsoMatches: "Passt ebenfalls: {items}", - open: "{path} öffnen", + open: "Öffnen", unresolvedTitle: "Dieser Link passt zu keinem Dokument", heading: "Überschrift #{anchor}: {status}", missingAnchorStatus: "fehlt", diff --git a/src/mainview/i18n/en.ts b/src/mainview/i18n/en.ts index 85db088..2977e4c 100644 --- a/src/mainview/i18n/en.ts +++ b/src/mainview/i18n/en.ts @@ -690,7 +690,10 @@ export default { shortcutSaveAs: "Save the active document as a new file", shortcutClose: "Close the active tab", shortcutCloseOthers: "Close every other tab", - shortcutTabs: "Move between open document tabs", + shortcutTabs: "Activate the next or previous open tab in tab order", + shortcutTabsOrder: "Activate the next or previous open tab in tab order", + shortcutTabList: "Activate the previous or next tab when the tab strip is focused", + 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: @@ -717,7 +720,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", @@ -835,7 +838,7 @@ export default { missingAnchor: 'Heading "{anchor}" does not exist in that document.', candidates: "Possible matches: {items}", alsoMatches: "Also matches: {items}", - open: "Open {path}", + open: "Open", unresolvedTitle: "This link does not match a document", heading: "Heading #{anchor}: {status}", missingAnchorStatus: "missing", diff --git a/src/mainview/i18n/es.ts b/src/mainview/i18n/es.ts index c55ca51..a009229 100644 --- a/src/mainview/i18n/es.ts +++ b/src/mainview/i18n/es.ts @@ -701,7 +701,11 @@ export default { shortcutSaveAs: "Guardar el documento activo como archivo nuevo", shortcutClose: "Cerrar la pestaña activa", shortcutCloseOthers: "Cerrar todas las demás pestañas", - shortcutTabs: "Moverse entre documentos abiertos", + shortcutTabs: "Activar la pestaña abierta siguiente o anterior en el orden de pestañas", + shortcutTabsOrder: "Activar la pestaña abierta siguiente o anterior en el orden de pestañas", + shortcutTabList: + "Activar la pestaña anterior o siguiente cuando la franja de pestañas tiene el foco", + 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: @@ -729,7 +733,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", @@ -851,7 +855,7 @@ export default { missingAnchor: 'El encabezado "{anchor}" no existe en ese documento.', candidates: "Posibles coincidencias: {items}", alsoMatches: "También coincide con: {items}", - open: "Abrir {path}", + open: "Abrir", unresolvedTitle: "Este enlace no coincide con un documento", heading: "Encabezado #{anchor}: {status}", missingAnchorStatus: "ausente", diff --git a/src/mainview/i18n/fr.ts b/src/mainview/i18n/fr.ts index 069bcb7..46d1861 100644 --- a/src/mainview/i18n/fr.ts +++ b/src/mainview/i18n/fr.ts @@ -711,7 +711,10 @@ export default { shortcutSaveAs: "Enregistrer le document actif dans un nouveau fichier", shortcutClose: "Fermer l'onglet actif", shortcutCloseOthers: "Fermer tous les autres onglets", - shortcutTabs: "Passer d'un onglet de document ouvert à l'autre", + shortcutTabs: "Activer l'onglet ouvert suivant ou précédent dans l'ordre des onglets", + shortcutTabsOrder: "Activer l'onglet ouvert suivant ou précédent dans l'ordre des onglets", + shortcutTabList: "Activer l'onglet précédent ou suivant lorsque la barre d'onglets a le focus", + 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: @@ -739,7 +742,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", @@ -860,7 +863,7 @@ export default { missingAnchor: 'Le titre "{anchor}" n\'existe pas dans ce document.', candidates: "Correspondances possibles : {items}", alsoMatches: "Correspond aussi : {items}", - open: "Ouvrir {path}", + open: "Ouvrir", unresolvedTitle: "Ce lien ne correspond à aucun document", heading: "Titre #{anchor} : {status}", missingAnchorStatus: "manquant", 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..b09837b 100644 --- a/src/mainview/i18n/it.ts +++ b/src/mainview/i18n/it.ts @@ -704,7 +704,11 @@ export default { shortcutSaveAs: "Salva il documento attivo come nuovo file", shortcutClose: "Chiudi la scheda attiva", shortcutCloseOthers: "Chiudi tutte le altre schede", - shortcutTabs: "Spostati tra le schede dei documenti aperti", + shortcutTabs: "Attiva la scheda aperta successiva o precedente nell'ordine delle schede", + shortcutTabsOrder: "Attiva la scheda aperta successiva o precedente nell'ordine delle schede", + shortcutTabList: + "Attiva la scheda precedente o successiva quando la barra delle schede ha il focus", + shortcutMoveTab: "Sposta a sinistra o a destra la scheda del documento focalizzata", shortcutContext: "Apri il Contesto del documento selezionato (I).", shortcutGraph: "Dov'è questo documento?", shortcutEscape: @@ -731,7 +735,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", @@ -852,7 +856,7 @@ export default { missingAnchor: 'Il titolo "{anchor}" non esiste in quel documento.', candidates: "Possibili corrispondenze: {items}", alsoMatches: "Corrisponde anche: {items}", - open: "Apri {path}", + open: "Apri", unresolvedTitle: "Questo collegamento non corrisponde a un documento", heading: "Titolo #{anchor}: {status}", missingAnchorStatus: "mancante", diff --git a/src/mainview/i18n/nl.ts b/src/mainview/i18n/nl.ts index bf0fa8e..8f8d03a 100644 --- a/src/mainview/i18n/nl.ts +++ b/src/mainview/i18n/nl.ts @@ -703,7 +703,11 @@ export default { shortcutSaveAs: "Het actieve document als nieuw bestand opslaan", shortcutClose: "Het actieve tabblad sluiten", shortcutCloseOthers: "Alle andere tabbladen sluiten", - shortcutTabs: "Wisselen tussen geopende documenttabbladen", + shortcutTabs: "Activeer het volgende of vorige geopende tabblad in tabbladvolgorde", + shortcutTabsOrder: "Activeer het volgende of vorige geopende tabblad in tabbladvolgorde", + shortcutTabList: + "Activeer het vorige of volgende tabblad wanneer de tabbladbalk de focus heeft", + shortcutMoveTab: "Verplaats het gefocuste documenttabblad naar links of rechts", shortcutContext: "Documentcontext voor het geselecteerde document openen (I).", shortcutGraph: "Waar is dit document?", shortcutEscape: @@ -729,7 +733,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", @@ -851,7 +855,7 @@ export default { missingAnchor: 'Kop "{anchor}" bestaat niet in dat document.', candidates: "Mogelijke overeenkomsten: {items}", alsoMatches: "Komt ook overeen: {items}", - open: "{path} openen", + open: "Openen", unresolvedTitle: "Deze koppeling komt niet overeen met een document", heading: "Kop #{anchor}: {status}", missingAnchorStatus: "ontbreekt", diff --git a/src/mainview/i18n/pt.ts b/src/mainview/i18n/pt.ts index 0d963bd..af44e49 100644 --- a/src/mainview/i18n/pt.ts +++ b/src/mainview/i18n/pt.ts @@ -703,7 +703,11 @@ export default { shortcutSaveAs: "Guardar o documento ativo como um novo ficheiro", shortcutClose: "Fechar o separador ativo", shortcutCloseOthers: "Fechar todos os outros separadores", - shortcutTabs: "Alternar entre separadores de documentos abertos", + shortcutTabs: "Ativar o separador aberto seguinte ou anterior na ordem dos separadores", + shortcutTabsOrder: "Ativar o separador aberto seguinte ou anterior na ordem dos separadores", + shortcutTabList: + "Ativar o separador anterior ou seguinte quando a faixa de separadores tem o foco", + 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: @@ -731,7 +735,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", @@ -854,7 +858,7 @@ export default { missingAnchor: 'O título "{anchor}" não existe nesse documento.', candidates: "Possíveis correspondências: {items}", alsoMatches: "Também corresponde: {items}", - open: "Abrir {path}", + open: "Abrir", unresolvedTitle: "Esta ligação não corresponde a um documento", heading: "Título #{anchor}: {status}", missingAnchorStatus: "em falta", 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/document/inspector/InspectorDrawer.vue b/src/mainview/modules/document/inspector/InspectorDrawer.vue index 35d5e45..e11c1f8 100644 --- a/src/mainview/modules/document/inspector/InspectorDrawer.vue +++ b/src/mainview/modules/document/inspector/InspectorDrawer.vue @@ -43,7 +43,12 @@ import FactSection from "../facts/FactSection.vue"; import FactStatement from "../facts/FactStatement.vue"; import ContextMenu, { type ContextMenuAction } from "../../../shell/ContextMenu.vue"; import { notify } from "../../../app/notify"; -import { INSPECTOR_WIDTH_LIMITS, layout, setInspectorWidth } from "../../../app/layoutStore"; +import { + INSPECTOR_WIDTH_LIMITS, + LAYOUT_RESIZE_STEP_PX, + layout, + setInspectorWidth, +} from "../../../app/layoutStore"; import { workspaceNotes, copyWorkspacePath, @@ -398,8 +403,8 @@ onBeforeUnmount(() => { :aria-valuemax="INSPECTOR_WIDTH_LIMITS.max" tabindex="0" @pointerdown="startInspectorResize" - @keydown.left.prevent="setInspectorWidth(layout.inspectorWidth + 8)" - @keydown.right.prevent="setInspectorWidth(layout.inspectorWidth - 8)" + @keydown.left.prevent="setInspectorWidth(layout.inspectorWidth + LAYOUT_RESIZE_STEP_PX)" + @keydown.right.prevent="setInspectorWidth(layout.inspectorWidth - LAYOUT_RESIZE_STEP_PX)" />
@@ -568,7 +573,7 @@ onBeforeUnmount(() => { v-if="entry.candidates.length === 1" type="button" class="inspector-panel__unresolved inspector-panel__unresolved--action" - :title="t('links.open', { path: entry.candidates[0].relativePath })" + :title="t('links.open')" @click="openConnectedDocument(entry.candidates[0].path)" > {{ formatUnresolvedLink(entry.link) }} diff --git a/src/mainview/modules/document/links/linkHoverSourcePeek.ts b/src/mainview/modules/document/links/linkHoverSourcePeek.ts new file mode 100644 index 0000000..3f4e7d5 --- /dev/null +++ b/src/mainview/modules/document/links/linkHoverSourcePeek.ts @@ -0,0 +1,181 @@ +/** + * Short Markdown-source peek for document-link hover. + * + * Uses the same target text already loaded for hover/heading checks + * (`contentForPath`). Does not render HTML, call marked, summarize, or + * invent text. Distinct from PreviewPane / `renderMarkdownPreview`. + */ + +/** How much of the target we may scan to build the peek (large-file ceiling). */ +export const LINK_HOVER_PEEK_READ_CAP = 4_096; + +/** Soft visual cap: a few lines in the hover tooltip. */ +export const LINK_HOVER_PEEK_MAX_LINES = 5; + +/** Soft visual cap: short enough that path + honesty stay primary. */ +export const LINK_HOVER_PEEK_MAX_CHARS = 220; + +const CLOSED_FRONTMATTER_RE = /^---\r?\n[\s\S]*?\r?\n---\r?\n?/; + +export type LinkHoverSourcePeekOptions = { + /** 1-based heading line when the link targets a fragment. */ + startLineNumber?: number; +}; + +function offsetOfLine(content: string, lineNumber1Based: number): number { + if (lineNumber1Based <= 1) { + return 0; + } + let line = 1; + for (let index = 0; index < content.length; index += 1) { + if (content.charCodeAt(index) === 0x0a) { + line += 1; + if (line === lineNumber1Based) { + return index + 1; + } + } + } + return content.length; +} + +/** Start of body after a closed YAML frontmatter block; else 0. */ +export function markdownBodyStartOffset(content: string): number { + const match = CLOSED_FRONTMATTER_RE.exec(content); + return match ? match[0].length : 0; +} + +/** + * Escape text so Monaco hover Markdown renders it literally. + * + * A hover body is Markdown, and the values composed into one come from + * documents: a link label, a heading in the target, a filename. Left raw, a + * heading such as `![](https://host/pixel.png)` becomes an image request from a + * tooltip, and `[text](https://host)` becomes a clickable external link - + * document content reaching the network, which `isTrusted: false` and + * `supportHtml: false` do not prevent because both are ordinary Markdown. + * + * The single rule for that escape lives here; annotation hovers use it too. + */ +export function escapeHoverMarkdownText(text: string): string { + return text.replace(/([\\`*_{}[\]()#+\-.!|<>~])/g, "\\$1"); +} + +/** + * Fence source so Monaco hover Markdown cannot turn it into links/HTML. + * Lengthens the fence if the excerpt already contains the same backtick run. + */ +export function linkHoverPeekAsHoverMarkdown(peek: string): string { + let fence = "```"; + while (peek.includes(fence)) { + fence += "`"; + } + return `${fence}markdown\n${peek}\n${fence}`; +} + +/** + * Compact hover body: identity, path only when it adds information, optional + * honesty lines, then a secondary source peek in one Markdown block. + * + * Every part except `peek` is emitted as Markdown, so callers pass text that is + * already safe: run document-derived values through `escapeHoverMarkdownText` + * first. Detail lines arrive localized, with the untrusted values already + * escaped inside them, so this function cannot escape them itself. + */ +export function linkHoverContentsMarkdown(parts: { + title: string; + path?: string | null; + detailLines?: readonly string[]; + peek?: string | null; +}): string { + const lines: string[] = [`**${parts.title}**`]; + if (parts.path && parts.path !== parts.title) { + lines.push(parts.path); + } + for (const detail of parts.detailLines ?? []) { + if (detail) { + lines.push(detail); + } + } + const header = lines.join("\n"); + if (!parts.peek) { + return header; + } + return `${header}\n\n${linkHoverPeekAsHoverMarkdown(parts.peek)}`; +} + +/** + * Small excerpt of Markdown source from `content`, or null when there is + * nothing useful to show (empty / frontmatter-only). + */ +export function linkHoverSourcePeek( + content: string, + options: LinkHoverSourcePeekOptions = {}, +): string | null { + if (!content) { + return null; + } + + const bodyOffset = markdownBodyStartOffset(content); + const headingOffset = + options.startLineNumber !== undefined + ? offsetOfLine(content, options.startLineNumber) + : bodyOffset; + const startOffset = Math.max(bodyOffset, headingOffset); + if (startOffset >= content.length) { + return null; + } + + const window = content.slice(startOffset, startOffset + LINK_HOVER_PEEK_READ_CAP); + const truncatedByReadCap = startOffset + LINK_HOVER_PEEK_READ_CAP < content.length; + const lines = window.split(/\r?\n/); + + let firstContent = 0; + while (firstContent < lines.length && lines[firstContent]?.trim() === "") { + firstContent += 1; + } + if (firstContent >= lines.length) { + return null; + } + + const taken: string[] = []; + let chars = 0; + let truncated = truncatedByReadCap; + for (let index = firstContent; index < lines.length; index += 1) { + const line = lines[index] ?? ""; + if (taken.length >= LINK_HOVER_PEEK_MAX_LINES) { + truncated = true; + break; + } + const newlineCost = taken.length > 0 ? 1 : 0; + const remaining = LINK_HOVER_PEEK_MAX_CHARS - chars - newlineCost; + if (remaining <= 0) { + truncated = true; + break; + } + if (line.length > remaining) { + taken.push(`${line.slice(0, remaining)}...`); + truncated = true; + break; + } + taken.push(line); + chars += line.length + newlineCost; + } + + if (taken.length === 0) { + return null; + } + + // Drop trailing blank lines so the fence does not look padded. + while (taken.length > 0 && taken[taken.length - 1]?.trim() === "") { + taken.pop(); + } + if (taken.length === 0) { + return null; + } + + let peek = taken.join("\n"); + if (truncated && !peek.endsWith("...")) { + peek = `${peek}\n...`; + } + return peek; +} diff --git a/src/mainview/modules/editor/EditorTabs.vue b/src/mainview/modules/editor/EditorTabs.vue index fa3c4d2..87991a7 100644 --- a/src/mainview/modules/editor/EditorTabs.vue +++ b/src/mainview/modules/editor/EditorTabs.vue @@ -1,13 +1,15 @@ @@ -154,14 +280,25 @@ defineExpose({ focusActiveTab }); :aria-label="t('tabs.openDocuments')" > +