@wildwinter/app-shell 0.31.0 → 0.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app-store.cjs +9 -3
- package/dist/app-store.cjs.map +1 -1
- package/dist/app-store.js +9 -3
- package/dist/app-store.js.map +1 -1
- package/dist/index.cjs +10 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +14 -1
- package/dist/index.d.ts +14 -1
- package/dist/index.js +10 -2
- package/dist/index.js.map +1 -1
- package/dist/tool-window.cjs +8 -2
- package/dist/tool-window.cjs.map +1 -1
- package/dist/tool-window.d.cts +21 -3
- package/dist/tool-window.d.ts +21 -3
- package/dist/tool-window.js +8 -3
- package/dist/tool-window.js.map +1 -1
- package/package.json +2 -1
package/dist/app-store.cjs
CHANGED
|
@@ -22,16 +22,22 @@ function createAppStore(opts) {
|
|
|
22
22
|
// Migration: a settings file written before places were keyed by project has
|
|
23
23
|
// a single `place` belonging to `lastProject`. Carry it over rather than
|
|
24
24
|
// dropping it, or everyone's first launch after this update forgets where
|
|
25
|
-
// they were
|
|
25
|
+
// they were - but only where no keyed entry exists. The first cut of this
|
|
26
|
+
// merge put the legacy value LAST, so a stale singular beat every fresh
|
|
27
|
+
// per-project write on every launch, and (because the `...loaded` spread
|
|
28
|
+
// above kept the key alive through save) it did so for ever: writes landed
|
|
29
|
+
// correctly all session and the next start quietly reset them. Found from
|
|
30
|
+
// a host's user report; the key is retired just below.
|
|
26
31
|
places: {
|
|
27
|
-
...loaded.
|
|
28
|
-
...loaded.
|
|
32
|
+
...loaded.lastProject !== void 0 && loaded.place !== void 0 ? { [loaded.lastProject]: loaded.place } : {},
|
|
33
|
+
...loaded.places ?? {}
|
|
29
34
|
},
|
|
30
35
|
// The app's slice merges FIELD BY FIELD, so a key added in a new version
|
|
31
36
|
// arrives with its default rather than being absent for everybody who has
|
|
32
37
|
// run the app before.
|
|
33
38
|
app: { ...opts.defaults, ...loaded.app ?? {} }
|
|
34
39
|
};
|
|
40
|
+
delete state.place;
|
|
35
41
|
const save = () => {
|
|
36
42
|
try {
|
|
37
43
|
fs.mkdirSync(opts.dir, { recursive: true });
|
package/dist/app-store.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/app-store.ts"],"names":["join","readFileSync","mkdirSync","writeFileSync","renameSync"],"mappings":"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","file":"app-store.cjs","sourcesContent":["// ---------------------------------------------------------------------------\n// The user's settings for THIS app: a small JSON file in the app's userData.\n//\n// Every app in the family has one, and both of the two that exist wrote it\n// separately: the same tolerant read, the same merged defaults, the same capped\n// recents, the same panes, the same per-tool-window bounds and pin, the same\n// identity. That is the definition of shell work.\n//\n// WHAT BELONGS HERE is settings that follow the PERSON, not the project: where\n// their windows were, which panes they like open, what they are called, what\n// they had open last. Project content never comes near this file - it is not\n// version controlled, it is not shared, and losing it should cost an author\n// nothing but a re-arranged window.\n//\n// The shell owns the CORE it can reason about and hands the app one slice of\n// its own (`app`) for everything else - a theme value, a view mode, whatever it\n// has. Two stores in one file, one atomic write, so a crash cannot leave an app\n// with half its settings.\n//\n// Electron-free on purpose: it takes a directory. That keeps it unit-testable\n// and lets a test point it at a temp dir.\n// ---------------------------------------------------------------------------\n\nimport { mkdirSync, readFileSync, renameSync, writeFileSync } from \"node:fs\";\nimport { join } from \"node:path\";\n\n/** Who is at the keyboard. Never in a project: see identity.ts. */\nexport interface Identity { name: string; email?: string }\n\n/** A remembered window rectangle. Absent = the app's default, centred. */\nexport interface WindowBounds { x?: number; y?: number; width: number; height: number }\n\n/** One helper window's remembered state. */\nexport interface WindowState { bounds?: WindowBounds; pinned?: boolean }\n\n/** Side panes: open/closed, and any dragged width. */\nexport interface PaneState {\n nav?: boolean;\n inspector?: boolean;\n navW?: number;\n inspW?: number;\n}\n\n/**\n * The part every app has.\n *\n * `places` is deliberately opaque: \"open where you left off\" is a family idea,\n * but WHERE is a scene and a caret in one app and a box, a tab and a card in\n * another.\n *\n * It is keyed BY PROJECT, and that is the interesting part. This held a single\n * `place` and dropped it whenever a different project was opened, on the\n * reasoning that clearing it was the one rule that could not be app-specific.\n * That reasoning was wrong, and Patterpad disproved it by having done the other\n * thing for a long time: it remembers the line you were on in every project you\n * have opened. With one slot, working on A, glancing at B and coming back to A\n * lands you at the top of A, having forgotten. Nobody wants that, and no editor\n * outside this package does it.\n */\n/**\n * A project in the Open Recent menu.\n *\n * The NAME is here rather than being derived from the path, because a project's\n * display name is what its file says it is, not what its folder is called: two\n * projects can sit in folders called `draft`, and one can be renamed without\n * moving. Every app in the family shows this menu, so both halves are shared.\n *\n * Optional because the path is the identity and the name is only how it reads.\n * An entry recorded before a name was known still works.\n */\nexport interface RecentProject {\n path: string;\n name?: string;\n}\n\nexport interface AppSettingsCore<Place> {\n recents: RecentProject[];\n lastProject?: string;\n /** Project path -> where the author was in it. Capped with `recents`, so a\n * project forgotten from the list stops carrying a place around too. */\n places: Record<string, Place>;\n panes: PaneState;\n identity?: Identity;\n /** Keyed by whatever the app calls each window (\"board\", \"search\"). */\n windows: Record<string, WindowState>;\n}\n\nexport type AppSettings<Place, App> = AppSettingsCore<Place> & { app: App };\n\nexport interface AppStoreOptions<Place, App> {\n /** The app's userData directory. */\n dir: string;\n /** The file inside it. One per app; two apps never share. */\n fileName?: string;\n /** The app's own slice, as it should read on a first run. */\n defaults: App;\n /** How the panes should sit before anybody touches them. */\n panes?: PaneState;\n maxRecents?: number;\n}\n\nexport interface AppStore<Place, App> {\n /** A copy, always: nothing outside can mutate the state behind the store's back. */\n get(): AppSettings<Place, App>;\n /** Merge into the app's own slice and save. */\n patchApp(patch: Partial<App>): void;\n /** Record an opened project: front of recents, deduped, capped. The name is\n * refreshed each time, so renaming a project updates the menu on next open;\n * omitting it keeps whatever was known before rather than blanking it. */\n touchProject(path: string, name?: string): void;\n /** Drop one that failed to open (moved, deleted). */\n forgetProject(path: string): void;\n /** Where the author was in the CURRENT project. Compared before writing: this\n * is called on every navigation and must not mean a disk write per click.\n * A no-op with no project open, since there is nothing to key it against. */\n setPlace(place: Place): void;\n /** Where the author was in a project, or undefined if it was never opened or\n * has aged out of `recents`. Defaults to the current one. */\n placeOf(path?: string): Place | undefined;\n setPanes(panes: PaneState): void;\n setIdentity(identity: Identity): void;\n /** One window's bounds and/or pin, merged with what it already had. */\n setWindow(key: string, state: WindowState): void;\n}\n\nexport function createAppStore<Place, App extends object>(\n opts: AppStoreOptions<Place, App>,\n): AppStore<Place, App> {\n const file = join(opts.dir, opts.fileName ?? \"app-settings.json\");\n const maxRecents = opts.maxRecents ?? 8;\n\n let loaded: Partial<AppSettings<Place, App>> = {};\n try {\n loaded = JSON.parse(readFileSync(file, \"utf8\")) as Partial<AppSettings<Place, App>>;\n } catch {\n // First run, or a file we cannot read. Defaults, never a crash: settings\n // are not worth failing to start over.\n }\n\n const state: AppSettings<Place, App> = {\n panes: opts.panes ?? {},\n windows: {},\n ...loaded,\n // Migration: recents used to be bare paths. Keep them, without names, rather\n // than emptying somebody's Open Recent menu on an update.\n recents: (loaded.recents ?? []).map((r) =>\n typeof r === \"string\" ? { path: r } : r) as RecentProject[],\n // Migration: a settings file written before places were keyed by project has\n // a single `place` belonging to `lastProject`. Carry it over rather than\n // dropping it, or everyone's first launch after this update forgets where\n // they were, which is the exact complaint the change is meant to fix.\n places: {\n ...(loaded.places ?? {}),\n ...(loaded.lastProject !== undefined && (loaded as { place?: Place }).place !== undefined\n ? { [loaded.lastProject]: (loaded as { place?: Place }).place as Place }\n : {}),\n },\n // The app's slice merges FIELD BY FIELD, so a key added in a new version\n // arrives with its default rather than being absent for everybody who has\n // run the app before.\n app: { ...opts.defaults, ...(loaded.app ?? {}) },\n };\n\n const save = (): void => {\n try {\n mkdirSync(opts.dir, { recursive: true });\n // Write beside, then rename: a rename is atomic on every platform this\n // ships to, so a crash mid-write cannot leave a half-written settings\n // file - which both apps' hand-written stores could.\n const temp = `${file}.tmp`;\n writeFileSync(temp, `${JSON.stringify(state, null, 2)}\\n`, \"utf8\");\n renameSync(temp, file);\n } catch {\n // A read-only home directory, a full disk: the app carries on with the\n // settings it has in memory rather than dying over a preference.\n }\n };\n\n return {\n get: () => structuredClone(state),\n patchApp(patch) {\n Object.assign(state.app, patch);\n save();\n },\n touchProject(path, name) {\n const known = state.recents.find((r) => r.path === path);\n // An omitted name keeps the one we had: reopening from a route that does\n // not know the name must not blank the menu entry.\n const entry: RecentProject = { path, ...(name ?? known?.name ? { name: name ?? known?.name } : {}) };\n state.recents = [entry, ...state.recents.filter((r) => r.path !== path)].slice(0, maxRecents);\n state.lastProject = path;\n // Places follow recents: one that has aged off the list should not keep a\n // place in the file for ever. Nothing is dropped for merely opening\n // something else, which is the whole point of keying them.\n for (const key of Object.keys(state.places)) {\n if (!state.recents.some((r) => r.path === key)) delete state.places[key];\n }\n save();\n },\n forgetProject(path) {\n state.recents = state.recents.filter((r) => r.path !== path);\n if (state.lastProject === path) delete state.lastProject;\n delete state.places[path]; // it moved or went: its place is meaningless now\n save();\n },\n setPlace(place) {\n const path = state.lastProject;\n if (path === undefined) return; // no project open: nothing to key it against\n if (JSON.stringify(state.places[path]) === JSON.stringify(place)) return;\n state.places[path] = place;\n save();\n },\n placeOf: (path) => {\n const key = path ?? state.lastProject;\n return key === undefined ? undefined : structuredClone(state.places[key]);\n },\n setPanes(panes) {\n state.panes = { ...state.panes, ...panes };\n save();\n },\n setIdentity(identity) {\n state.identity = identity;\n save();\n },\n setWindow(key, next) {\n state.windows[key] = { ...state.windows[key], ...next };\n save();\n },\n };\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/app-store.ts"],"names":["join","readFileSync","mkdirSync","writeFileSync","renameSync"],"mappings":"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","file":"app-store.cjs","sourcesContent":["// ---------------------------------------------------------------------------\n// The user's settings for THIS app: a small JSON file in the app's userData.\n//\n// Every app in the family has one, and both of the two that exist wrote it\n// separately: the same tolerant read, the same merged defaults, the same capped\n// recents, the same panes, the same per-tool-window bounds and pin, the same\n// identity. That is the definition of shell work.\n//\n// WHAT BELONGS HERE is settings that follow the PERSON, not the project: where\n// their windows were, which panes they like open, what they are called, what\n// they had open last. Project content never comes near this file - it is not\n// version controlled, it is not shared, and losing it should cost an author\n// nothing but a re-arranged window.\n//\n// The shell owns the CORE it can reason about and hands the app one slice of\n// its own (`app`) for everything else - a theme value, a view mode, whatever it\n// has. Two stores in one file, one atomic write, so a crash cannot leave an app\n// with half its settings.\n//\n// Electron-free on purpose: it takes a directory. That keeps it unit-testable\n// and lets a test point it at a temp dir.\n// ---------------------------------------------------------------------------\n\nimport { mkdirSync, readFileSync, renameSync, writeFileSync } from \"node:fs\";\nimport { join } from \"node:path\";\n\n/** Who is at the keyboard. Never in a project: see identity.ts. */\nexport interface Identity { name: string; email?: string }\n\n/** A remembered window rectangle. Absent = the app's default, centred. */\nexport interface WindowBounds { x?: number; y?: number; width: number; height: number }\n\n/** One helper window's remembered state. */\nexport interface WindowState { bounds?: WindowBounds; pinned?: boolean }\n\n/** Side panes: open/closed, and any dragged width. */\nexport interface PaneState {\n nav?: boolean;\n inspector?: boolean;\n navW?: number;\n inspW?: number;\n}\n\n/**\n * The part every app has.\n *\n * `places` is deliberately opaque: \"open where you left off\" is a family idea,\n * but WHERE is a scene and a caret in one app and a box, a tab and a card in\n * another.\n *\n * It is keyed BY PROJECT, and that is the interesting part. This held a single\n * `place` and dropped it whenever a different project was opened, on the\n * reasoning that clearing it was the one rule that could not be app-specific.\n * That reasoning was wrong, and Patterpad disproved it by having done the other\n * thing for a long time: it remembers the line you were on in every project you\n * have opened. With one slot, working on A, glancing at B and coming back to A\n * lands you at the top of A, having forgotten. Nobody wants that, and no editor\n * outside this package does it.\n */\n/**\n * A project in the Open Recent menu.\n *\n * The NAME is here rather than being derived from the path, because a project's\n * display name is what its file says it is, not what its folder is called: two\n * projects can sit in folders called `draft`, and one can be renamed without\n * moving. Every app in the family shows this menu, so both halves are shared.\n *\n * Optional because the path is the identity and the name is only how it reads.\n * An entry recorded before a name was known still works.\n */\nexport interface RecentProject {\n path: string;\n name?: string;\n}\n\nexport interface AppSettingsCore<Place> {\n recents: RecentProject[];\n lastProject?: string;\n /** Project path -> where the author was in it. Capped with `recents`, so a\n * project forgotten from the list stops carrying a place around too. */\n places: Record<string, Place>;\n panes: PaneState;\n identity?: Identity;\n /** Keyed by whatever the app calls each window (\"board\", \"search\"). */\n windows: Record<string, WindowState>;\n}\n\nexport type AppSettings<Place, App> = AppSettingsCore<Place> & { app: App };\n\nexport interface AppStoreOptions<Place, App> {\n /** The app's userData directory. */\n dir: string;\n /** The file inside it. One per app; two apps never share. */\n fileName?: string;\n /** The app's own slice, as it should read on a first run. */\n defaults: App;\n /** How the panes should sit before anybody touches them. */\n panes?: PaneState;\n maxRecents?: number;\n}\n\nexport interface AppStore<Place, App> {\n /** A copy, always: nothing outside can mutate the state behind the store's back. */\n get(): AppSettings<Place, App>;\n /** Merge into the app's own slice and save. */\n patchApp(patch: Partial<App>): void;\n /** Record an opened project: front of recents, deduped, capped. The name is\n * refreshed each time, so renaming a project updates the menu on next open;\n * omitting it keeps whatever was known before rather than blanking it. */\n touchProject(path: string, name?: string): void;\n /** Drop one that failed to open (moved, deleted). */\n forgetProject(path: string): void;\n /** Where the author was in the CURRENT project. Compared before writing: this\n * is called on every navigation and must not mean a disk write per click.\n * A no-op with no project open, since there is nothing to key it against. */\n setPlace(place: Place): void;\n /** Where the author was in a project, or undefined if it was never opened or\n * has aged out of `recents`. Defaults to the current one. */\n placeOf(path?: string): Place | undefined;\n setPanes(panes: PaneState): void;\n setIdentity(identity: Identity): void;\n /** One window's bounds and/or pin, merged with what it already had. */\n setWindow(key: string, state: WindowState): void;\n}\n\nexport function createAppStore<Place, App extends object>(\n opts: AppStoreOptions<Place, App>,\n): AppStore<Place, App> {\n const file = join(opts.dir, opts.fileName ?? \"app-settings.json\");\n const maxRecents = opts.maxRecents ?? 8;\n\n let loaded: Partial<AppSettings<Place, App>> = {};\n try {\n loaded = JSON.parse(readFileSync(file, \"utf8\")) as Partial<AppSettings<Place, App>>;\n } catch {\n // First run, or a file we cannot read. Defaults, never a crash: settings\n // are not worth failing to start over.\n }\n\n const state: AppSettings<Place, App> = {\n panes: opts.panes ?? {},\n windows: {},\n ...loaded,\n // Migration: recents used to be bare paths. Keep them, without names, rather\n // than emptying somebody's Open Recent menu on an update.\n recents: (loaded.recents ?? []).map((r) =>\n typeof r === \"string\" ? { path: r } : r) as RecentProject[],\n // Migration: a settings file written before places were keyed by project has\n // a single `place` belonging to `lastProject`. Carry it over rather than\n // dropping it, or everyone's first launch after this update forgets where\n // they were - but only where no keyed entry exists. The first cut of this\n // merge put the legacy value LAST, so a stale singular beat every fresh\n // per-project write on every launch, and (because the `...loaded` spread\n // above kept the key alive through save) it did so for ever: writes landed\n // correctly all session and the next start quietly reset them. Found from\n // a host's user report; the key is retired just below.\n places: {\n ...(loaded.lastProject !== undefined && (loaded as { place?: Place }).place !== undefined\n ? { [loaded.lastProject]: (loaded as { place?: Place }).place as Place }\n : {}),\n ...(loaded.places ?? {}),\n },\n // The app's slice merges FIELD BY FIELD, so a key added in a new version\n // arrives with its default rather than being absent for everybody who has\n // run the app before.\n app: { ...opts.defaults, ...(loaded.app ?? {}) },\n };\n // Retire the legacy key: it rode in on `...loaded`, and a state that still\n // carries it re-runs the fold on every future load.\n delete (state as { place?: Place }).place;\n\n const save = (): void => {\n try {\n mkdirSync(opts.dir, { recursive: true });\n // Write beside, then rename: a rename is atomic on every platform this\n // ships to, so a crash mid-write cannot leave a half-written settings\n // file - which both apps' hand-written stores could.\n const temp = `${file}.tmp`;\n writeFileSync(temp, `${JSON.stringify(state, null, 2)}\\n`, \"utf8\");\n renameSync(temp, file);\n } catch {\n // A read-only home directory, a full disk: the app carries on with the\n // settings it has in memory rather than dying over a preference.\n }\n };\n\n return {\n get: () => structuredClone(state),\n patchApp(patch) {\n Object.assign(state.app, patch);\n save();\n },\n touchProject(path, name) {\n const known = state.recents.find((r) => r.path === path);\n // An omitted name keeps the one we had: reopening from a route that does\n // not know the name must not blank the menu entry.\n const entry: RecentProject = { path, ...(name ?? known?.name ? { name: name ?? known?.name } : {}) };\n state.recents = [entry, ...state.recents.filter((r) => r.path !== path)].slice(0, maxRecents);\n state.lastProject = path;\n // Places follow recents: one that has aged off the list should not keep a\n // place in the file for ever. Nothing is dropped for merely opening\n // something else, which is the whole point of keying them.\n for (const key of Object.keys(state.places)) {\n if (!state.recents.some((r) => r.path === key)) delete state.places[key];\n }\n save();\n },\n forgetProject(path) {\n state.recents = state.recents.filter((r) => r.path !== path);\n if (state.lastProject === path) delete state.lastProject;\n delete state.places[path]; // it moved or went: its place is meaningless now\n save();\n },\n setPlace(place) {\n const path = state.lastProject;\n if (path === undefined) return; // no project open: nothing to key it against\n if (JSON.stringify(state.places[path]) === JSON.stringify(place)) return;\n state.places[path] = place;\n save();\n },\n placeOf: (path) => {\n const key = path ?? state.lastProject;\n return key === undefined ? undefined : structuredClone(state.places[key]);\n },\n setPanes(panes) {\n state.panes = { ...state.panes, ...panes };\n save();\n },\n setIdentity(identity) {\n state.identity = identity;\n save();\n },\n setWindow(key, next) {\n state.windows[key] = { ...state.windows[key], ...next };\n save();\n },\n };\n}\n"]}
|
package/dist/app-store.js
CHANGED
|
@@ -20,16 +20,22 @@ function createAppStore(opts) {
|
|
|
20
20
|
// Migration: a settings file written before places were keyed by project has
|
|
21
21
|
// a single `place` belonging to `lastProject`. Carry it over rather than
|
|
22
22
|
// dropping it, or everyone's first launch after this update forgets where
|
|
23
|
-
// they were
|
|
23
|
+
// they were - but only where no keyed entry exists. The first cut of this
|
|
24
|
+
// merge put the legacy value LAST, so a stale singular beat every fresh
|
|
25
|
+
// per-project write on every launch, and (because the `...loaded` spread
|
|
26
|
+
// above kept the key alive through save) it did so for ever: writes landed
|
|
27
|
+
// correctly all session and the next start quietly reset them. Found from
|
|
28
|
+
// a host's user report; the key is retired just below.
|
|
24
29
|
places: {
|
|
25
|
-
...loaded.
|
|
26
|
-
...loaded.
|
|
30
|
+
...loaded.lastProject !== void 0 && loaded.place !== void 0 ? { [loaded.lastProject]: loaded.place } : {},
|
|
31
|
+
...loaded.places ?? {}
|
|
27
32
|
},
|
|
28
33
|
// The app's slice merges FIELD BY FIELD, so a key added in a new version
|
|
29
34
|
// arrives with its default rather than being absent for everybody who has
|
|
30
35
|
// run the app before.
|
|
31
36
|
app: { ...opts.defaults, ...loaded.app ?? {} }
|
|
32
37
|
};
|
|
38
|
+
delete state.place;
|
|
33
39
|
const save = () => {
|
|
34
40
|
try {
|
|
35
41
|
mkdirSync(opts.dir, { recursive: true });
|
package/dist/app-store.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/app-store.ts"],"names":[],"mappings":"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","file":"app-store.js","sourcesContent":["// ---------------------------------------------------------------------------\n// The user's settings for THIS app: a small JSON file in the app's userData.\n//\n// Every app in the family has one, and both of the two that exist wrote it\n// separately: the same tolerant read, the same merged defaults, the same capped\n// recents, the same panes, the same per-tool-window bounds and pin, the same\n// identity. That is the definition of shell work.\n//\n// WHAT BELONGS HERE is settings that follow the PERSON, not the project: where\n// their windows were, which panes they like open, what they are called, what\n// they had open last. Project content never comes near this file - it is not\n// version controlled, it is not shared, and losing it should cost an author\n// nothing but a re-arranged window.\n//\n// The shell owns the CORE it can reason about and hands the app one slice of\n// its own (`app`) for everything else - a theme value, a view mode, whatever it\n// has. Two stores in one file, one atomic write, so a crash cannot leave an app\n// with half its settings.\n//\n// Electron-free on purpose: it takes a directory. That keeps it unit-testable\n// and lets a test point it at a temp dir.\n// ---------------------------------------------------------------------------\n\nimport { mkdirSync, readFileSync, renameSync, writeFileSync } from \"node:fs\";\nimport { join } from \"node:path\";\n\n/** Who is at the keyboard. Never in a project: see identity.ts. */\nexport interface Identity { name: string; email?: string }\n\n/** A remembered window rectangle. Absent = the app's default, centred. */\nexport interface WindowBounds { x?: number; y?: number; width: number; height: number }\n\n/** One helper window's remembered state. */\nexport interface WindowState { bounds?: WindowBounds; pinned?: boolean }\n\n/** Side panes: open/closed, and any dragged width. */\nexport interface PaneState {\n nav?: boolean;\n inspector?: boolean;\n navW?: number;\n inspW?: number;\n}\n\n/**\n * The part every app has.\n *\n * `places` is deliberately opaque: \"open where you left off\" is a family idea,\n * but WHERE is a scene and a caret in one app and a box, a tab and a card in\n * another.\n *\n * It is keyed BY PROJECT, and that is the interesting part. This held a single\n * `place` and dropped it whenever a different project was opened, on the\n * reasoning that clearing it was the one rule that could not be app-specific.\n * That reasoning was wrong, and Patterpad disproved it by having done the other\n * thing for a long time: it remembers the line you were on in every project you\n * have opened. With one slot, working on A, glancing at B and coming back to A\n * lands you at the top of A, having forgotten. Nobody wants that, and no editor\n * outside this package does it.\n */\n/**\n * A project in the Open Recent menu.\n *\n * The NAME is here rather than being derived from the path, because a project's\n * display name is what its file says it is, not what its folder is called: two\n * projects can sit in folders called `draft`, and one can be renamed without\n * moving. Every app in the family shows this menu, so both halves are shared.\n *\n * Optional because the path is the identity and the name is only how it reads.\n * An entry recorded before a name was known still works.\n */\nexport interface RecentProject {\n path: string;\n name?: string;\n}\n\nexport interface AppSettingsCore<Place> {\n recents: RecentProject[];\n lastProject?: string;\n /** Project path -> where the author was in it. Capped with `recents`, so a\n * project forgotten from the list stops carrying a place around too. */\n places: Record<string, Place>;\n panes: PaneState;\n identity?: Identity;\n /** Keyed by whatever the app calls each window (\"board\", \"search\"). */\n windows: Record<string, WindowState>;\n}\n\nexport type AppSettings<Place, App> = AppSettingsCore<Place> & { app: App };\n\nexport interface AppStoreOptions<Place, App> {\n /** The app's userData directory. */\n dir: string;\n /** The file inside it. One per app; two apps never share. */\n fileName?: string;\n /** The app's own slice, as it should read on a first run. */\n defaults: App;\n /** How the panes should sit before anybody touches them. */\n panes?: PaneState;\n maxRecents?: number;\n}\n\nexport interface AppStore<Place, App> {\n /** A copy, always: nothing outside can mutate the state behind the store's back. */\n get(): AppSettings<Place, App>;\n /** Merge into the app's own slice and save. */\n patchApp(patch: Partial<App>): void;\n /** Record an opened project: front of recents, deduped, capped. The name is\n * refreshed each time, so renaming a project updates the menu on next open;\n * omitting it keeps whatever was known before rather than blanking it. */\n touchProject(path: string, name?: string): void;\n /** Drop one that failed to open (moved, deleted). */\n forgetProject(path: string): void;\n /** Where the author was in the CURRENT project. Compared before writing: this\n * is called on every navigation and must not mean a disk write per click.\n * A no-op with no project open, since there is nothing to key it against. */\n setPlace(place: Place): void;\n /** Where the author was in a project, or undefined if it was never opened or\n * has aged out of `recents`. Defaults to the current one. */\n placeOf(path?: string): Place | undefined;\n setPanes(panes: PaneState): void;\n setIdentity(identity: Identity): void;\n /** One window's bounds and/or pin, merged with what it already had. */\n setWindow(key: string, state: WindowState): void;\n}\n\nexport function createAppStore<Place, App extends object>(\n opts: AppStoreOptions<Place, App>,\n): AppStore<Place, App> {\n const file = join(opts.dir, opts.fileName ?? \"app-settings.json\");\n const maxRecents = opts.maxRecents ?? 8;\n\n let loaded: Partial<AppSettings<Place, App>> = {};\n try {\n loaded = JSON.parse(readFileSync(file, \"utf8\")) as Partial<AppSettings<Place, App>>;\n } catch {\n // First run, or a file we cannot read. Defaults, never a crash: settings\n // are not worth failing to start over.\n }\n\n const state: AppSettings<Place, App> = {\n panes: opts.panes ?? {},\n windows: {},\n ...loaded,\n // Migration: recents used to be bare paths. Keep them, without names, rather\n // than emptying somebody's Open Recent menu on an update.\n recents: (loaded.recents ?? []).map((r) =>\n typeof r === \"string\" ? { path: r } : r) as RecentProject[],\n // Migration: a settings file written before places were keyed by project has\n // a single `place` belonging to `lastProject`. Carry it over rather than\n // dropping it, or everyone's first launch after this update forgets where\n // they were, which is the exact complaint the change is meant to fix.\n places: {\n ...(loaded.places ?? {}),\n ...(loaded.lastProject !== undefined && (loaded as { place?: Place }).place !== undefined\n ? { [loaded.lastProject]: (loaded as { place?: Place }).place as Place }\n : {}),\n },\n // The app's slice merges FIELD BY FIELD, so a key added in a new version\n // arrives with its default rather than being absent for everybody who has\n // run the app before.\n app: { ...opts.defaults, ...(loaded.app ?? {}) },\n };\n\n const save = (): void => {\n try {\n mkdirSync(opts.dir, { recursive: true });\n // Write beside, then rename: a rename is atomic on every platform this\n // ships to, so a crash mid-write cannot leave a half-written settings\n // file - which both apps' hand-written stores could.\n const temp = `${file}.tmp`;\n writeFileSync(temp, `${JSON.stringify(state, null, 2)}\\n`, \"utf8\");\n renameSync(temp, file);\n } catch {\n // A read-only home directory, a full disk: the app carries on with the\n // settings it has in memory rather than dying over a preference.\n }\n };\n\n return {\n get: () => structuredClone(state),\n patchApp(patch) {\n Object.assign(state.app, patch);\n save();\n },\n touchProject(path, name) {\n const known = state.recents.find((r) => r.path === path);\n // An omitted name keeps the one we had: reopening from a route that does\n // not know the name must not blank the menu entry.\n const entry: RecentProject = { path, ...(name ?? known?.name ? { name: name ?? known?.name } : {}) };\n state.recents = [entry, ...state.recents.filter((r) => r.path !== path)].slice(0, maxRecents);\n state.lastProject = path;\n // Places follow recents: one that has aged off the list should not keep a\n // place in the file for ever. Nothing is dropped for merely opening\n // something else, which is the whole point of keying them.\n for (const key of Object.keys(state.places)) {\n if (!state.recents.some((r) => r.path === key)) delete state.places[key];\n }\n save();\n },\n forgetProject(path) {\n state.recents = state.recents.filter((r) => r.path !== path);\n if (state.lastProject === path) delete state.lastProject;\n delete state.places[path]; // it moved or went: its place is meaningless now\n save();\n },\n setPlace(place) {\n const path = state.lastProject;\n if (path === undefined) return; // no project open: nothing to key it against\n if (JSON.stringify(state.places[path]) === JSON.stringify(place)) return;\n state.places[path] = place;\n save();\n },\n placeOf: (path) => {\n const key = path ?? state.lastProject;\n return key === undefined ? undefined : structuredClone(state.places[key]);\n },\n setPanes(panes) {\n state.panes = { ...state.panes, ...panes };\n save();\n },\n setIdentity(identity) {\n state.identity = identity;\n save();\n },\n setWindow(key, next) {\n state.windows[key] = { ...state.windows[key], ...next };\n save();\n },\n };\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/app-store.ts"],"names":[],"mappings":"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","file":"app-store.js","sourcesContent":["// ---------------------------------------------------------------------------\n// The user's settings for THIS app: a small JSON file in the app's userData.\n//\n// Every app in the family has one, and both of the two that exist wrote it\n// separately: the same tolerant read, the same merged defaults, the same capped\n// recents, the same panes, the same per-tool-window bounds and pin, the same\n// identity. That is the definition of shell work.\n//\n// WHAT BELONGS HERE is settings that follow the PERSON, not the project: where\n// their windows were, which panes they like open, what they are called, what\n// they had open last. Project content never comes near this file - it is not\n// version controlled, it is not shared, and losing it should cost an author\n// nothing but a re-arranged window.\n//\n// The shell owns the CORE it can reason about and hands the app one slice of\n// its own (`app`) for everything else - a theme value, a view mode, whatever it\n// has. Two stores in one file, one atomic write, so a crash cannot leave an app\n// with half its settings.\n//\n// Electron-free on purpose: it takes a directory. That keeps it unit-testable\n// and lets a test point it at a temp dir.\n// ---------------------------------------------------------------------------\n\nimport { mkdirSync, readFileSync, renameSync, writeFileSync } from \"node:fs\";\nimport { join } from \"node:path\";\n\n/** Who is at the keyboard. Never in a project: see identity.ts. */\nexport interface Identity { name: string; email?: string }\n\n/** A remembered window rectangle. Absent = the app's default, centred. */\nexport interface WindowBounds { x?: number; y?: number; width: number; height: number }\n\n/** One helper window's remembered state. */\nexport interface WindowState { bounds?: WindowBounds; pinned?: boolean }\n\n/** Side panes: open/closed, and any dragged width. */\nexport interface PaneState {\n nav?: boolean;\n inspector?: boolean;\n navW?: number;\n inspW?: number;\n}\n\n/**\n * The part every app has.\n *\n * `places` is deliberately opaque: \"open where you left off\" is a family idea,\n * but WHERE is a scene and a caret in one app and a box, a tab and a card in\n * another.\n *\n * It is keyed BY PROJECT, and that is the interesting part. This held a single\n * `place` and dropped it whenever a different project was opened, on the\n * reasoning that clearing it was the one rule that could not be app-specific.\n * That reasoning was wrong, and Patterpad disproved it by having done the other\n * thing for a long time: it remembers the line you were on in every project you\n * have opened. With one slot, working on A, glancing at B and coming back to A\n * lands you at the top of A, having forgotten. Nobody wants that, and no editor\n * outside this package does it.\n */\n/**\n * A project in the Open Recent menu.\n *\n * The NAME is here rather than being derived from the path, because a project's\n * display name is what its file says it is, not what its folder is called: two\n * projects can sit in folders called `draft`, and one can be renamed without\n * moving. Every app in the family shows this menu, so both halves are shared.\n *\n * Optional because the path is the identity and the name is only how it reads.\n * An entry recorded before a name was known still works.\n */\nexport interface RecentProject {\n path: string;\n name?: string;\n}\n\nexport interface AppSettingsCore<Place> {\n recents: RecentProject[];\n lastProject?: string;\n /** Project path -> where the author was in it. Capped with `recents`, so a\n * project forgotten from the list stops carrying a place around too. */\n places: Record<string, Place>;\n panes: PaneState;\n identity?: Identity;\n /** Keyed by whatever the app calls each window (\"board\", \"search\"). */\n windows: Record<string, WindowState>;\n}\n\nexport type AppSettings<Place, App> = AppSettingsCore<Place> & { app: App };\n\nexport interface AppStoreOptions<Place, App> {\n /** The app's userData directory. */\n dir: string;\n /** The file inside it. One per app; two apps never share. */\n fileName?: string;\n /** The app's own slice, as it should read on a first run. */\n defaults: App;\n /** How the panes should sit before anybody touches them. */\n panes?: PaneState;\n maxRecents?: number;\n}\n\nexport interface AppStore<Place, App> {\n /** A copy, always: nothing outside can mutate the state behind the store's back. */\n get(): AppSettings<Place, App>;\n /** Merge into the app's own slice and save. */\n patchApp(patch: Partial<App>): void;\n /** Record an opened project: front of recents, deduped, capped. The name is\n * refreshed each time, so renaming a project updates the menu on next open;\n * omitting it keeps whatever was known before rather than blanking it. */\n touchProject(path: string, name?: string): void;\n /** Drop one that failed to open (moved, deleted). */\n forgetProject(path: string): void;\n /** Where the author was in the CURRENT project. Compared before writing: this\n * is called on every navigation and must not mean a disk write per click.\n * A no-op with no project open, since there is nothing to key it against. */\n setPlace(place: Place): void;\n /** Where the author was in a project, or undefined if it was never opened or\n * has aged out of `recents`. Defaults to the current one. */\n placeOf(path?: string): Place | undefined;\n setPanes(panes: PaneState): void;\n setIdentity(identity: Identity): void;\n /** One window's bounds and/or pin, merged with what it already had. */\n setWindow(key: string, state: WindowState): void;\n}\n\nexport function createAppStore<Place, App extends object>(\n opts: AppStoreOptions<Place, App>,\n): AppStore<Place, App> {\n const file = join(opts.dir, opts.fileName ?? \"app-settings.json\");\n const maxRecents = opts.maxRecents ?? 8;\n\n let loaded: Partial<AppSettings<Place, App>> = {};\n try {\n loaded = JSON.parse(readFileSync(file, \"utf8\")) as Partial<AppSettings<Place, App>>;\n } catch {\n // First run, or a file we cannot read. Defaults, never a crash: settings\n // are not worth failing to start over.\n }\n\n const state: AppSettings<Place, App> = {\n panes: opts.panes ?? {},\n windows: {},\n ...loaded,\n // Migration: recents used to be bare paths. Keep them, without names, rather\n // than emptying somebody's Open Recent menu on an update.\n recents: (loaded.recents ?? []).map((r) =>\n typeof r === \"string\" ? { path: r } : r) as RecentProject[],\n // Migration: a settings file written before places were keyed by project has\n // a single `place` belonging to `lastProject`. Carry it over rather than\n // dropping it, or everyone's first launch after this update forgets where\n // they were - but only where no keyed entry exists. The first cut of this\n // merge put the legacy value LAST, so a stale singular beat every fresh\n // per-project write on every launch, and (because the `...loaded` spread\n // above kept the key alive through save) it did so for ever: writes landed\n // correctly all session and the next start quietly reset them. Found from\n // a host's user report; the key is retired just below.\n places: {\n ...(loaded.lastProject !== undefined && (loaded as { place?: Place }).place !== undefined\n ? { [loaded.lastProject]: (loaded as { place?: Place }).place as Place }\n : {}),\n ...(loaded.places ?? {}),\n },\n // The app's slice merges FIELD BY FIELD, so a key added in a new version\n // arrives with its default rather than being absent for everybody who has\n // run the app before.\n app: { ...opts.defaults, ...(loaded.app ?? {}) },\n };\n // Retire the legacy key: it rode in on `...loaded`, and a state that still\n // carries it re-runs the fold on every future load.\n delete (state as { place?: Place }).place;\n\n const save = (): void => {\n try {\n mkdirSync(opts.dir, { recursive: true });\n // Write beside, then rename: a rename is atomic on every platform this\n // ships to, so a crash mid-write cannot leave a half-written settings\n // file - which both apps' hand-written stores could.\n const temp = `${file}.tmp`;\n writeFileSync(temp, `${JSON.stringify(state, null, 2)}\\n`, \"utf8\");\n renameSync(temp, file);\n } catch {\n // A read-only home directory, a full disk: the app carries on with the\n // settings it has in memory rather than dying over a preference.\n }\n };\n\n return {\n get: () => structuredClone(state),\n patchApp(patch) {\n Object.assign(state.app, patch);\n save();\n },\n touchProject(path, name) {\n const known = state.recents.find((r) => r.path === path);\n // An omitted name keeps the one we had: reopening from a route that does\n // not know the name must not blank the menu entry.\n const entry: RecentProject = { path, ...(name ?? known?.name ? { name: name ?? known?.name } : {}) };\n state.recents = [entry, ...state.recents.filter((r) => r.path !== path)].slice(0, maxRecents);\n state.lastProject = path;\n // Places follow recents: one that has aged off the list should not keep a\n // place in the file for ever. Nothing is dropped for merely opening\n // something else, which is the whole point of keying them.\n for (const key of Object.keys(state.places)) {\n if (!state.recents.some((r) => r.path === key)) delete state.places[key];\n }\n save();\n },\n forgetProject(path) {\n state.recents = state.recents.filter((r) => r.path !== path);\n if (state.lastProject === path) delete state.lastProject;\n delete state.places[path]; // it moved or went: its place is meaningless now\n save();\n },\n setPlace(place) {\n const path = state.lastProject;\n if (path === undefined) return; // no project open: nothing to key it against\n if (JSON.stringify(state.places[path]) === JSON.stringify(place)) return;\n state.places[path] = place;\n save();\n },\n placeOf: (path) => {\n const key = path ?? state.lastProject;\n return key === undefined ? undefined : structuredClone(state.places[key]);\n },\n setPanes(panes) {\n state.panes = { ...state.panes, ...panes };\n save();\n },\n setIdentity(identity) {\n state.identity = identity;\n save();\n },\n setWindow(key, next) {\n state.windows[key] = { ...state.windows[key], ...next };\n save();\n },\n };\n}\n"]}
|
package/dist/index.cjs
CHANGED
|
@@ -180,8 +180,13 @@ function iconBtn(glyph, title, onClick, disabled = false, danger = false) {
|
|
|
180
180
|
return b;
|
|
181
181
|
}
|
|
182
182
|
function labelled(label2, control) {
|
|
183
|
-
const
|
|
183
|
+
const target = control.matches("input, select, textarea") ? control : control.querySelector("input:not([type=button]):not([type=submit]), select, textarea");
|
|
184
|
+
const w = el(target ? "label" : "div", "shell-labelled");
|
|
184
185
|
w.append(el("span", "shell-fieldcap", label2), control);
|
|
186
|
+
if (target) {
|
|
187
|
+
if (!target.id) target.id = `shell-f-${Math.random().toString(36).slice(2, 9)}`;
|
|
188
|
+
w.htmlFor = target.id;
|
|
189
|
+
}
|
|
185
190
|
return w;
|
|
186
191
|
}
|
|
187
192
|
function moveItem(arr, i, delta) {
|
|
@@ -988,8 +993,11 @@ function bindPropertyRef(input, onCommit, opts) {
|
|
|
988
993
|
const list = document.createElement("datalist");
|
|
989
994
|
list.id = `shell-propref-${Math.random().toString(36).slice(2, 9)}`;
|
|
990
995
|
input.setAttribute("list", list.id);
|
|
991
|
-
|
|
996
|
+
const attach = () => {
|
|
997
|
+
if (!list.isConnected && input.parentNode) input.after(list);
|
|
998
|
+
};
|
|
992
999
|
const sync = () => {
|
|
1000
|
+
attach();
|
|
993
1001
|
const known = opts.known();
|
|
994
1002
|
list.replaceChildren(...known.map((k) => {
|
|
995
1003
|
const o = document.createElement("option");
|