Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .github/workflows/build-windows.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: build-windows

# On-demand Windows installer build. Unlike release.yml (which fires on a v*
# tag and publishes a public GitHub Release), this builds the same NSIS + portable
# installers and uploads them as a private workflow ARTIFACT — downloadable by
# repo collaborators, auto-expiring, nothing published outward.
#
# Trigger it any of these ways:
# - Actions tab -> "build-windows" -> Run workflow (workflow_dispatch, once on the default branch)
# - push a lightweight tag matching winbuild-* (e.g. winbuild-1)
# - push to the claude/shopdeck-file-selection-bug branch (builds this WIP branch)

on:
workflow_dispatch:
push:
branches: ['claude/shopdeck-file-selection-bug-j00q7y']
tags: ['winbuild-*']

jobs:
build:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm test # same green-tests gate as release.yml
- run: npm run build:win # electron-vite build + electron-builder (NSIS + portable)
- name: Upload installer artifact
uses: actions/upload-artifact@v4
with:
name: shopdeck-windows-installer
if-no-files-found: error
retention-days: 30
path: |
dist/*.exe
dist/*.blockmap
dist/latest.yml
11 changes: 8 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,14 @@ plain files so a shared/network deployment is a later config change.
packages. Storage is plain files + a JSON index; the whole stack is pure JS.
- **Modules stay untouched.** ShopDeck displays a module's HTML byte-for-byte in
its own window. Astryx/React dress only the wrapper chrome, never the module.
- **The manifest is the contract.** ShopDeck reads only the embedded
`module-manifest` block. See `MODULE-SPEC.md` in the sibling
`Part timeline plus timeline` project for the authoritative field reference.
- **The manifest is the contract — but it's optional.** ShopDeck prefers the
embedded `module-manifest` block; when a self-contained HTML has none (e.g. a
timeline exported by the standalone tool), `scanLibrary` **infers** one from the
file — its `<title>`, filename, and, for tool-swap timelines, the embedded
`const DATA` object (part, events, date range, operation tags). Inferred modules
are flagged `inferred: true` (the card shows "· no manifest"). Files with a real
manifest stay authoritative. See `MODULE-SPEC.md` in the sibling
`Part timeline plus timeline` project for the field reference.
- Git identity is **AxialForge** (global). Check `git config --local --get-regexp '^user\.'`
before the first commit — a stale local override has mis-attributed commits before.

Expand Down
212 changes: 212 additions & 0 deletions FIXES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
# ShopDeck — Fixes & Changes

A record of the bugs found and fixed (and the features added) while getting the
library working on a real Windows + OneDrive setup. All of this lives on the
`claude/shopdeck-file-selection-bug-j00q7y` branch (PR #1).

The reported symptom was: **"the app won't let me see or correctly select files,"**
starting with a fatal error dialog on launch. Chasing that down surfaced a chain
of related issues — three distinct bugs and two feature gaps — all documented below.

---

## 1. Launch crash — `UNKNOWN: unknown error, watch`

**Symptom.** On launch the app died immediately with Electron's fatal dialog:

```
A JavaScript error occurred in the main process
Uncaught Exception:
Error: UNKNOWN: unknown error, watch
at FSWatcher._handle.onchange (node:internal/fs/watchers:267:21)
```

The main process crashed, so the window never rendered — hence "can't see or
select files."

**Root cause.** The library auto-refresh watcher created a **recursive**
`fs.watch` over the library root:

```js
watcher = watch(root, { recursive: true }, handler) // wrapped in try/catch
```

The surrounding `try/catch` only guards the **synchronous** setup call. On
Windows, a recursive watch over an **OneDrive-synced** folder (the default root
was `Documents/ShopDeck Library`, and Documents is commonly OneDrive-synced) or
a network share fires an **asynchronous** `error` event — `UNKNOWN: unknown
error, watch` — from the `FSWatcher` handle, long after setup. With no `'error'`
listener attached, Node's `EventEmitter` re-throws it as an uncaught exception
and the whole main process dies.

**Fix.**
- Attach an `'error'` listener to the watcher that closes it and degrades to
manual refresh instead of crashing.
- Add main-process `uncaughtException` / `unhandledRejection` guards so no stray
async failure can ever show that fatal dialog again.

**Files.** `electron/main.js`
**Commit.** `44a3ec6`

---

## 2. Unreachable library bricked the UI (sibling of the crash)

**Symptom.** If the configured library folder was an offline network share or a
deleted folder, the app hung forever on **"Loading…"** with nothing shown and no
error.

**Root cause.** Every IPC handler funnels through `libraryRoot()`, which did
`await fs.mkdir(root, …)`. That throws when the root is unreachable, so
`library:scan` rejected — and the renderer's `reload()` had **no `try/catch`**,
so the rejection was swallowed, `setLoading(false)` never ran, and the view was
stuck. Same failure family as the watch crash: a network-share subsystem failure
that should degrade gracefully but instead broke the app.

**Fix.**
- `libraryRoot()` never throws now (best-effort `mkdir`, ignore failure).
- `library:scan` degrades to a soft error object (`{ root, folders: [],
modules: [], error }`) instead of rejecting.
- The renderer wraps its reload in `try/catch/finally` and shows a dismissable
**error banner** with **Retry** and **Manage libraries** instead of an empty
window.

**Files.** `electron/main.js`, `src/renderer/src/App.jsx`, `src/renderer/src/app.css`
**Commit.** `d730fcc`

---

## 3. Library showed the folder but **zero modules** (OneDrive on-demand files)

**Symptom.** After the crash was fixed, the library listed the folder in the nav
tree but showed **0 modules** inside it — even though the `.html` files were
clearly there in Explorer. It reproduced on a plain `C:` drive too.

**Root cause.** OneDrive **"Files On-Demand"** placeholder files are reparse
points, and `fs.readdir(dir, { withFileTypes: true })` reports them as
**symlinks, not files** — `dirent.isFile()` returns `false`. The scan's `walk()`
required `entry.isFile()`, so it **skipped every module**, while directories
(which keep their folder attribute) were still detected. That's the exact
fingerprint: folder visible, files invisible. Moving the files to another folder
on the **same volume** keeps them as placeholders, so it reproduced off OneDrive
as well.

**Fix.**
- New `entryKind()` helper: trust the dirent when it's decisive, otherwise
**`stat`** to resolve the real type (`stat` reads metadata only — it does *not*
download an online-only file). `walk()` and `walkHtmlUnder()` use it, so
placeholder/symlinked `.html` files are discovered. A realpath-keyed `seen`
set guards against symlink/junction cycles now that reparse entries are
followed.
- `scanLibrary` also returns `htmlCount` and a `skipped` list (file + reason),
and the empty-state message uses them to **explain itself** ("Found N HTML
files here, but … First: <file> — <reason>.") instead of a bare "Library is
empty" — so any remaining cause is visible without another debugging round.

**Files.** `electron/library.js`, `src/renderer/src/App.jsx`
**Commit.** `0aa5aee`

---

## 4. Standalone-tool timelines had **no manifest** (files loaded but were rejected)

**Symptom.** With the scan now finding the files, the app reported: *"Found 9
HTML files here, but none are valid modules — each needs a `module-manifest`
block. First: … — no module-manifest block found."*

**Root cause.** The timeline files were exported by the **standalone timeline
tool**, not by ShopDeck's own generator. They carry a `<title>` and an embedded
`const DATA = {…}` object, but **no `module-manifest`** block. ShopDeck was built
to catalog only files that carry that manifest, so it correctly found the files
but rejected all of them. The requirement was to support **both** generated
(manifest-carrying) and standalone (manifest-less) files, since multiple users
would be dropping in files.

**Fix.** ShopDeck now **infers a manifest** when a file has none (or an invalid
one):
- **Any HTML** gets an id from its filename and a title from its `<title>`.
- **Tool-swap timelines** are recognized from the embedded `DATA` and enriched
with the part number, event (swap) count, date range, and operation tags — so
their cards look just like generated ones. (Verified against the real exported
file: part `40-1318-01`, 147 events, 2011→2026, tags
preform/blocker/finish/pierce-strip/trim.)
- Inferred modules are flagged `inferred: true` and the card shows a small
**"· no manifest"** note. Files with a real manifest stay authoritative.
- `importFiles` / `importTree` no longer reject manifest-less HTML either.

**Files.** `electron/library.js`, `src/renderer/src/App.jsx`, `CLAUDE.md`
**Commit.** `627ec6a`

---

## Feature A — Multiple libraries + rename

Added support for keeping **several named libraries** (local folders or network
shares) and switching between them.

- **New pure-Node model `electron/libraries.js`.** A library is a named pointer
`{ id, name, root }`. **Rename** edits the app-side label only — the folder on
disk is never renamed (consistent with how ShopDeck stores title/tag overrides
and never modifies your files). **Remove** just unlinks a library; the files
stay on disk. The last library can't be removed. Legacy single-root installs
migrate automatically on first run.
- `electron/main.js` keeps a normalized `{ libraries, activeLibraryId }` view;
the existing `libraryRoot()` now resolves the **active** library, so every
downstream handler keeps working unchanged. New IPC:
`libraries:list/add/rename/remove/switch/reveal`.
- `electron/preload.js` exposes `window.shopdeck.libraries.*`.
- Renderer: an **active-library switcher** in the top bar, and a full
**Libraries** manager in Settings (use / rename / reveal / remove / add).

**Files.** `electron/libraries.js`, `electron/main.js`, `electron/preload.js`,
`src/renderer/src/App.jsx`, `src/renderer/src/app.css`, `test/libraries.test.mjs`
**Commit.** `d730fcc`

---

## Feature B — On-demand Windows installer build (CI)

Because the installer can only be built on Windows, added a CI workflow that
builds the NSIS + portable installers on a `windows-latest` runner and uploads
them as a **private, auto-expiring artifact** (no public release). This is what
produced each test build during debugging.

- Triggers: `workflow_dispatch`, a `winbuild-*` tag, or a push to the feature
branch.
- The existing `release.yml` (public GitHub Release on a `v*` tag) is untouched
and remains the path for real releases.

**Files.** `.github/workflows/build-windows.yml`
**Commits.** `d8ec34c`, `c967885`

---

## Testing

Electron itself can't be launched in the build/agent environment (package
install scripts are blocked there, per `CLAUDE.md`), so verification relied on
the pure-Node test suite plus real-file checks:

- `test/library.test.mjs` — manifest parsing/validation, scanning, versioning,
overrides, import, **OneDrive/symlink discovery**, and **manifest inference**
(timeline + generic).
- `test/libraries.test.mjs` — the multi-library model (migration, add/rename/
remove/switch, de-dup, "always at least one library").
- Full pure-Node suite: **24 tests passing.** CI (`node --test` on Ubuntu) and
the Windows installer build are both green on the branch.

Each fix was confirmed on the user's real Windows machine via a fresh installer
build, ending with all 9 timelines showing correctly.

---

## Summary

| # | Problem | Cause | Fix | Commit |
|---|---------|-------|-----|--------|
| 1 | Fatal crash on launch | Async `fs.watch` error, no `'error'` listener | Watcher error handler + process guards | `44a3ec6` |
| 2 | Stuck on "Loading…" | `libraryRoot()`/scan threw on unreachable root | Soft-error scan + retry banner | `d730fcc` |
| 3 | Folder shown, 0 modules | OneDrive placeholders read as symlinks; `isFile()` false | `stat`-resolve entries + self-diagnosing empty state | `0aa5aee` |
| 4 | Files found but rejected | Standalone timelines carry no manifest | Infer a manifest from `<title>` + `DATA` | `627ec6a` |
| A | — | Feature: multiple libraries + rename | `electron/libraries.js` + UI | `d730fcc` |
| B | — | Feature: on-demand Windows installer build | `build-windows.yml` | `d8ec34c` |
114 changes: 114 additions & 0 deletions electron/libraries.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// Pure-Node library-list model for ShopDeck. No Electron imports — shared by the
// Electron main process and unit tests (runs under `node --test`).
//
// A "library" is a named pointer to a root folder:
// { id, name, root }
// The NAME is a friendly, app-side label (stored in settings) — renaming a
// library changes that label only; the folder on disk is never touched, exactly
// like the title/tag overrides in library.js. Multiple libraries can coexist;
// `activeLibraryId` selects the one the app is currently showing.
//
// Settings shape:
// { libraries: [{id,name,root}], activeLibraryId, theme, mode, showUpdater }
// The legacy single-root `libraryRoot` field is migrated on first read.

import { randomUUID } from 'node:crypto'

/**
* A sensible default display name from a folder path (its last segment).
* Splits on both `/` and `\` so a Windows path resolves correctly even when
* this runs on a POSIX host (e.g. CI) — node:path.basename is platform-specific.
*/
export function libraryDisplayName(root) {
const clean = String(root || '').replace(/[/\\]+$/, '')
const seg = clean.split(/[/\\]/).pop()
return seg || clean || 'Library'
}

/**
* Ensure settings carry a valid libraries[] + activeLibraryId, migrating from a
* legacy single `libraryRoot` when needed. De-dupes by root and backfills any
* missing id/name. Returns { libraries, activeLibraryId, changed } where
* `changed` is true when the caller should persist the normalized shape.
*/
export function normalizeLibraries(settings = {}, defaultRoot) {
const s = settings || {}
let changed = false

const seen = new Set()
let libraries = (Array.isArray(s.libraries) ? s.libraries : [])
.filter((l) => l && typeof l.root === 'string' && l.root.trim())
.filter((l) => { if (seen.has(l.root)) { changed = true; return false } seen.add(l.root); return true })
.map((l) => {
const id = l.id || randomUUID()
const name = (typeof l.name === 'string' && l.name.trim()) ? l.name : libraryDisplayName(l.root)
if (id !== l.id || name !== l.name) changed = true
return { id, name, root: l.root }
})

// Migrate a legacy single-root install (or seed the built-in default).
if (libraries.length === 0) {
const root = (typeof s.libraryRoot === 'string' && s.libraryRoot) ? s.libraryRoot : defaultRoot
libraries = [{ id: randomUUID(), name: libraryDisplayName(root), root }]
changed = true
}

let activeLibraryId = s.activeLibraryId
if (!libraries.some((l) => l.id === activeLibraryId)) {
const match = libraries.find((l) => l.root === s.libraryRoot)
activeLibraryId = (match || libraries[0]).id
changed = true
}

return { libraries, activeLibraryId, changed }
}

/** The currently-active library record (falls back to the first, or null). */
export function activeLibrary(state) {
if (!state || !Array.isArray(state.libraries) || state.libraries.length === 0) return null
return state.libraries.find((l) => l.id === state.activeLibraryId) || state.libraries[0]
}

/**
* Add a library for `root` and make it active. If a library already points at
* that root, no duplicate is created — it just becomes active instead.
*/
export function addLibrary(state, { root, name, id = randomUUID() } = {}) {
if (!root || !String(root).trim()) return state
const existing = state.libraries.find((l) => l.root === root)
if (existing) return { libraries: state.libraries, activeLibraryId: existing.id }
const lib = { id, name: (name && name.trim()) ? name.trim() : libraryDisplayName(root), root }
return { libraries: [...state.libraries, lib], activeLibraryId: id }
}

/** Rename a library's friendly label (the folder on disk is never touched). */
export function renameLibrary(state, id, name) {
const clean = String(name || '').trim()
if (!clean) return state
return { ...state, libraries: state.libraries.map((l) => (l.id === id ? { ...l, name: clean } : l)) }
}

/**
* Unlink a library from ShopDeck. Files on disk are left alone. The last
* remaining library can't be removed (there is always at least one). If the
* active library is removed, the first survivor becomes active.
*/
export function removeLibrary(state, id) {
if (state.libraries.length <= 1) return state
const libraries = state.libraries.filter((l) => l.id !== id)
if (libraries.length === state.libraries.length) return state
const activeLibraryId = state.activeLibraryId === id ? libraries[0].id : state.activeLibraryId
return { libraries, activeLibraryId }
}

/** Set the active library (no-op if the id isn't known). */
export function setActive(state, id) {
if (!state.libraries.some((l) => l.id === id)) return state
return { ...state, activeLibraryId: id }
}

/** Change the folder a specific library points at (defaults to the active one). */
export function setLibraryRoot(state, root, id = state.activeLibraryId) {
if (!root || !String(root).trim()) return state
return { ...state, libraries: state.libraries.map((l) => (l.id === id ? { ...l, root } : l)) }
}
Loading
Loading