From 44a3ec66f17aba7957a7434473e31428460bedee Mon Sep 17 00:00:00 2001 From: AxialForge <37514365+AxialForge@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:08:32 +0000 Subject: [PATCH 1/8] Fix main-process crash from async fs.watch error on launch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The library watcher created a recursive fs.watch over the library root but only guarded the synchronous setup call. On Windows a recursive watch over an OneDrive-synced Documents folder (the default root) or a network share emits an asynchronous 'error' event ("UNKNOWN: unknown error, watch") from the FSWatcher handle. With no 'error' listener, Node's EventEmitter rethrew it as an uncaught exception, killing the main process with the fatal "A JavaScript error occurred in the main process" dialog — leaving the window blank so files could not be seen or selected. Attach an 'error' listener that closes the watcher and degrades to manual refresh, and add main-process uncaughtException/unhandledRejection guards so a stray async failure no longer takes the whole app down. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019rbmo7LSmxRtFf5zwfvw83 --- electron/main.js | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/electron/main.js b/electron/main.js index 1dec66a..c27016d 100644 --- a/electron/main.js +++ b/electron/main.js @@ -7,6 +7,14 @@ import XLSX from 'xlsx' import { generateTimeline } from './generators/timeline.js' import timelineTemplate from './generators/timeline-template.html?raw' +// ---- Crash resilience ------------------------------------------------------- +// A single stray async error in the main process otherwise shows Electron's +// fatal "A JavaScript error occurred in the main process" dialog and kills the +// app. Log and keep running so a non-critical failure (e.g. a file watcher on a +// flaky network share) never takes the whole window down with it. +process.on('uncaughtException', (err) => { console.error('[main] uncaught exception:', err) }) +process.on('unhandledRejection', (err) => { console.error('[main] unhandled rejection:', err) }) + // ---- Settings (userData/settings.json) -------------------------------------- const DEFAULTS = { libraryRoot: null, theme: 'grey', showUpdater: true, mode: 'editing' } const settingsFile = () => join(app.getPath('userData'), 'settings.json') @@ -55,10 +63,18 @@ async function startWatch() { if (watcher) { try { watcher.close() } catch { /* ignore */ } watcher = null } const root = await libraryRoot() try { - watcher = watch(root, { recursive: true }, (_evt, file) => { + const w = watch(root, { recursive: true }, (_evt, file) => { if (file && String(file).includes('.shopdeck')) return // our own metadata scheduleNotify() }) + // fs.watch can fail ASYNCHRONOUSLY, long after this synchronous setup call. + // On Windows a recursive watch over an OneDrive-synced Documents folder + // (the default root) or a network share emits an 'error' event — + // "UNKNOWN: unknown error, watch" from FSWatcher._handle.onchange. With no + // 'error' listener, EventEmitter rethrows it and the whole main process + // dies with a fatal dialog. Catch it and degrade to manual refresh. + w.on('error', () => { try { w.close() } catch { /* ignore */ } if (watcher === w) watcher = null }) + watcher = w } catch { /* some network shares don't emit events; degrade quietly */ } } From d730fccecf33f0bf3822e07b6fde3346b77db053 Mon Sep 17 00:00:00 2001 From: AxialForge <37514365+AxialForge@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:23:58 +0000 Subject: [PATCH 2/8] Add multiple libraries with rename, and make an unreachable library degrade gracefully MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feature — multiple named libraries: - New pure-Node model electron/libraries.js: a library is a named pointer { id, name, root }. Renaming edits the app-side label only; the folder on disk is never touched (consistent with the title/tag override design). Removing a library just unlinks it — files stay on disk — and the last library can't be removed. Legacy single-root installs migrate automatically. - main.js keeps a normalized { libraries, activeLibraryId } view; the existing libraryRoot() now resolves the ACTIVE library, so every downstream IPC handler keeps working unchanged. New IPC: libraries:list/add/rename/remove/ switch/reveal, plus library:chooseRoot repoints the active library. - preload 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). Rename uses the in-app modal (Electron has no window.prompt). - Unit tests for the model (runs under the existing pure-Node CI). Fix — sibling of the fs.watch crash (a network-share failure must not brick the app): every IPC handler funnels through libraryRoot(), whose fs.mkdir threw when the configured root was an offline share or a deleted folder; library:scan then rejected and the renderer's reload() (no try/catch) hung on "Loading…" forever. Now libraryRoot() never throws, scan degrades to a soft error object, and the renderer shows a dismissable error banner with Retry and Manage libraries instead of an empty window. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019rbmo7LSmxRtFf5zwfvw83 --- electron/libraries.js | 114 +++++++++++++++++++++++++++++++++++ electron/main.js | 91 +++++++++++++++++++++++++--- electron/preload.js | 10 ++++ src/renderer/src/App.jsx | 126 +++++++++++++++++++++++++++++++++++---- src/renderer/src/app.css | 42 +++++++++++++ test/libraries.test.mjs | 118 ++++++++++++++++++++++++++++++++++++ 6 files changed, 479 insertions(+), 22 deletions(-) create mode 100644 electron/libraries.js create mode 100644 test/libraries.test.mjs diff --git a/electron/libraries.js b/electron/libraries.js new file mode 100644 index 0000000..8f7c52d --- /dev/null +++ b/electron/libraries.js @@ -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)) } +} diff --git a/electron/main.js b/electron/main.js index c27016d..e821ba1 100644 --- a/electron/main.js +++ b/electron/main.js @@ -2,6 +2,7 @@ import { app, BrowserWindow, ipcMain, dialog, shell, screen } from 'electron' import { join, basename } from 'node:path' import { promises as fs, watch } from 'node:fs' import { scanLibrary, resolveModuleFile, setOverride, importFiles, importTree, createFolder, moduleVersions, deleteModule, deleteFolder, backupLibrary } from './library.js' +import { normalizeLibraries, activeLibrary as pickActive, addLibrary, renameLibrary, removeLibrary, setActive, setLibraryRoot } from './libraries.js' import * as updater from './updater.js' import XLSX from 'xlsx' import { generateTimeline } from './generators/timeline.js' @@ -16,7 +17,9 @@ process.on('uncaughtException', (err) => { console.error('[main] uncaught except process.on('unhandledRejection', (err) => { console.error('[main] unhandled rejection:', err) }) // ---- Settings (userData/settings.json) -------------------------------------- -const DEFAULTS = { libraryRoot: null, theme: 'grey', showUpdater: true, mode: 'editing' } +// `libraries` is the multi-library list (see electron/libraries.js); `libraryRoot` +// is the legacy single-root field kept only so old installs migrate cleanly. +const DEFAULTS = { libraries: null, activeLibraryId: null, libraryRoot: null, theme: 'grey', showUpdater: true, mode: 'editing' } const settingsFile = () => join(app.getPath('userData'), 'settings.json') const defaultRoot = () => join(app.getPath('documents'), 'ShopDeck Library') @@ -29,12 +32,45 @@ async function saveSettings(patch) { await fs.writeFile(settingsFile(), JSON.stringify(next, null, 2), 'utf8') return next } -async function libraryRoot() { + +// The normalized { libraries, activeLibraryId } view, migrating a legacy single +// libraryRoot on first read and persisting the result so it only happens once. +async function libraryState() { const s = await loadSettings() - const root = s.libraryRoot || defaultRoot() - await fs.mkdir(root, { recursive: true }) - if (!s.libraryRoot) await saveSettings({ libraryRoot: root }) // persist the resolved default - return root + const st = normalizeLibraries(s, defaultRoot()) + if (st.changed) await saveSettings({ libraries: st.libraries, activeLibraryId: st.activeLibraryId }) + return { libraries: st.libraries, activeLibraryId: st.activeLibraryId } +} +async function persistState(st) { + await saveSettings({ libraries: st.libraries, activeLibraryId: st.activeLibraryId }) + return st +} +async function libraryRoot() { + const lib = pickActive(await libraryState()) + // Best-effort ensure the folder exists. Never throw here: an offline share or + // a deleted folder is surfaced by scan (as a soft error) instead of taking + // down whichever IPC handler happened to call this first. + try { await fs.mkdir(lib.root, { recursive: true }) } catch { /* surfaced by scan */ } + return lib.root +} + +// Turn a raw fs error from an unreachable/missing library into a short, plain +// message the renderer can show — the graceful-degradation sibling of the +// watcher fix (a network-share failure must not brick the app). +function friendlyRootError(err) { + const msg = String((err && err.message) || err || '') + if (/ENOENT|no such file/i.test(msg)) return 'The library folder could not be found — it may have been moved or renamed, or a network share is offline.' + if (/EPERM|EACCES|permission denied/i.test(msg)) return "ShopDeck doesn't have permission to read this library folder." + if (/UNKNOWN|EBUSY|ENETUNREACH|ETIMEDOUT|EHOSTDOWN|EHOSTUNREACH|ENXIO/i.test(msg)) return 'The library folder is unavailable — a network share may be disconnected.' + return 'The library could not be opened: ' + (msg.split('\n')[0].slice(0, 160) || 'unknown error') +} + +// Scan the active library, degrading to a soft error object (never a rejection) +// so the renderer can render a retry banner instead of hanging on "Loading…". +async function scanActive() { + const lib = pickActive(await libraryState()) + try { return { ...(await scanLibrary(lib.root)), library: lib } } + catch (err) { return { root: lib.root, folders: [], modules: [], library: lib, error: friendlyRootError(err) } } } // ---- Windows ---------------------------------------------------------------- @@ -114,8 +150,9 @@ function openModuleWindow({ indexPath, title, version }) { ipcMain.handle('settings:get', () => loadSettings()) ipcMain.handle('settings:set', (_e, patch) => saveSettings(patch || {})) -ipcMain.handle('library:scan', async () => scanLibrary(await libraryRoot())) +ipcMain.handle('library:scan', () => scanActive()) +// Change the folder the ACTIVE library points at (multi-library repoint). ipcMain.handle('library:chooseRoot', async () => { const res = await dialog.showOpenDialog(mainWindow, { title: 'Choose library folder (local or network share)', @@ -123,9 +160,45 @@ ipcMain.handle('library:chooseRoot', async () => { }) if (res.canceled || !res.filePaths[0]) return { canceled: true } const root = res.filePaths[0] - await saveSettings({ libraryRoot: root }) + await persistState(setLibraryRoot(await libraryState(), root)) startWatch() - return { canceled: false, root, ...(await scanLibrary(root)) } + return { canceled: false, root, ...(await scanActive()) } +}) + +// ---- Libraries (multiple named library roots) ------------------------------- +ipcMain.handle('libraries:list', () => libraryState()) + +ipcMain.handle('libraries:add', async () => { + const res = await dialog.showOpenDialog(mainWindow, { + title: 'Add a library folder (local or network share)', + properties: ['openDirectory', 'createDirectory'] + }) + if (res.canceled || !res.filePaths[0]) return { canceled: true } + const next = await persistState(addLibrary(await libraryState(), { root: res.filePaths[0] })) + startWatch() + return { canceled: false, ...next } +}) + +ipcMain.handle('libraries:rename', async (_e, { id, name }) => + persistState(renameLibrary(await libraryState(), id, name))) + +ipcMain.handle('libraries:remove', async (_e, { id }) => { + const next = await persistState(removeLibrary(await libraryState(), id)) + startWatch() + return next +}) + +ipcMain.handle('libraries:switch', async (_e, { id }) => { + const next = await persistState(setActive(await libraryState(), id)) + startWatch() + return next +}) + +ipcMain.handle('libraries:reveal', async (_e, { id }) => { + const st = await libraryState() + const lib = st.libraries.find((l) => l.id === id) + if (lib) await shell.openPath(lib.root) + return true }) ipcMain.handle('library:reveal', async () => { await shell.openPath(await libraryRoot()); return true }) diff --git a/electron/preload.js b/electron/preload.js index 565b4a5..1e07ad9 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -9,6 +9,16 @@ contextBridge.exposeInMainWorld('shopdeck', { revealRoot: () => ipcRenderer.invoke('library:reveal'), openExternal: (url) => ipcRenderer.invoke('shell:openExternal', { url }), + // Multiple named libraries + rename (see electron/libraries.js). + libraries: { + list: () => ipcRenderer.invoke('libraries:list'), + add: () => ipcRenderer.invoke('libraries:add'), + rename: (id, name) => ipcRenderer.invoke('libraries:rename', { id, name }), + remove: (id) => ipcRenderer.invoke('libraries:remove', { id }), + switch: (id) => ipcRenderer.invoke('libraries:switch', { id }), + reveal: (id) => ipcRenderer.invoke('libraries:reveal', { id }) + }, + open: (id, version, title) => ipcRenderer.invoke('module:open', { id, version, title }), showSource: (id, version) => ipcRenderer.invoke('module:source', { id, version }), setMeta: (id, patch) => ipcRenderer.invoke('module:setMeta', { id, ...patch }), diff --git a/src/renderer/src/App.jsx b/src/renderer/src/App.jsx index 4223d1b..f4a70ce 100644 --- a/src/renderer/src/App.jsx +++ b/src/renderer/src/App.jsx @@ -54,9 +54,13 @@ function buildTree(folders) { return root } +const SAMPLE_LIBS = [{ id: 'sample', name: 'ShopDeck Library', root: SAMPLE.root }] + export default function App() { const [settings, setSettings] = useState({ theme: 'grey', libraryRoot: '', mode: 'editing' }) const [data, setData] = useState({ root: '', folders: [], modules: [] }) + const [libraries, setLibraries] = useState(SAMPLE_LIBS) + const [activeLibraryId, setActiveLibraryId] = useState('sample') const [loading, setLoading] = useState(true) const [view, setView] = useState('home') // home | library | generator | settings | about const [dropping, setDropping] = useState(false) @@ -66,17 +70,29 @@ export default function App() { const [activeTags, setActiveTags] = useState(() => new Set()) const [editing, setEditing] = useState(null) // module being edited const [newFolderOpen, setNewFolderOpen] = useState(false) + const [renaming, setRenaming] = useState(null) // library being renamed const [thumbs, setThumbs] = useState({}) // id -> data URL ('' = tried, none) const applyTheme = (t) => { document.documentElement.dataset.theme = t || 'grey' } const reload = useCallback(async () => { setLoading(true) - const s = api ? await api.getSettings() : { theme: 'grey', libraryRoot: SAMPLE.root, mode: 'editing' } - applyTheme(s.theme) - setSettings(s) - setData(api ? await api.scan() : SAMPLE) - setLoading(false) + try { + const s = api ? await api.getSettings() : { theme: 'grey', libraryRoot: SAMPLE.root, mode: 'editing' } + applyTheme(s.theme) + setSettings(s) + if (api) { + const st = await api.libraries.list() + setLibraries(st.libraries || []) + setActiveLibraryId(st.activeLibraryId || null) + } + setData(api ? await api.scan() : SAMPLE) + } catch (err) { + // Never leave the UI stuck on "Loading…" — surface the failure instead. + setData((d) => ({ ...d, error: String((err && err.message) || err) })) + } finally { + setLoading(false) + } }, []) useEffect(() => { reload() }, [reload]) @@ -156,6 +172,22 @@ export default function App() { } async function onChangeTheme(t) { applyTheme(t); setSettings((s) => ({ ...s, theme: t })); if (api) await api.setSettings({ theme: t }) } async function onChangeRoot() { if (!api) return; const r = await api.chooseRoot(); if (!r?.canceled) reload() } + + // ---- Libraries ------------------------------------------------------------- + async function onAddLibrary() { if (!api) return; const r = await api.libraries.add(); if (!r?.canceled) reload() } + async function onSwitchLibrary(id) { + if (!api || id === activeLibraryId) return + setFolder(''); setActiveTags(new Set()); setQuery('') // filters are per-library + await api.libraries.switch(id); reload() + } + async function onRenameLibrary(id, name) { + setRenaming(null) + if (!api || !name) return + const st = await api.libraries.rename(id, name) + setLibraries(st.libraries || []); setActiveLibraryId(st.activeLibraryId || null) + } + async function onRemoveLibrary(id) { if (!api) return; await api.libraries.remove(id); reload() } + function onRevealLibrary(id) { api?.libraries.reveal(id) } async function saveEdit(id, title, tags) { if (api) await api.setMeta(id, { title, tags }); setEditing(null); reload() } async function createFolderNow(name) { setNewFolderOpen(false) @@ -177,6 +209,13 @@ export default function App() {
S ShopDeck
+ {libraries.length > 0 && ( + + )}
+ {data.error && ( +
+ ! + {data.error} +
+ )} + {view === 'library' && ( <>
@@ -283,8 +331,12 @@ export default function App() { {view === 'home' && api?.revealRoot()} />} {view === 'generator' && } - {view === 'settings' && api?.revealRoot()} hasApi={!!api} />} + {view === 'settings' && api?.revealRoot()} hasApi={!!api} + onAddLibrary={onAddLibrary} onSwitchLibrary={onSwitchLibrary} + onRenameLibrary={(lib) => setRenaming(lib)} onRemoveLibrary={onRemoveLibrary} onRevealLibrary={onRevealLibrary} />} {view === 'about' && }
@@ -296,6 +348,10 @@ export default function App() { {editing && setEditing(null)} onSave={saveEdit} />} {newFolderOpen && setNewFolderOpen(false)} onSave={createFolderNow} note={folder ? `Inside: ${folder}` : 'At the library root'} />} + {renaming && setRenaming(null)} onSave={(name) => onRenameLibrary(renaming.id, name)} />}
) } @@ -336,8 +392,9 @@ function EditModal({ module, onCancel, onSave }) { ) } -function NameModal({ title, placeholder, note, onCancel, onSave }) { - const [name, setName] = useState('') +function NameModal({ title, placeholder, note, initial = '', actionLabel = 'Create', sanitize = true, onCancel, onSave }) { + const [name, setName] = useState(initial) + const submit = () => { const v = name.trim(); onSave(sanitize ? v.replace(/[\\/]/g, '-') : v) } return (
e.stopPropagation()}> @@ -348,7 +405,7 @@ function NameModal({ title, placeholder, note, onCancel, onSave }) {
@@ -434,7 +491,46 @@ function AboutView() { ) } -function SettingsView({ settings, root, onChangeTheme, onChangeMode, onChangeRoot, onReveal, hasApi }) { +function LibrariesSection({ libraries, activeLibraryId, hasApi, onAdd, onSwitch, onRename, onRemove, onReveal }) { + const single = libraries.length <= 1 + return ( +
+
Libraries
+
Keep several libraries (local folders or network shares) and switch between + them from the bar up top. Renaming changes only the label shown here — the folder on disk is never + touched. Removing a library just unlinks it from ShopDeck; your files stay on disk.
+
+ {libraries.map((l) => { + const active = l.id === activeLibraryId + return ( +
+
+
+ {l.name} + {active && } +
+
{l.root}
+
+
+ {!active && } + + + {!single && } +
+
+ ) + })} +
+
+
+ {!hasApi &&
(Browser preview — library management works in the desktop app.)
} +
+ ) +} + +function SettingsView({ settings, root, libraries, activeLibraryId, onChangeTheme, onChangeMode, onChangeRoot, + onReveal, hasApi, onAddLibrary, onSwitchLibrary, onRenameLibrary, onRemoveLibrary, onRevealLibrary }) { const [backupMsg, setBackupMsg] = useState('') async function onBackup() { if (!hasApi) return @@ -457,9 +553,13 @@ function SettingsView({ settings, root, onChangeTheme, onChangeMode, onChangeRoo
+ +
-
Library folder
-
The selected folder's structure is the organization. The app only reads and writes inside it.
+
Active library folder
+
The active library's folder structure is the organization. The app only reads and writes inside it.
{root || settings.libraryRoot || '—'}