Skip to content

feat(browser): save a configured layer and re-add it from My Data - #1564

Merged
giswqs merged 13 commits into
mainfrom
feat/issue-1520-layer-library
Jul 31, 2026
Merged

feat(browser): save a configured layer and re-add it from My Data#1564
giswqs merged 13 commits into
mainfrom
feat/issue-1520-layer-library

Conversation

@giswqs

@giswqs giswqs commented Jul 30, 2026

Copy link
Copy Markdown
Member

Fixes #1520

Adds a Layer Library: save a fully configured layer once, re-add it to any later project in one click. This closes the gap the existing libraries left — styles (Style Manager), services, views (bookmarks), and connections/favorites all persist across projects, but the layer itself did not, so re-adding a COG, a PostGIS table, or a remote GeoParquet meant walking the Add Data dialog again and restyling from scratch.

What it stores

packages/core/src/layer-library.ts follows the shape of style-library.ts and captures the source specification, not the data: source spec, layer type, full LayerStyle (labels included), opacity, metadata, joins, virtual fields, and the attribute form. The library stays small and an entry always reflects its source's current contents. Project-specific placement (id, groupId, beforeId) is deliberately not captured; the transient XYZ proxy rewrite (metadata.resolvedUrl) is stripped.

Edge cases from the issue

  • In-memory features (drawn features, processing output) and local files have no re-fetchable source, so their features embed behind a 5 MB per-entry cap. Over the cap, a local-file layer degrades to a path-only entry that only a filesystem-capable host can re-add — badged DESKTOP in the tree, with a clear message in the browser build instead of a silent failure. An in-memory layer over the cap is refused with a specific message rather than saved broken.
  • Control-painted layers (Add Vector Layer, deck.gl rasters, 3D Tiles, LiDAR, Planetary Computer) are drawn by their plugin's restore pass, not by the store sync. Their features therefore go into metadata.embeddedGeoJSON — the same field the project Embed/Share flow writes and the plugin's restore pass reads — and a re-add runs that pass (apps/geolibre-desktop/src/lib/restore-library-layer.ts). Without this the re-added layer lists in the Layers panel and draws nothing; the current data is read from the control via materializeEmbeddableVectorLayers, so a tiles-mode layer works too.
  • Entries with nothing to re-add from are excluded at both ends: the menu item does not appear, and a hand-edited bundle carrying such an entry has it dropped on import rather than showing a permanently-failing row.

Entry points

  • Layers panel → layer actions → Save to My Data, confirmed (or refused, with the reason) in the existing per-layer status row.
  • A My Data root node in the Browser panel, directly above Services and below Favorites, sharing the tree's existing keyboard navigation and search. Rows rename in place and delete; the section header imports and exports the library as a JSON bundle, matching how the Style Manager shares presets. Re-importing an exported bundle upserts by id rather than duplicating.

Persistence

An app-level store slice persisted to IndexedDB (layer-library-store.ts + useLayerLibraryPersistence), mirroring the Style Manager: outside the project lifecycle, never serialized into .geolibre.json, untouched by newProject/loadProject, and excluded from undo history. getAll() returns key order, so each record carries its display index and the load restores the most-recently-saved-first order.

LayerType is now derived from a new runtime LAYER_TYPES array, so an imported bundle's layer type is validated against the real union with no chance of the list and the type drifting.

Verification

Driven in a real browser with Playwright against us_cities.geojson from a local dataset directory, in both dark and light themes:

  1. Added the layer, set opacity 0.70 and fill #e11d48, saved it to My Data (confirmation shown, entry written to IndexedDB with opacity: 0.7, fillColor: #e11d48, and 109 features under metadata.embeddedGeoJSON).
  2. Removed the layer from the project, re-added it from My Data: it came back with the same name, opacity, and crimson fill, and rendered its 109 points (the vector control adopted it — geojson · 109 ft).
  3. Confirmed persistence across a fresh page load (the entry was still listed before any save in that session).
  4. Imported a hand-written bundle carrying a source-only XYZ entry at opacity: 0.55; adding it produced a layer indistinguishable from the same service added through the app's own Services path.
  5. Renamed an entry (persisted to IndexedDB) and deleted one (persisted; project layers already added from it were left alone).

Export opens the browser's native save picker, which the headless run cannot complete — it goes through the same shared saveTextFileWithFallback helper as the Style Manager export, and serializeLayerLibrary is covered by unit tests.

Tests: 31 new cases in tests/layer-library.test.ts (capture rules, the size-cap degradation ladder, the add plan, untrusted-input normalization, bundle round-trip), 8 in tests/browser-tree.test.ts for the My Data section, and 4 in tests/layer-library-store.test.ts for the stored-order restore. Full frontend suite (4448 passing), the coverage gate, npm run build, and pre-commit all pass.

Summary by CodeRabbit

  • New Features
    • Added a desktop My Data Layer Library with IndexedDB persistence for saving fully configured layers and re-adding them later.
    • Browser now supports My Data in the tree, including inline rename, delete, and per-layer JSON import/export.
    • Layer cards can Save to Library when eligible (including special handling for control-painted layers and large/embeddable content).
  • Bug Fixes
    • Import/export section no longer gets hidden when it has no children.
  • Documentation
    • Updated Layer Library feature guidance.
  • Tests
    • Added/expanded coverage for My Data tree behavior, persistence/order sorting, and Layer Library bundle utilities.

Add a Layer Library so a fully configured layer can be saved once and
re-added to any later project in one click, closing the gap the existing
libraries left: styles, services, views, and connections all persist
across projects, but the layer itself did not.

- `packages/core/src/layer-library.ts` follows `style-library.ts`: it
  captures a layer's source spec, type, full style (labels included),
  opacity, metadata, joins, virtual fields, and attribute form, plans how
  to re-add one, normalizes untrusted input, and reads/writes the
  shareable JSON bundle. Project-specific placement (id, groupId,
  beforeId) is deliberately not captured.
- Entries store the **source specification, not the data**, so the
  library stays small and an entry always reflects its source's current
  contents. A layer whose features exist only in memory or in a local
  file embeds them behind a 5 MB per-entry cap; over the cap, a local
  file degrades to a path-only entry that only a filesystem-capable host
  can re-add (badged "desktop" in the tree).
- Control-painted layers (Add Vector Layer, deck.gl rasters, 3D Tiles,
  LiDAR, Planetary Computer) are drawn by their plugin's restore pass,
  not by the store sync, so their features go in
  `metadata.embeddedGeoJSON` (the same field the project Embed/Share flow
  writes) and a re-add runs that pass via `restore-library-layer.ts`.
  Without it the layer would list in the Layers panel and draw nothing.
- Entry points: Layers panel → layer actions → **Save to My Data**, and a
  **My Data** root node in the Browser panel above Services, sharing the
  tree's keyboard navigation. Rows rename in place and delete; the section
  header imports and exports the library as a JSON bundle.
- The library is an app-level store slice persisted to IndexedDB
  (`layer-library-store.ts` + `useLayerLibraryPersistence`), outside the
  project lifecycle and outside undo history, with the display order
  stored alongside each record since `getAll()` returns key order.
- `LayerType` is now derived from a new runtime `LAYER_TYPES` array so an
  imported bundle's layer type can be validated without the two drifting.

Fixes #1520
Copilot AI review requested due to automatic review settings July 30, 2026 03:20

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a persistent Layer Library for saving configured layers, restoring them in projects, managing entries through the Browser panel, and sharing them through JSON import/export. The implementation includes IndexedDB persistence, source and embedded-feature handling, restoration passes, and validation tests.

Changes

Layer Library contracts and serialization

Layer / File(s) Summary
Layer Library contracts and serialization
packages/core/src/types.ts, packages/core/src/layer-library.ts, packages/core/src/paths.ts, packages/core/src/external-native-paint.ts, packages/core/src/index.ts, apps/geolibre-desktop/src/lib/tauri-io.ts, tests/layer-library.test.ts
Defines Layer Library entries, capture and restore plans, size limits, path validation, normalization, bundle parsing/serialization, runtime layer types, and generated entry IDs with utility tests.

Layer Library state and IndexedDB persistence

Layer / File(s) Summary
Layer Library state and IndexedDB persistence
packages/core/src/store.ts, apps/geolibre-desktop/src/lib/layer-library-store.ts, apps/geolibre-desktop/src/hooks/useLayerLibraryPersistence.ts, apps/geolibre-desktop/src/App.tsx, tests/layer-library-store.test.ts
Adds app-level store actions and startup synchronization with IndexedDB, including ordered reads, queued writes, retry handling, and persistence tests.

Layer saving and restoration integration

Layer / File(s) Summary
Layer saving and restoration integration
apps/geolibre-desktop/src/components/panels/LayerPanel.tsx, apps/geolibre-desktop/src/lib/restore-library-layer.ts, packages/map/src/layer-sync.ts, packages/plugins/src/index.ts, packages/plugins/src/plugins/*
Adds configured-layer capture from the Layer Panel, control-rendered layer detection, restore passes for supported source kinds, and shared plugin source-kind exports.

My Data browser workflow

Layer / File(s) Summary
My Data browser workflow
apps/geolibre-desktop/src/lib/browser-tree.ts, apps/geolibre-desktop/src/hooks/useBrowserTree.ts, apps/geolibre-desktop/src/components/panels/BrowserPanel.tsx, apps/geolibre-desktop/src/components/panels/BrowserTreeNode.tsx, apps/geolibre-desktop/src/i18n/locales/en.json, docs/features.md, tests/browser-tree.test.ts
Adds the My Data tree section with layer activation, rename, delete, import, export, inline editing, local-file handling, localization, documentation, and tree behavior tests.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant LayerPanel
  participant AppState
  participant IndexedDB
  participant BrowserPanel
  participant BrowserTreeNode
  LayerPanel->>AppState: saveLayerLibraryEntry
  AppState->>IndexedDB: persist layer library
  BrowserTreeNode->>BrowserPanel: activate library-layer
  BrowserPanel->>AppState: lookup and plan library entry
  BrowserPanel->>AppState: add and restore layer
Loading

Suggested reviewers: craun718

Poem

A bunny tucked layers in a library bright,
With styles and sources kept snug overnight.
“My Data!” they cheer, with a hop and a spin,
Import, export, rename—let reuse begin.
Saved little layers return home soon.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 69.57% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: saving configured layers and re-adding them from My Data.
Linked Issues check ✅ Passed The changes implement the Layer Library, My Data browser integration, import/export, rename/delete, and edge-case handling requested by #1520.
Out of Scope Changes check ✅ Passed The diff stays focused on the Layer Library feature set and supporting tests/docs, with no obvious unrelated additions.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/issue-1520-layer-library

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

🔍 Cloudflare PR preview

Item Value
Site https://f30a5e17.geolibre-preview.pages.dev
Demo app https://f30a5e17.geolibre-preview.pages.dev/demo/
Commit fdd0f20

@github-actions

github-actions Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

🔍 GitHub Pages PR preview

Item Value
Site https://opengeos.org/pages-preview/GeoLibre/pr-1564/
Demo app https://opengeos.org/pages-preview/GeoLibre/pr-1564/demo/
Commit fdd0f20

Comment thread apps/geolibre-desktop/src/lib/restore-library-layer.ts
Comment thread apps/geolibre-desktop/src/components/panels/BrowserTreeNode.tsx
Comment thread apps/geolibre-desktop/src/components/panels/BrowserPanel.tsx
@github-actions

Copy link
Copy Markdown
Contributor

All three inline comments posted. Now the final summary.

Code review

Bugs

  • RESTORE_BY_SOURCE_KIND (apps/geolibre-desktop/src/lib/restore-library-layer.ts:36-42) only covers 5 sourceKind values, but canSaveLayerToLibrary doesn't gate on sourceKind at all — it offers "Save to My Data" for any layer with a re-fetchable source or local path. Several other control-painted layer kinds (COG/GeoTIFF via the Vantor/STAC control, PMTiles, Zarr, Gaussian Splat, Earth Engine, Esri Wayback, DuckDB Query, GeoLens, Basemap Control) qualify to save but have no restore pass registered, so they silently re-add "unrendered" — exactly the failure mode this feature was designed to prevent. High confidence.
  • RenameRow's cancelled guard (apps/geolibre-desktop/src/components/panels/BrowserTreeNode.tsx:108-135) uses useState, but the same codebase already had to fix this exact bug class elsewhere (LayerPanel.tsx's handledRef/suppressBlurCommitRef) using a useRef instead, because a state update on a component that's being unmounted in the same batch never actually commits — so the trailing blur from removing the focused input fires with a stale cancelled === false closure, committing an Escape-cancelled rename anyway. Medium-high confidence.
  • The plan.kind === "layer" branch in BrowserPanel.tsx's activate (lines 465-474) never calls beginBusy, unlike every sibling branch, so the busyRef re-entrancy guard doesn't protect it — a fast double-click on a My Data row can add the same saved layer twice. Medium confidence.

Security

  • No issues found. Notably, the PostGIS "editable table" and "tiles-mode" flows deliberately keep connection strings out of layer.source/metadata (an in-memory registry + masked label instead), so the Layer Library's JSON export doesn't leak PostGIS credentials.

Performance

  • Nothing notable; entries stay small (source specs, not data) and IndexedDB writes are queued/serialized appropriately.

Quality

  • No further issues beyond the bugs above; the new layer-library.ts module is well-documented and its normalization/capture logic (size-cap degradation, transient-metadata stripping, untrusted-bundle validation) is thorough and well-tested.

CLAUDE.md

  • No violations found — new user-facing strings go through t()/en.json, no node_modules edits, no relevant mirrors (LAYER_TYPES, etc.) needed updating for this change.

- Gate "Save to My Data" on whether the layer can actually be re-rendered,
  not just on having something to re-add from. `canSaveLayerToLibrary` now
  takes a `canRestoreControlPainted` predicate and refuses a control-painted
  layer without it, and the desktop app supplies `canRestoreLibraryLayer`
  (backed by the same restore-pass table). Previously any layer with a
  re-fetchable URL or a local path was offered, so control-painted kinds with
  no restore route (`cog-url`, `zarr-url`, `flatgeobuf-url`, `splatting-url`,
  `earth-engine-raster`, `duckdb-query`, …) saved fine and then re-added
  unrendered — the exact failure `restore-library-layer.ts` exists to avoid.
- Give that gate a single source of truth: the `metadata.customLayerType`
  check layer-sync's dispatch branches on moves into core as
  `controlRendersLayer`, and layer-sync's `isExternalCustomLayer` delegates
  to it. Adding a kind to `RESTORE_BY_SOURCE_KIND` now also makes it
  saveable, so the two cannot drift.
- Guard the Browser tree's rename editor with a ref instead of state. Enter
  and Escape both clear the parent's `renamingId`, unmounting the editor and
  firing a trailing blur handled by the pre-keypress closure — a `useState`
  flag is discarded with the unmounting component, so Escape committed the
  abandoned draft and Enter committed twice. Mirrors `handledRef` /
  `suppressBlurCommitRef` in LayerPanel, which exist for this same reason.
- Hold the busy flag across a library add and await the restore pass, so the
  row spins through a slow re-ingest and a second click mid-restore cannot
  add the layer twice. Every other `activate` branch already did this.
Comment thread packages/core/src/layer-library.ts Outdated
Comment thread packages/core/src/layer-library.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/geolibre-desktop/src/components/panels/BrowserTreeNode.tsx`:
- Line 130: Update the rename editor container in BrowserTreeNode to use the
logical paddingInlineStart inset instead of physical paddingLeft, preserving its
current indentation in LTR and aligning it correctly in RTL. Also update the
corresponding existing row indentation styles in the same component where needed
for consistent logical-direction behavior.

In `@apps/geolibre-desktop/src/components/panels/LayerPanel.tsx`:
- Around line 1037-1076: Update handleSaveToLibrary to re-read the latest layer
from the store after the await to materialize features, then pass that refreshed
layer to captureLayerLibraryEntry while preserving the materialized features
from the original layer. Follow the existing post-await refresh pattern used by
handleImportStyle, handleSaveEditsToSource, and handleRefreshLayer.

In `@apps/geolibre-desktop/src/lib/restore-library-layer.ts`:
- Around line 40-46: Add dedicated tests for canRestoreLibraryLayer and
restoreLibraryLayer in restore-library-layer.ts. Cover a RESTORE_BY_SOURCE_KIND
control-painted source kind, an external-native control-painted layer outside
the map, and an external-native GeoLibre-rendered layer, asserting each follows
the expected restore gating and dispatch behavior.

In `@packages/core/src/layer-library.ts`:
- Around line 395-411: Update normalizeLayerLibraryEntries() to validate members
before cloning or retaining candidate.joins, candidate.virtualFields, and
candidate.attributeForm. Preserve only joins containing id, joinLayerId,
targetField, and joinField; virtual fields containing id, name, and expression;
and attribute-form configs whose fields array has a field key on every member.
Keep the existing structural gates while dropping malformed definitions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 5bed4205-81c0-4217-acb9-0a38e86ee319

📥 Commits

Reviewing files that changed from the base of the PR and between 2856ef8 and 1247b1e.

📒 Files selected for processing (21)
  • apps/geolibre-desktop/src/App.tsx
  • apps/geolibre-desktop/src/components/panels/BrowserPanel.tsx
  • apps/geolibre-desktop/src/components/panels/BrowserTreeNode.tsx
  • apps/geolibre-desktop/src/components/panels/LayerPanel.tsx
  • apps/geolibre-desktop/src/hooks/useBrowserTree.ts
  • apps/geolibre-desktop/src/hooks/useLayerLibraryPersistence.ts
  • apps/geolibre-desktop/src/i18n/locales/en.json
  • apps/geolibre-desktop/src/lib/browser-tree.ts
  • apps/geolibre-desktop/src/lib/layer-library-store.ts
  • apps/geolibre-desktop/src/lib/restore-library-layer.ts
  • docs/features.md
  • packages/core/src/external-native-paint.ts
  • packages/core/src/index.ts
  • packages/core/src/layer-library.ts
  • packages/core/src/store.ts
  • packages/core/src/types.ts
  • packages/map/src/layer-sync.ts
  • packages/plugins/src/index.ts
  • tests/browser-tree.test.ts
  • tests/layer-library-store.test.ts
  • tests/layer-library.test.ts

Comment thread apps/geolibre-desktop/src/components/panels/BrowserTreeNode.tsx Outdated
Comment thread apps/geolibre-desktop/src/components/panels/LayerPanel.tsx
Comment thread apps/geolibre-desktop/src/lib/restore-library-layer.ts
Comment thread packages/core/src/layer-library.ts Outdated
Comment thread packages/core/src/layer-library.ts Outdated
@github-actions

Copy link
Copy Markdown
Contributor

Code review

Bugs

  • layerLocalPath() in packages/core/src/layer-library.ts:72 accepts any non-empty sourcePath as a re-readable local path, but doesn't check metadata.localFileReloadable the way the rest of the codebase does (project.ts, restore-local-layers.ts, maplibre-vector.ts). A browser-picked Add Vector Layer file (no absolute path granted) still gets a bare-filename sourcePath; if its embedded features exceed the 5 MB cap, the entry silently degrades to a needsLocalFile: true entry that can never actually be re-added, while the user sees a false "Saved to My Data" success. Medium-high confidence.
  • planLayerLibraryAdd's local-file branch (packages/core/src/layer-library.ts:274) only returns { path, name }, dropping the entry's style, opacity, joins, virtualFields, and attributeForm even though captureLayerLibraryEntry preserved them for exactly this degraded case. BrowserPanel.tsx re-adds via the generic file-import flow with default styling, so a "desktop-only" saved layer silently loses all its configured presentation on re-add, with no warning to the user. Medium-high confidence.

Security

  • normalizeLayerLibraryEntries (packages/core/src/layer-library.ts:363) accepts an imported bundle's sourcePath/needsLocalFile with no absolute-path or ..-traversal validation, unlike every other place in the codebase that turns a persisted sourcePath back into a filesystem read (isAbsoluteLocalPath + hasPathTraversal checks used as defense-in-depth alongside the Rust-side is_allowed_local_vector_path backstop, which itself still permits .json and other broad vector extensions). Since the Layer Library's "Import saved layers" bundle is explicitly designed to be shared between users, a crafted bundle could point sourcePath at an arbitrary absolute file the importing user has on disk; clicking "Add" on that entry reaches the Tauri file-read command with no client-side check. Medium confidence — impact is bounded by the Rust-side extension allowlist, but the missing client-side guard is a real, inconsistent gap versus the rest of the codebase's stated threat model.

Performance

  • Nothing notable. IndexedDB reads/writes are per-user-action (save/rename/delete/import/export), not in a hot path, and the write queue correctly serializes concurrent persists.

Quality

  • The sourceKind → restore-pass mapping (RESTORE_BY_SOURCE_KIND in restore-library-layer.ts) and the controlRendersLayer/isExternalNativeLayerRecord gating were cross-checked against every plugin that sets customLayerType/sourceKind (Add Vector Layer, raster, planetary computer, 3D Tiles, LiDAR, plus the many kinds deliberately excluded — COG/GeoTIFF singles, STAC search, Gaussian splats, ArcGIS I3S, Overture, etc.) and is internally consistent with the PR's stated scope; no drift found. Low confidence there's anything to fix here — flagging only as a note that this was verified, not a nit.
  • planLayerLibraryAdd's returned name field for the local-file plan kind is computed but never consumed by its only call site (BrowserPanel.tsx), which is a symptom of the style/opacity/joins loss noted above rather than an independent issue.

CLAUDE.md

  • No violations found: new user-facing strings go through t()/en.json, RTL-safe Tailwind logical classes are used in the new rename/import/export controls, and no guidance about tile hosts, the Whitebox catalog, or the various mirrored constants applies to this change.

- Require `metadata.localFileReloadable` before treating `sourcePath` as a
  re-readable path. A browser-picked Add Vector Layer file has no path but
  `createVectorStoreLayer` still records the bare file *name* there, so an
  oversized one degraded into a `needsLocalFile` entry holding a filename no
  host could open — a false "Saved" for a permanently broken entry. It now
  reports "too large" instead, and a saved entry no longer carries the
  useless name at all.
- Validate a persisted `sourcePath` (absolute, no `..` traversal) on capture,
  on import, and in `planLayerLibraryAdd`. A bundle is shareable, so a crafted
  `needsLocalFile` entry could otherwise aim `onAddFilePath` at any file on the
  importing user's disk — the analogous project-restore paths already
  re-validate client-side before calling into Tauri. `isAbsoluteLocalPath`
  moves into core as `isAbsoluteFilesystemPath` so both sides share one rule.
- Carry the entry's style, opacity, joins, virtual fields, and attribute form
  on the `local-file` plan and apply them to the layer the file import
  produces. That import builds a default-styled layer, so a degraded entry
  previously came back unstyled, contradicting "save a fully configured
  layer". Joins and virtual fields go through their own store actions so the
  derived columns are materialized rather than stored inert.
- Drop join, virtual-field, and attribute-form members that lack the fields
  their engine needs, instead of accepting them on shape alone: a join with no
  `joinLayerId`/`joinField` resolves to nothing and would sit in the Joins UI
  as a permanently broken definition. A block with no surviving member is
  omitted.
- Capture from the current layer after awaiting the vector-control
  materialize, so a concurrent style/opacity/join edit is not lost to the
  pre-await closure (mirrors handleImportStyle / handleSaveEditsToSource).
- Switch the Browser tree's inline indents to `paddingInlineStart` so they sit
  on the reading-direction side in RTL locales, per the project's logical-
  property convention. Applied to every row in the component, not just the new
  rename editor, so the editor still lines up with the row it replaces.
Comment thread packages/core/src/layer-library.ts Outdated
Comment thread packages/core/src/layer-library.ts Outdated
Comment thread packages/core/src/layer-library.ts Outdated
@github-actions

Copy link
Copy Markdown
Contributor

Code review

Bugs

  • packages/core/src/layer-library.ts:90 — Locally-opened raster/COG layers (added via the maplibre-gl-raster control) can never be saved to the Layer Library. layerLocalPath() only recognizes metadata.localFileReloadable, a flag set by the vector control/plain-GeoJSON path, not the raster control (which uses metadata.localFilePath and puts only the basename in sourcePath). Combined with rasters having no .geojson and no source.url, canSaveLayerToLibrary is always false for these — the menu item silently never appears, despite restore-library-layer.ts explicitly wiring up a restore pass for this exact source kind. Confidence: medium.
  • packages/core/src/layer-library.ts:481normalizeLayerLibraryEntries (used on every startup load and on bundle import) doesn't strip TRANSIENT_METADATA_KEYS (resolvedUrl) the way captureLayerLibraryEntry does, so a stale per-session proxy rewrite could persist across reloads instead of being cleaned up. Confidence: low.

Security

  • packages/core/src/layer-library.ts:251captureLayerLibraryEntry clones layer.source verbatim with no credential redaction. An authenticated 3D Tiles layer's source.requestHeaders (bearer tokens/API keys for non-Google tilesets) flows straight into the saved entry and then into the "Export saved layers" JSON bundle — a flow this PR explicitly positions for sharing with a team. This is arguably inherited from the pre-existing project-file behavior, but Export turns it into a more direct, share-oriented leak path. Confidence: low-medium.

Performance

  • No issues found.

Quality

  • The rest of the implementation (capture/normalize/plan/import/export, IndexedDB persistence with ordered writes and retry-then-stay-unarmed loading, path-traversal validation on both capture and import, the RenameRow blur/unmount handling) is careful and well-tested (31+ new cases). No other notable issues found.

CLAUDE.md

  • No violations found — new strings go through t()/en.json, RTL-safe logical Tailwind classes are used in the new tree rows, and the affected shared constants/mirrors (LAYER_TYPES, controlRendersLayer) were consolidated rather than duplicated.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
apps/geolibre-desktop/src/components/panels/BrowserPanel.tsx (1)

624-627: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Restore tree focus after keyboard rename completion.

Enter/Escape clears renamingId and unmounts the focused input, leaving focus out of the tree. Restore focus to the renamed row after keyboard commit/cancel; preserve normal click-away focus by passing a keyboard-finish flag from RenameRow.

Also applies to: 742-745

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/geolibre-desktop/src/components/panels/BrowserPanel.tsx` around lines
624 - 627, Update commitRename and the corresponding cancel path to accept a
keyboard-finish flag from RenameRow, and when the rename ends via Enter or
Escape, restore focus to the renamed tree row after clearing renamingId; leave
click-away completion unchanged so it preserves normal focus behavior.
packages/core/src/layer-library.ts (1)

328-346: 🔒 Security & Privacy | 🔵 Trivial | 💤 Low value

Consider re-validating entry.sourcePath in the default "layer" plan too.

layerLibraryEntryNeedsLocalFile re-validates the path with isReReadablePath before routing to the "local-file" plan, but the fallback "layer" plan at Line 339 spreads entry.sourcePath (and the cloned entry.metadata, which can carry localFileReloadable: true) with no equivalent check. In the two real entry-creation paths (captureLayerLibraryEntry via layerLocalPath, and normalizeLayerLibraryEntries via the same isReReadablePath gate) sourcePath is always pre-validated before reaching here, so this isn't reachable today — but it's a one-line gap in the "validate everywhere a persisted path flows toward a read" invariant this file otherwise enforces consistently (see isReReadablePath's own doc comment: "Applied to captured and imported entries").

♻️ Optional hardening
-      ...(entry.sourcePath ? { sourcePath: entry.sourcePath } : {}),
+      ...(entry.sourcePath && isReReadablePath(entry.sourcePath) ? { sourcePath: entry.sourcePath } : {}),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/layer-library.ts` around lines 328 - 346, Re-validate
entry.sourcePath in the default layer-plan construction before including it in
the returned layer, using the existing isReReadablePath check. Preserve
sourcePath only when it passes validation, and ensure metadata cannot retain
localFileReloadable: true for an invalid path.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/geolibre-desktop/src/components/panels/BrowserPanel.tsx`:
- Around line 490-510: The file-import flow should use the created layer ID
returned by onAddFilePath instead of diffing global layer IDs. Update the shell
callback contract and its implementation to return the imported layer’s ID, then
in the import handling block use that ID directly with updateLayer,
setLayerJoins, and setLayerVirtualFields while preserving the existing error
handling.

---

Outside diff comments:
In `@apps/geolibre-desktop/src/components/panels/BrowserPanel.tsx`:
- Around line 624-627: Update commitRename and the corresponding cancel path to
accept a keyboard-finish flag from RenameRow, and when the rename ends via Enter
or Escape, restore focus to the renamed tree row after clearing renamingId;
leave click-away completion unchanged so it preserves normal focus behavior.

In `@packages/core/src/layer-library.ts`:
- Around line 328-346: Re-validate entry.sourcePath in the default layer-plan
construction before including it in the returned layer, using the existing
isReReadablePath check. Preserve sourcePath only when it passes validation, and
ensure metadata cannot retain localFileReloadable: true for an invalid path.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 052953c4-ec76-4728-a3c1-c0b468497b75

📥 Commits

Reviewing files that changed from the base of the PR and between 1247b1e and dd616ac.

📒 Files selected for processing (7)
  • apps/geolibre-desktop/src/components/panels/BrowserPanel.tsx
  • apps/geolibre-desktop/src/components/panels/BrowserTreeNode.tsx
  • apps/geolibre-desktop/src/components/panels/LayerPanel.tsx
  • apps/geolibre-desktop/src/lib/tauri-io.ts
  • packages/core/src/layer-library.ts
  • packages/core/src/paths.ts
  • tests/layer-library.test.ts

Comment thread apps/geolibre-desktop/src/components/panels/BrowserPanel.tsx
- Recognize a raster/COG's local path so a locally-opened one can be saved.
  The raster control records the absolute path in `metadata.localFilePath`
  (what `restoreRasterLayers` re-reads) and keeps only the basename in
  `sourcePath`, and it never sets `localFileReloadable` — so requiring that
  flag alone made every locally-opened COG unsaveable, despite
  `RESTORE_BY_SOURCE_KIND` already wiring its restore pass. `layerLocalPath`
  now honors both conventions, each re-validated.
- Redact `source.requestHeaders` from the exported bundle. An authenticated
  3D Tiles layer's bearer token is deliberately persisted into project files,
  but Export is pitched as one-click team sharing, which is a far more direct
  exposure path. The token stays in the local IndexedDB entry so re-adding on
  this machine still works, and only the bundle drops it — the recipient
  re-enters their own credentials.
- Strip the transient metadata keys in `normalizeLayerLibraryEntries` too, not
  only on capture. That path runs on every startup load, so a stale
  `resolvedUrl` on an older record or an imported bundle would otherwise keep
  replaying a dev-server proxy rewrite instead of being cleaned up.
- Identify the layer a `local-file` re-add produced by matching the path it was
  imported from, rather than requiring exactly one new store id. One file can
  legitimately yield several layers (a KML adds placemarks plus ground
  overlays and models) and an unrelated concurrent add would previously make
  the diff ambiguous and silently skip the config patch. Falls back to a lone
  new layer when no layer records a path, and still patches nothing rather
  than the wrong layer.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/geolibre-desktop/src/components/panels/BrowserPanel.tsx`:
- Around line 509-519: Update the layer restoration flow around onAddFilePath
and the added-layer lookup so configuration is never applied to an arbitrary
same-path output. Extend the import/library contract to preserve a per-output
discriminator or return all created layer descriptors, use it to identify the
saved layer uniquely, and surface an error when no unique target can be
determined; do not fall back to the first matching output.

In `@packages/core/src/layer-library.ts`:
- Around line 491-495: Update the metadata normalization in the layer
restoration flow to validate metadata.localFilePath against the already
validated sourcePath. Remove an invalid or conflicting localFilePath, or
overwrite it with sourcePath, so raster restoration cannot read from an
untrusted path; preserve the existing TRANSIENT_METADATA_KEYS cleanup.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 8a6d4f55-9346-4c32-9ccf-a609c5043d6d

📥 Commits

Reviewing files that changed from the base of the PR and between dd616ac and 0c516cb.

📒 Files selected for processing (3)
  • apps/geolibre-desktop/src/components/panels/BrowserPanel.tsx
  • packages/core/src/layer-library.ts
  • tests/layer-library.test.ts

Comment thread apps/geolibre-desktop/src/components/panels/BrowserPanel.tsx Outdated
Comment thread packages/core/src/layer-library.ts
Comment thread packages/core/src/layer-library.ts Outdated
Comment thread apps/geolibre-desktop/src/lib/restore-library-layer.ts
@github-actions

Copy link
Copy Markdown
Contributor

Code review

Bugs

  • None found with high confidence. The capture/plan/normalize pipeline in layer-library.ts (source-vs-embed decision, size-cap degradation to needsLocalFile, transient resolvedUrl stripping, path re-validation on both capture and import) is internally consistent and well covered by the new tests, and I traced the control-painted restore paths (vector/raster/planetary-computer/3D-tiles/LiDAR) end-to-end without finding a mismatch.

Security

  • Low-medium confidence: EXPORT_REDACTED_SOURCE_KEYS (packages/core/src/layer-library.ts) only strips source.requestHeaders before Export — a credential embedded elsewhere (e.g. an API key/token as a URL query parameter in source.url/source.tiles) would travel unredacted into a bundle explicitly pitched as "share with your team." Flagged inline for verification against the currently-supported service types.

Performance

  • No issues found. The IndexedDB writes are a wholesale clear+put per library change, which is a deliberate, documented tradeoff given the library's expected size, and structuredClone/JSON-size checks in captureLayerLibraryEntry only run on a user-triggered save, not a hot path.

Quality

  • Medium confidence: RESTORE_BY_SOURCE_KIND in restore-library-layer.ts hardcodes three plugin sourceKind string literals ("planetary-computer-raster", "3d-tiles-url", "lidar-url") instead of importing shared constants like the other two entries (VECTOR_SOURCE_KIND, RASTER_SOURCE_KIND), and no test pins the mapping — a future rename in those plugins would silently disable "Save to My Data" for that layer kind. Flagged inline with a suggestion to export constants or add a drift test, matching this repo's existing convention for this class of duplication.
  • Minor/low confidence (not filed inline): handleSaveToLibrary in LayerPanel.tsx has no re-entrancy guard, unlike the library "add" path in BrowserPanel.tsx — a rapid double-invocation of "Save to My Data" on the same layer would create two separate library entries rather than being coalesced. Low-impact UX nit.

CLAUDE.md

  • No violations found. New user-facing strings go through t() and were added to en.json (the source of truth); new UI in BrowserTreeNode.tsx uses logical Tailwind utilities (me-, ms-, paddingInlineStart) rather than physical left/right ones, consistent with the RTL guidance.

- Validate `metadata.localFilePath` during normalization. Teaching
  `layerLocalPath` to read that field (previous commit) gave an entry a second
  path, and normalization was copying it verbatim — so a shared bundle could
  keep a clean-looking `sourcePath` while pointing `restoreRasterLayers` at any
  file on the importing user's disk. It now gets the same absolute-path,
  no-traversal check as `sourcePath`, and is dropped when it fails.
- Count that path in the "anything to re-add from" gate. A local raster's
  source has no URL and it has no features, so without this the entry the
  previous commit made saveable would have been discarded on the next load.
- Require a unique path match before reapplying a `local-file` entry's saved
  configuration, and say so inline when there isn't one. One file can produce
  siblings that share a path (a KML's placemarks plus its ground overlays), and
  patching whichever appeared first would style the wrong one; leaving the user
  to notice their styling silently did not return was the other half of the
  problem.
Comment thread packages/core/src/layer-library.ts Outdated
- Redact credentials embedded in source URLs on export, not just
  `source.requestHeaders`. Plenty of tile and web services authenticate in the
  URL — an XYZ template with `?api_key=`, a Google `key=`, a Mapbox
  `access_token=`, an S3 presigned link, an Azure SAS token — so the previous
  redaction still shipped a key in a shared bundle. `source.url`,
  `source.tiles`, and `metadata.originalUrl` now have known credential
  parameters stripped, everything else preserved (including the `{z}/{x}/{y}`
  placeholders, which is why the query is split by hand rather than through
  URLSearchParams). The in-memory entry keeps its credential so re-adding
  locally still works.
- Key the restore dispatch off constants the plugins export instead of
  hand-typed literals. `PLANETARY_COMPUTER_SOURCE_KIND`,
  `THREE_D_TILES_SOURCE_KIND`, and `LIDAR_SOURCE_KIND` are now defined next to
  the `sourceKind` each plugin writes and used at both the write site and its
  own read guard, so a rename cannot silently stop the table matching — the
  failure mode this repo's "mirror" convention exists to prevent, and a quiet
  one here ("Save to My Data" just stops appearing, or an entry re-adds
  unrendered).
Comment thread packages/core/src/layer-library.ts Outdated
Comment thread apps/geolibre-desktop/src/components/panels/BrowserTreeNode.tsx
Comment thread apps/geolibre-desktop/src/components/panels/BrowserPanel.tsx Outdated
Comment thread apps/geolibre-desktop/src/components/panels/LayerPanel.tsx
@github-actions

Copy link
Copy Markdown
Contributor

All 8 inline comments posted successfully. Now the final summary.

Code review

Bugs

  • packages/core/src/layer-library.ts:273captureLayerLibraryEntry clones a layer's source verbatim without rewriting a resolved XYZ template back to metadata.originalUrl, unlike prepareLayerForSave's project-save path; a saved entry can bake in a stale, session-specific resolved endpoint. Medium confidence.
  • packages/core/src/layer-library.ts:483normalizeLayerLibraryEntries (the bundle-import path) never enforces MAX_LAYER_LIBRARY_ENTRY_BYTES, so importing a hand-crafted or large bundle can add arbitrarily large entries into IndexedDB, completely bypassing the 5MB cap the "Save to My Data" UI path enforces. Medium-high confidence.
  • packages/core/src/layer-library.ts:189-197approximateJsonBytes measures JSON.stringify(...).length (UTF-16 code units), undercounting non-ASCII-heavy content by up to ~3x against the byte-based 5MB cap; included a small TextEncoder-based fix. Medium confidence.
  • apps/geolibre-desktop/src/lib/restore-library-layer.ts:99await restore(app) doesn't actually wait for 4 of 5 restore passes (vector/raster/Planetary Computer/3D Tiles are internally fire-and-forget and typed void, not Promise<void>), so the function resolves well before the layer is actually drawn, undercutting the busy-guard's stated purpose and its own doc comment's claim. Medium-high confidence.
  • apps/geolibre-desktop/src/components/panels/BrowserPanel.tsx:509 — if onAddFilePath succeeds but yields zero newly-added layers, the re-add silently no-ops with no user feedback. Low confidence.

Security

  • packages/core/src/layer-library.ts:641redactUrlCredentials only strips credential-bearing query parameters; a credential in a URL fragment or userinfo segment survives into an exported bundle unredacted. Likely unreachable via current Add Data flows but a latent gap for hand-edited sources. Low confidence.

Quality

  • apps/geolibre-desktop/src/components/panels/BrowserPanel.tsx:509 — inner entry closures shadow the outer LayerLibraryEntry entry from line 469; harmless today but a maintenance trap. High confidence, low severity.
  • apps/geolibre-desktop/src/components/panels/LayerPanel.tsx:1048handleSaveToLibrary lacks the re-entrancy guard its sibling handleRefreshLayer uses, so rapid re-invocation before its await resolves could create duplicate library entries; low impact since the menu closes on select. Low confidence.

Accessibility

  • apps/geolibre-desktop/src/components/panels/BrowserTreeNode.tsx:240 — no focus is restored to the row/pencil button after an inline rename commits or is cancelled, dropping keyboard-only users' focus to <body>. Medium confidence.

CLAUDE.md adherence
No violations found — new UI strings go through t()/en.json, RTL logical utilities (paddingInlineStart, ms-/me-) are used correctly, and the credential-redaction convention from the prior commit is followed for export (with the fragment/userinfo gap noted above).

Areas checked with no issues: the LAYER_TYPES/LayerType union can't drift by construction; the layer library is correctly excluded from undo history and project load/save; prototype-pollution and path-traversal protections on imported bundles look sound; IndexedDB persistence timing/ordering mirrors the pre-existing Style Library pattern correctly; the three plugin-constant refactors and layer-sync.ts change are behavior-preserving. Two test-coverage gaps worth noting (not filed inline): the bundle-import id-collision upsert logic in BrowserPanel.tsx's importLibrary has no test, and the 5MB size-cap tests never probe the exact boundary (all use payloads well past it), so an off-by-one in the <= comparisons wouldn't be caught.

Core (`layer-library.ts`):
- Rewind a resolved XYZ endpoint to `metadata.originalUrl` on capture, the way
  `prepareLayerForSave` does for project files. Stripping `metadata.resolvedUrl`
  alone left the session-specific endpoint sitting in `source.tiles`/`source.url`,
  so an entry would replay a stale host forever instead of re-resolving.
- Enforce the per-entry size cap and a new `MAX_LAYER_LIBRARY_ENTRIES` count cap
  during normalization, not only on capture. A bundle is produced elsewhere, so
  import could otherwise push unbounded embedded features (and unbounded entries)
  into IndexedDB — the outcome the cap exists to prevent.
- Measure that cap in UTF-8 bytes via TextEncoder rather than
  `JSON.stringify().length`. CJK/Arabic/Cyrillic text is one UTF-16 unit but 2-3
  bytes per character, so the length check passed entries at up to 3x the cap.
- Redact credentials in a URL fragment (`#access_token=…`, the same `name=value`
  shape) and `user:pass@` userinfo, not only query parameters. A plain `#anchor`
  fragment is preserved.

App:
- Correct `restoreLibraryLayer`'s contract: four of the five restore passes are
  `(app) => void` that fire their real work into a floating async IIFE, so
  awaiting them spans only the dispatch. The doc comment claimed "dispatched and
  settled" and the busy-flag comment implied full coverage; both now say what
  actually happens.
- Restore focus to the tree row after a rename commits or cancels. The focused
  input unmounts, which dropped a keyboard user's focus to <body> and lost their
  place in the tree.
- Guard `handleSaveToLibrary` against re-entrancy, so a repeat click during the
  vector-control materialize cannot write two entries under two generated ids.
- Report when a `local-file` re-add adds no layers at all, which previously fell
  between both branches and no-oped silently. Stop shadowing the outer `entry`
  in the layer-diff closures.
Comment thread packages/core/src/layer-library.ts
Comment thread packages/core/src/layer-library.ts Outdated
Comment thread packages/core/src/store.ts
@github-actions

Copy link
Copy Markdown
Contributor

Code review

Bugs

  • Saved joins reference project-local layer ids (joinLayerId), which won't resolve after a re-add into a different project — the join silently goes inert with no user-facing message, unlike this PR's other failure paths. Medium confidence. (packages/core/src/layer-library.ts:314)
  • captureLayerLibraryEntry's size-cap fallback returns the same "too-large" reason whether the overage comes from embedded features or from a large style/attributeForm/joins payload on a layer that does have a re-fetchable source — the UI then shows a misleading "features are the only copy" message. Low-to-medium confidence. (packages/core/src/layer-library.ts:337)

Security

  • None found with confidence. The export redaction (URL userinfo, credential query params, requestHeaders) looks reasonably thorough and matches how credentials are actually stored in this codebase; the imported-bundle path revalidates sourcePath/localFilePath against traversal and absoluteness before trusting them, which is the right call for untrusted input.

Performance

  • Nothing notable; entries store source specs rather than data, and the write-queue/size-cap approach in layer-library-store.ts is reasonable for the expected scale.

Quality

  • MAX_LAYER_LIBRARY_ENTRIES is only enforced inside normalizeLayerLibraryEntries (load/import), not in saveLayerLibraryEntry (packages/core/src/store.ts:1179) or in BrowserPanel.tsx's import-merge — so the library can grow past the documented cap through ordinary use, unlike the codebase's existing MAX_FAVORITES pattern which caps on every write. Medium confidence.

CLAUDE.md

  • No violations found. New user-facing strings go through t() and are added to en.json; no MapLibre state is mutated outside the store; no new external tile/map hosts are introduced.

- Tell the user when a re-added layer's saved joins cannot resolve. A
  `LayerJoin` points at another layer by its **project-local id**, so a join
  travels only as far as the project it was captured in — and `applyLayerJoins`
  drops an unresolved one quietly, which is right at runtime but silent for a
  feature whose whole point is re-adding elsewhere. New
  `unresolvedLayerLibraryJoins` is checked before the add (so the layer's own
  fresh id cannot count as a match) and the panel reports the count.

  Kept capturing joins rather than dropping them: they do resolve when the join
  source is present — re-adding into the project the entry came from, or into one
  restored from the same saved project — and the issue asks for joins explicitly.
  The failure is now explained instead of invisible.
- Split the over-cap failure in two. `too-large` was reported whether or not any
  features were embedded, so a layer with a perfectly good source that simply has
  a huge rule-based style was told "this layer's features are its only copy and
  are too large" — pointing at the wrong thing entirely. Now
  `features-too-large` and `config-too-large`, with their own messages.
- Enforce `MAX_LAYER_LIBRARY_ENTRIES` on every store write, not only when
  normalizing untrusted input. Repeated saves or importing several bundles over
  time could otherwise grow the library past the cap its own doc comment
  promises. Mirrors `writeBrowserFavorites`, which slices to `MAX_FAVORITES` on
  each write; a new entry goes to the front, so at the cap the oldest falls off.
Comment thread apps/geolibre-desktop/src/components/panels/BrowserPanel.tsx Outdated
Comment thread packages/core/src/layer-library.ts
@github-actions

Copy link
Copy Markdown
Contributor

Code review

Bugs

  • apps/geolibre-desktop/src/components/panels/BrowserPanel.tsx:705-711importLibrary appends newly-imported entries to the end of the library (next.push(entry)), so a freshly imported bundle's layers land at the bottom of My Data instead of leading it, contradicting the "most recently saved first" convention saveLayerLibraryEntry establishes elsewhere in the same PR. Medium confidence; posted with a suggested fix.

Security

  • packages/core/src/layer-library.ts:682-815 — Export redaction (redactSourceValue) only scrubs top-level string/array-of-string values under source; a nested object (a layer type that stores its URL/credentials one level deeper) would ship unredacted in an exported bundle. Also, EXPORT_REDACTED_URL_PARAMS omits a few common credential-like param names (password, client_secret, pwd). Low-medium confidence — no concrete current layer type was found with this shape, but it's the same drift risk the code already calls out for the flat-field case.

Performance

  • None found.

Quality

  • The Layer Library entry cap (MAX_LAYER_LIBRARY_ENTRIES = 500) silently drops the oldest entry on write with no user-facing notice (mirrors the existing MAX_FAVORITES pattern, so likely intentional/acceptable, but worth noting). Not posted inline — minor and consistent with existing precedent.

CLAUDE.md

  • No violations found: new UI strings go through t()/en.json, RTL logical Tailwind utilities (ms-/me-/paddingInlineStart) are used correctly for the new tree rows (and even fix a pre-existing physical-padding RTL gap), and no changed files touch any of the drift-prone mirrored constants called out in CLAUDE.md.

Overall this is an unusually well-tested and carefully-commented PR (31+ new tests, explicit handling of untrusted/imported input, path-traversal and credential-redaction awareness). The two items above are the only things I'd want the author to look at before merge; everything else held up under review.

- Put newly imported entries at the front of My Data. Import appended, which
  contradicted the most-recently-first ordering `saveLayerLibraryEntry`
  establishes and buried freshly shared layers under the whole existing library.
  An id already present stays an in-place update rather than a reorder.
- Sweep nested source objects on export, not only top-level strings and arrays.
  A layer type that groups its endpoint and auth as `{ store: { url, headers } }`
  could otherwise ship a token one level deeper — the same "a per-field list has
  to be extended for each new one" risk this file already calls out, which is the
  argument for recursing rather than listing. Nested `requestHeaders`-style keys
  are dropped like the top level's.

  GeoJSON payloads are deliberately excluded from the sweep: `source.data` can
  hold an inline FeatureCollection, and descending into it would rewrite any
  feature property that looks like a URL with a `key=` parameter — corrupting the
  user's data in the name of protecting it. Recursion is depth-capped.
- Add `password`, `pwd`, `client_secret`, and `clientsecret` to the redacted
  query parameters.
Comment thread apps/geolibre-desktop/src/lib/restore-library-layer.ts Outdated
Comment thread apps/geolibre-desktop/src/components/panels/BrowserPanel.tsx
Comment thread packages/core/src/layer-library.ts Outdated
Comment on lines +272 to +293
if (input.libraryLayers) {
sections.push({
id: "section:my-data",
kind: "section",
label: labels.myData ?? "My Data",
addable: false,
libraryImportExport: true,
count: input.libraryLayers.length,
children: input.libraryLayers.map(
(entry): BrowserNode => ({
id: `library-layer:${entry.id}`,
kind: "library-layer",
label: entry.name,
addable: true,
libraryLayerId: entry.id,
renamable: true,
deletable: true,
...(entry.needsLocalFile ? { needsLocalFile: true } : {}),
}),
),
});
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor UX nit (low confidence): filterBrowserTree (below, pre-existing) prunes a whole section when neither its own label nor any child matches the search query — which will also hide this section's Import (⬆) action whenever a search matches none of the saved layer names. Unlike Databases/Files, whose "always-on" + actions share the same pre-existing gap, My Data's Import is arguably more likely to be wanted while searching (e.g. importing a shared bundle to find something not yet in the library). Not new logic, but this PR is what gives that gap a persistently-useful action to hide.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changing this one, with reasoning rather than a dismissal. The pruning is filterBrowserTree pre-existing behavior and it is shared by every section, including the Databases and Files + actions you note have the same gap. Making My Data an exception would leave the tree inconsistent; fixing it properly means changing how filterBrowserTree treats sections with always-on actions, which affects three sections that are not part of this feature and has its own UX question (should an empty-matching section render as a bare header while searching?).

I agree this PR is what makes the gap worth noticing, so it is a fair thing to raise — it just looks like a separate change to me rather than something to fold in at the end of this one. Happy to open a follow-up issue for it if that is useful; leaving this thread open for a maintainer to weigh in.

@github-actions

Copy link
Copy Markdown
Contributor

All four inline comments posted successfully. Here's the final summary.

Code review

Bugs

  • canRestoreLibraryLayer's save/restore gate (apps/geolibre-desktop/src/lib/restore-library-layer.ts:79) is effectively a no-op for Planetary Computer layers: controlRendersLayer keys off metadata.customLayerType, which the PC plugin never sets, so the function returns true without ever checking whether a restore pass actually exists. It works today only because PLANETARY_COMPUTER_SOURCE_KIND happens to be registered in RESTORE_BY_SOURCE_KIND; a future edit to that map could silently start offering "Save to My Data" for PC layers that then re-add blank. A naive fix (adding customLayerType) would itself break PC's opacity/visibility sync, since it has no paint bridge. Medium confidence.
  • Focus-management gaps in the new My Data controls (BrowserPanel.tsx:672-688): deleting an entry drops keyboard focus to <body> (unlike rename, which explicitly restores it), and a rapid rename→rename click sequence can leave visible focus on the wrong row due to an requestAnimationFrame race between the old row's blur-commit and the new row's autoFocus. Medium/low confidence.

Security

  • No issues found. Path validation (isReReadablePath = absolute + no .. traversal) is consistently applied at capture, at untrusted-bundle normalization, and again before any Tauri filesystem read; the exported bundle redacts header/query-string/userinfo credentials from source URLs. An imported bundle's sourcePath can still point anywhere on disk reachable by the OS (not sandboxed to a project directory), but this mirrors the existing, accepted trust model for .geolibre.json project files rather than introducing a new one. Low confidence / informational only, not filed as a separate comment.

Performance

  • No meaningful issues found. IndexedDB writes are queued/serialized correctly, the entry-count and per-entry byte caps are enforced on every write path (not just untrusted import), and the persistence hook's extra unconditional write on load (when nothing actually changed) is negligible given source-spec-only records. Low confidence.

Quality

  • unresolvedLayerLibraryJoins's docstring says it returns join "names" but it returns the unresolved join's target-layer id (packages/core/src/layer-library.ts:452); harmless today since the only caller checks .length, but misleading for future use. Low confidence — comment includes a one-line doc fix.
  • My Data's Import (⬆) action disappears during a search that matches no saved layer names, since filterBrowserTree prunes the whole section (browser-tree.ts:272-293) — inherited from the pre-existing Databases/Files pattern but newly relevant here. Low confidence.
  • Secondary rename/delete icon buttons aren't disabled while a row's add is in flight, unlike the primary control — functionally harmless, not filed as a separate comment. Low confidence.

CLAUDE.md

  • No violations found: all new UI strings route through t()/en.json, new layout classes use logical Tailwind utilities (ms-/me-/ps-/text-start), and the plugin SOURCE_KIND constant extractions correctly avoid touching usePlugins.ts (no new plugin registered, only existing ones exporting a constant).

Overall the core data-layer module (layer-library.ts), store wiring, IndexedDB persistence, and path-validation logic are careful, thoroughly tested, and hold up well under scrutiny — the issues found are mostly minor UX polish, with one legitimate correctness/robustness gap in the Planetary Computer restore gate worth a deliberate follow-up rather than a quick patch.

- Stop `canRestoreLibraryLayer` depending on a coincidence for Planetary
  Computer layers. Those set `externalNativeLayer`/`sourceKind` but never
  `customLayerType`, so `controlRendersLayer` returns false and the gate passed
  them through the "the map sync rebuilds it" branch without consulting the
  restore table — correct today only because the kind happens to be in that
  table. Listed explicitly as control-created, so the gate now depends on the
  restore entry and removing one stops offering the kind rather than silently
  saving entries that re-add unrendered.
- Restore focus after deleting a My Data entry, to the section header (the
  nearest surviving row). The trash button held focus and its removal dropped
  focus to <body> — the same gap `endRename` already handles for rename.
- Stop `endRename`'s deferred focus restore from stealing focus from a second
  rename editor. Clicking another row's pencil commits the first by blur, and
  that row's `autoFocus` input has already mounted by the time the frame runs, so
  the restore is now skipped when an editor is open (checked through a ref, since
  state is stale inside the deferred callback).
- Correct `unresolvedLayerLibraryJoins`'s docstring: it returns join-target layer
  ids, not names — `LayerJoin` has no display name.
Comment on lines +771 to +804
function redactEntryForExport(entry: LayerLibraryEntry): LayerLibraryEntry {
const source = { ...entry.source };
let changed = false;
for (const key of EXPORT_REDACTED_SOURCE_KEYS) {
if (key in source) {
delete source[key];
changed = true;
}
}
// Sweep every URL-shaped value in `source` rather than naming the fields that
// hold one. Different layer types put their URL in different places — `url`,
// `data` (a GeoJSON source pointing at a URL, which
// `hasRestorableLayerSource` already treats as re-fetchable), `tiles[]` — and
// a per-field list has to be extended for each new one, with a silent
// credential leak as the cost of forgetting.
for (const [key, value] of Object.entries(source)) {
const redacted = redactSourceValue(value);
if (redacted !== value) {
source[key] = redacted;
changed = true;
}
}
// `metadata.originalUrl` is the template a tile layer restores from, so it
// carries the same credential as `source.tiles`.
let metadata = entry.metadata;
if (typeof metadata.originalUrl === "string") {
const redacted = redactUrlCredentials(metadata.originalUrl);
if (redacted !== metadata.originalUrl) {
metadata = { ...metadata, originalUrl: redacted };
changed = true;
}
}
return changed ? { ...entry, source, metadata } : entry;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security/privacy: local filesystem paths aren't redacted on export.

redactEntryForExport carefully strips credentials from source (headers, URL query params, userinfo, fragment tokens) and from metadata.originalUrl, but it never touches entry.sourcePath or entry.metadata.localFilePath. Both are captured verbatim for any layer read from a local file (see layerLocalPath/captureLayerLibraryEntry around line 319), including layers whose features are embedded under the cap (not just needsLocalFile degraded entries).

Since Export is pitched in the PR description as a one-click "share with your team" action, exported bundles will carry the exporting user's absolute local path — which on most OSes embeds the OS username (e.g. /Users/alice/Documents/..., C:\Users\alice\...) — with no redaction, even though the PR goes to considerable lengths to avoid leaking credentials the same way.

This may be intentional for the needsLocalFile case (recipients on a shared drive at the same path), but it applies indiscriminately to every locally-sourced entry, including ones where the path is unnecessary because the data is already embedded. Worth confirming this is deliberate, or stripping/warning about the path on export similar to the credential handling above.

Confidence: medium.

Comment on lines +547 to +555
if (target) {
const { joins, virtualFields, ...rest } = plan.config;
useAppStore.getState().updateLayer(target.id, rest);
// Joins and virtual fields must go through their own actions so the
// derived columns are materialized rather than stored inert.
if (joins) useAppStore.getState().setLayerJoins(target.id, joins);
if (virtualFields) {
useAppStore.getState().setLayerVirtualFields(target.id, virtualFields);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: unresolved joins aren't reported on the local-file re-add path.

For the "layer" plan (line 500-508 above), unresolvedLayerLibraryJoins is checked before the add and browser.libraryLayerJoinsUnresolved is shown if any join's target layer isn't present in this project. This "local-file" branch applies joins via setLayerJoins (line 552) without ever calling unresolvedLayerLibraryJoins or surfacing that error — so re-adding a degraded (oversized) local-file entry with joins to a layer missing from the target project silently drops those joins (per the join engine's documented silent-drop behavior) with no equivalent warning to the user, unlike the parallel code path just above it.

Confidence: medium.

// features at all (a rule-based style with hundreds of rules, a large
// attribute form), and telling that user their "features are the only copy"
// would send them looking in the wrong place.
return { ok: false, reason: embed ? "features-too-large" : "config-too-large" };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: failure reason can be mislabeled after the path-only fallback also exceeds the cap.

If embed && localPath is true but the path-only entry built at line 333-338 is still over MAX_LAYER_LIBRARY_ENTRY_BYTES after geojson/embeddedGeoJSON are stripped (e.g. a huge style with a modestly-sized local file), execution falls through to this line, which reports "features-too-large" purely because embed was truthy earlier — even though at this point the entry no longer embeds any features and the actual overage is coming from the style/joins/attribute-form bulk. The user-facing message (saveToLibraryTooLarge) would then point them at the wrong cause. Consider re-deriving the reason from whether pathOnly (minus config) is itself over cap, or falling back to "config-too-large" when the non-feature fields alone exceed the limit.

Confidence: low (narrow edge case: requires both a large embedded/local-file feature set and an oversized style/joins/attribute-form).

@github-actions

Copy link
Copy Markdown
Contributor

All three inline comments posted. Final summary below.

Code review

Bugs

  • apps/geolibre-desktop/src/components/panels/BrowserPanel.tsx:547-555 — The "local-file" re-add path (used for degraded, oversized entries) applies saved joins via setLayerJoins without ever checking unresolvedLayerLibraryJoins, unlike the parallel "layer" plan path just above it (lines 500-508) which warns the user when a join's target layer isn't present. Joins to a missing layer are silently dropped with no feedback on this path. Confidence: medium.
  • packages/core/src/layer-library.ts:347 — When the path-only fallback for an oversized local-file entry (lines 329-341) is itself still over the size cap, the returned failure reason defaults to "features-too-large" purely because embed was truthy earlier, even though the entry no longer carries embedded features at that point — mislabeling entries whose real overage is a huge style/joins/attribute-form. Confidence: low (narrow edge case).

Security

  • packages/core/src/layer-library.ts:771-804redactEntryForExport thoroughly redacts credentials from source (headers, URL query params, userinfo, fragment tokens) and metadata.originalUrl, but never touches entry.sourcePath / entry.metadata.localFilePath. Exported "share with your team" bundles will carry the exporting user's absolute local file path (often embedding the OS username) unredacted, for every locally-sourced entry — not just the needsLocalFile ones where the path is functionally needed. Worth confirming this is intentional. Confidence: medium.

Performance

  • No issues found. The IndexedDB write-queue serialization, the 5 MB per-entry cap, the 500-entry library cap, and the wholesale clear+put persistence strategy are all reasonable for the expected data volumes.

Quality

  • No significant issues found. The new module (layer-library.ts) is unusually well-documented, capture/normalize/export logic is symmetric and covered by extensive tests, and the LAYER_TYPES/controlRendersLayer/*_SOURCE_KIND refactors correctly centralize definitions that were previously duplicated or hand-typed, reducing drift risk.

CLAUDE.md

  • No violations found. New user-facing strings go through t() and are added to en.json; RTL-safe logical Tailwind utilities (paddingInlineStart, ms-/me-) are used in the touched components; no direct MapLibre mutation from UI code was introduced.

@giswqs
giswqs merged commit 7fad14a into main Jul 31, 2026
32 checks passed
@giswqs
giswqs deleted the feat/issue-1520-layer-library branch July 31, 2026 01:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Layer Library: save a configured layer and re-add it to any project

2 participants