@wildwinter/app-shell 0.34.1 → 0.35.1
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 +5 -0
- package/dist/app-store.cjs.map +1 -1
- package/dist/app-store.d.cts +3 -0
- package/dist/app-store.d.ts +3 -0
- package/dist/app-store.js +5 -0
- package/dist/app-store.js.map +1 -1
- package/dist/session.cjs +8 -0
- package/dist/session.cjs.map +1 -1
- package/dist/session.d.cts +10 -0
- package/dist/session.d.ts +10 -0
- package/dist/session.js +8 -0
- package/dist/session.js.map +1 -1
- package/dist/tool-window.cjs +28 -1
- package/dist/tool-window.cjs.map +1 -1
- package/dist/tool-window.d.cts +24 -13
- package/dist/tool-window.d.ts +24 -13
- package/dist/tool-window.js +29 -2
- package/dist/tool-window.js.map +1 -1
- package/package.json +1 -1
package/dist/app-store.cjs
CHANGED
|
@@ -70,6 +70,11 @@ function createAppStore(opts) {
|
|
|
70
70
|
delete state.places[path];
|
|
71
71
|
save();
|
|
72
72
|
},
|
|
73
|
+
clearLastProject() {
|
|
74
|
+
if (state.lastProject === void 0) return;
|
|
75
|
+
delete state.lastProject;
|
|
76
|
+
save();
|
|
77
|
+
},
|
|
73
78
|
setPlace(place) {
|
|
74
79
|
const path = state.lastProject;
|
|
75
80
|
if (path === void 0) return;
|
package/dist/app-store.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/app-store.ts"],"names":["join","readFileSync","mkdirSync","writeFileSync","renameSync"],"mappings":";;;;;;AA6HO,SAAS,eACd,IAAA,EACsB;AACtB,EAAA,MAAM,OAAOA,SAAA,CAAK,IAAA,CAAK,GAAA,EAAK,IAAA,CAAK,YAAY,mBAAmB,CAAA;AAChE,EAAA,MAAM,UAAA,GAAa,KAAK,UAAA,IAAc,CAAA;AAEtC,EAAA,IAAI,SAA2C,EAAC;AAChD,EAAA,IAAI;AACF,IAAA,MAAA,GAAS,IAAA,CAAK,KAAA,CAAMC,eAAA,CAAa,IAAA,EAAM,MAAM,CAAC,CAAA;AAAA,EAChD,CAAA,CAAA,MAAQ;AAAA,EAGR;AAEA,EAAA,MAAM,KAAA,GAAiC;AAAA,IACrC,KAAA,EAAO,IAAA,CAAK,KAAA,IAAS,EAAC;AAAA,IACtB,SAAS,EAAC;AAAA,IACV,GAAG,MAAA;AAAA;AAAA;AAAA,IAGH,OAAA,EAAA,CAAU,MAAA,CAAO,OAAA,IAAW,IAAI,GAAA,CAAI,CAAC,CAAA,KACnC,OAAO,MAAM,QAAA,GAAW,EAAE,IAAA,EAAM,CAAA,KAAM,CAAC,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAUzC,MAAA,EAAQ;AAAA,MACN,GAAI,MAAA,CAAO,WAAA,KAAgB,MAAA,IAAc,OAA6B,KAAA,KAAU,MAAA,GAC5E,EAAE,CAAC,OAAO,WAAW,GAAI,MAAA,CAA6B,KAAA,KACtD,EAAC;AAAA,MACL,GAAI,MAAA,CAAO,MAAA,IAAU;AAAC,KACxB;AAAA;AAAA;AAAA;AAAA,IAIA,GAAA,EAAK,EAAE,GAAG,IAAA,CAAK,UAAU,GAAI,MAAA,CAAO,GAAA,IAAO,EAAC;AAAG,GACjD;AAGA,EAAA,OAAQ,KAAA,CAA4B,KAAA;AAEpC,EAAA,MAAM,OAAO,MAAY;AACvB,IAAA,IAAI;AACF,MAAAC,YAAA,CAAU,IAAA,CAAK,GAAA,EAAK,EAAE,SAAA,EAAW,MAAM,CAAA;AAIvC,MAAA,MAAM,IAAA,GAAO,GAAG,IAAI,CAAA,IAAA,CAAA;AACpB,MAAAC,gBAAA,CAAc,MAAM,CAAA,EAAG,IAAA,CAAK,UAAU,KAAA,EAAO,IAAA,EAAM,CAAC,CAAC;AAAA,CAAA,EAAM,MAAM,CAAA;AACjE,MAAAC,aAAA,CAAW,MAAM,IAAI,CAAA;AAAA,IACvB,CAAA,CAAA,MAAQ;AAAA,IAGR;AAAA,EACF,CAAA;AAEA,EAAA,OAAO;AAAA,IACL,GAAA,EAAK,MAAM,eAAA,CAAgB,KAAK,CAAA;AAAA,IAChC,SAAS,KAAA,EAAO;AACd,MAAA,MAAA,CAAO,MAAA,CAAO,KAAA,CAAM,GAAA,EAAK,KAAK,CAAA;AAC9B,MAAA,IAAA,EAAK;AAAA,IACP,CAAA;AAAA,IACA,YAAA,CAAa,MAAM,IAAA,EAAM;AACvB,MAAA,MAAM,KAAA,GAAQ,MAAM,OAAA,CAAQ,IAAA,CAAK,CAAC,CAAA,KAAM,CAAA,CAAE,SAAS,IAAI,CAAA;AAGvD,MAAA,MAAM,KAAA,GAAuB,EAAE,IAAA,EAAM,GAAI,QAAQ,KAAA,EAAO,IAAA,GAAO,EAAE,IAAA,EAAM,IAAA,IAAQ,KAAA,EAAO,IAAA,EAAK,GAAI,EAAC,EAAG;AACnG,MAAA,KAAA,CAAM,UAAU,CAAC,KAAA,EAAO,GAAG,KAAA,CAAM,QAAQ,MAAA,CAAO,CAAC,CAAA,KAAM,CAAA,CAAE,SAAS,IAAI,CAAC,CAAA,CAAE,KAAA,CAAM,GAAG,UAAU,CAAA;AAC5F,MAAA,KAAA,CAAM,WAAA,GAAc,IAAA;AAIpB,MAAA,KAAA,MAAW,GAAA,IAAO,MAAA,CAAO,IAAA,CAAK,KAAA,CAAM,MAAM,CAAA,EAAG;AAC3C,QAAA,IAAI,CAAC,KAAA,CAAM,OAAA,CAAQ,IAAA,CAAK,CAAC,CAAA,KAAM,CAAA,CAAE,IAAA,KAAS,GAAG,CAAA,EAAG,OAAO,KAAA,CAAM,OAAO,GAAG,CAAA;AAAA,MACzE;AACA,MAAA,IAAA,EAAK;AAAA,IACP,CAAA;AAAA,IACA,cAAc,IAAA,EAAM;AAClB,MAAA,KAAA,CAAM,OAAA,GAAU,MAAM,OAAA,CAAQ,MAAA,CAAO,CAAC,CAAA,KAAM,CAAA,CAAE,SAAS,IAAI,CAAA;AAC3D,MAAA,IAAI,KAAA,CAAM,WAAA,KAAgB,IAAA,EAAM,OAAO,KAAA,CAAM,WAAA;AAC7C,MAAA,OAAO,KAAA,CAAM,OAAO,IAAI,CAAA;AACxB,MAAA,IAAA,EAAK;AAAA,IACP,CAAA;AAAA,IACA,SAAS,KAAA,EAAO;AACd,MAAA,MAAM,OAAO,KAAA,CAAM,WAAA;AACnB,MAAA,IAAI,SAAS,MAAA,EAAW;AACxB,MAAA,IAAI,IAAA,CAAK,SAAA,CAAU,KAAA,CAAM,MAAA,CAAO,IAAI,CAAC,CAAA,KAAM,IAAA,CAAK,SAAA,CAAU,KAAK,CAAA,EAAG;AAClE,MAAA,KAAA,CAAM,MAAA,CAAO,IAAI,CAAA,GAAI,KAAA;AACrB,MAAA,IAAA,EAAK;AAAA,IACP,CAAA;AAAA,IACA,OAAA,EAAS,CAAC,IAAA,KAAS;AACjB,MAAA,MAAM,GAAA,GAAM,QAAQ,KAAA,CAAM,WAAA;AAC1B,MAAA,OAAO,QAAQ,MAAA,GAAY,MAAA,GAAY,gBAAgB,KAAA,CAAM,MAAA,CAAO,GAAG,CAAC,CAAA;AAAA,IAC1E,CAAA;AAAA,IACA,SAAS,KAAA,EAAO;AACd,MAAA,KAAA,CAAM,QAAQ,EAAE,GAAG,KAAA,CAAM,KAAA,EAAO,GAAG,KAAA,EAAM;AACzC,MAAA,IAAA,EAAK;AAAA,IACP,CAAA;AAAA,IACA,YAAY,QAAA,EAAU;AACpB,MAAA,KAAA,CAAM,QAAA,GAAW,QAAA;AACjB,MAAA,IAAA,EAAK;AAAA,IACP,CAAA;AAAA,IACA,SAAA,CAAU,KAAK,IAAA,EAAM;AACnB,MAAA,KAAA,CAAM,OAAA,CAAQ,GAAG,CAAA,GAAI,EAAE,GAAG,MAAM,OAAA,CAAQ,GAAG,CAAA,EAAG,GAAG,IAAA,EAAK;AACtD,MAAA,IAAA,EAAK;AAAA,IACP;AAAA,GACF;AACF","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"]}
|
|
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 /** Forget WHICH project was open, keeping it in recents: Close Project's\n * half of the memory, so the next boot lands on the welcome screen. */\n clearLastProject(): 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 clearLastProject() {\n if (state.lastProject === undefined) return;\n delete state.lastProject;\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.d.cts
CHANGED
|
@@ -89,6 +89,9 @@ interface AppStore<Place, App> {
|
|
|
89
89
|
touchProject(path: string, name?: string): void;
|
|
90
90
|
/** Drop one that failed to open (moved, deleted). */
|
|
91
91
|
forgetProject(path: string): void;
|
|
92
|
+
/** Forget WHICH project was open, keeping it in recents: Close Project's
|
|
93
|
+
* half of the memory, so the next boot lands on the welcome screen. */
|
|
94
|
+
clearLastProject(): void;
|
|
92
95
|
/** Where the author was in the CURRENT project. Compared before writing: this
|
|
93
96
|
* is called on every navigation and must not mean a disk write per click.
|
|
94
97
|
* A no-op with no project open, since there is nothing to key it against. */
|
package/dist/app-store.d.ts
CHANGED
|
@@ -89,6 +89,9 @@ interface AppStore<Place, App> {
|
|
|
89
89
|
touchProject(path: string, name?: string): void;
|
|
90
90
|
/** Drop one that failed to open (moved, deleted). */
|
|
91
91
|
forgetProject(path: string): void;
|
|
92
|
+
/** Forget WHICH project was open, keeping it in recents: Close Project's
|
|
93
|
+
* half of the memory, so the next boot lands on the welcome screen. */
|
|
94
|
+
clearLastProject(): void;
|
|
92
95
|
/** Where the author was in the CURRENT project. Compared before writing: this
|
|
93
96
|
* is called on every navigation and must not mean a disk write per click.
|
|
94
97
|
* A no-op with no project open, since there is nothing to key it against. */
|
package/dist/app-store.js
CHANGED
|
@@ -68,6 +68,11 @@ function createAppStore(opts) {
|
|
|
68
68
|
delete state.places[path];
|
|
69
69
|
save();
|
|
70
70
|
},
|
|
71
|
+
clearLastProject() {
|
|
72
|
+
if (state.lastProject === void 0) return;
|
|
73
|
+
delete state.lastProject;
|
|
74
|
+
save();
|
|
75
|
+
},
|
|
71
76
|
setPlace(place) {
|
|
72
77
|
const path = state.lastProject;
|
|
73
78
|
if (path === void 0) return;
|
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 - 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"]}
|
|
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 /** Forget WHICH project was open, keeping it in recents: Close Project's\n * half of the memory, so the next boot lands on the welcome screen. */\n clearLastProject(): 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 clearLastProject() {\n if (state.lastProject === undefined) return;\n delete state.lastProject;\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/session.cjs
CHANGED
|
@@ -29,6 +29,14 @@ function createProjectSession(opts) {
|
|
|
29
29
|
opts.refreshMenu?.();
|
|
30
30
|
return opened.reply;
|
|
31
31
|
},
|
|
32
|
+
closeCurrent() {
|
|
33
|
+
if (session === void 0) return;
|
|
34
|
+
opts.close?.(session);
|
|
35
|
+
session = void 0;
|
|
36
|
+
opts.store.clearLastProject?.();
|
|
37
|
+
invalidateSatellites();
|
|
38
|
+
opts.refreshMenu?.();
|
|
39
|
+
},
|
|
32
40
|
isKnownPath(path$1) {
|
|
33
41
|
const s = opts.store.get();
|
|
34
42
|
const paths = [s.lastProject, ...s.recents.map((r) => r.path)];
|
package/dist/session.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/session.ts"],"names":["path","resolve"],"mappings":";;;;;AAgHO,SAAS,qBAA2B,IAAA,EAAyD;AAClG,EAAA,IAAI,OAAA;AACJ,EAAA,MAAM,aAA0B,CAAC,GAAI,IAAA,CAAK,UAAA,IAAc,EAAG,CAAA;AAE3D,EAAA,MAAM,uBAAuB,MAAY;AACvC,IAAA,KAAA,MAAW,OAAO,UAAA,EAAY;AAC5B,MAAA,GAAA,CAAI,KAAA,IAAQ;AACZ,MAAA,MAAM,GAAA,GAAM,IAAI,MAAA,EAAO;AACvB,MAAA,IAAI,GAAA,IAAO,CAAC,GAAA,CAAI,WAAA,IAAe,GAAA,CAAI,WAAA,CAAY,IAAA,CAAK,GAAA,CAAI,OAAO,CAAA;AAAA,IACjE;AAAA,EACF,CAAA;AAEA,EAAA,OAAO;AAAA,IACL,SAAS,MAAM,OAAA;AAAA,IAEf,OAAO,IAAA,EAAM;AACX,MAAA,MAAM,MAAA,GAAS,IAAA,CAAK,IAAA,CAAK,IAAI,CAAA;AAC7B,MAAA,IAAI,WAAW,MAAA,EAAQ;AACrB,QAAA,IAAA,CAAK,KAAA,CAAM,cAAc,IAAI,CAAA;AAC7B,QAAA,IAAA,CAAK,WAAA,IAAc;AACnB,QAAA,OAAO,MAAA;AAAA,MACT;AACA,MAAA,IAAI,OAAA,KAAY,MAAA,EAAW,IAAA,CAAK,KAAA,GAAQ,OAAO,CAAA;AAC/C,MAAA,OAAA,GAAU,MAAA,CAAO,OAAA;AACjB,MAAA,IAAA,CAAK,KAAA,CAAM,YAAA,CAAa,MAAA,CAAO,IAAA,EAAM,OAAO,IAAI,CAAA;AAChD,MAAA,oBAAA,EAAqB;AACrB,MAAA,IAAA,CAAK,WAAA,IAAc;AACnB,MAAA,OAAO,MAAA,CAAO,KAAA;AAAA,IAChB,CAAA;AAAA,IAEA,YAAYA,MAAA,EAAM;AAChB,MAAA,MAAM,CAAA,GAAI,IAAA,CAAK,KAAA,CAAM,GAAA,EAAI;AAQzB,MAAA,MAAM,KAAA,GAAQ,CAAC,CAAA,CAAE,WAAA,EAAa,GAAG,CAAA,CAAE,OAAA,CAAQ,GAAA,CAAI,CAAC,CAAA,KAAM,CAAA,CAAE,IAAI,CAAC,CAAA;AAC7D,MAAA,MAAM,QAAQ,IAAI,GAAA,CAAI,KAAA,CAAM,MAAA,CAAO,CAAC,CAAA,KAAmB,CAAC,CAAC,CAAC,EAAE,GAAA,CAAI,CAAC,MAAMC,YAAA,CAAQ,CAAC,CAAC,CAAC,CAAA;AAClF,MAAA,OAAO,KAAA,CAAM,GAAA,CAAIA,YAAA,CAAQD,MAAI,CAAC,CAAA;AAAA,IAChC,CAAA;AAAA,IAEA,aAAa,SAAA,EAAW;AACtB,MAAA,UAAA,CAAW,KAAK,SAAS,CAAA;AACzB,MAAA,OAAO,MAAM;AACX,QAAA,MAAM,CAAA,GAAI,UAAA,CAAW,OAAA,CAAQ,SAAS,CAAA;AACtC,QAAA,IAAI,CAAA,IAAK,CAAA,EAAG,UAAA,CAAW,MAAA,CAAO,GAAG,CAAC,CAAA;AAAA,MACpC,CAAA;AAAA,IACF,CAAA;AAAA,IAEA;AAAA,GACF;AACF","file":"session.cjs","sourcesContent":["// ---------------------------------------------------------------------------\n// The project session: what happens around opening a project, as opposed to\n// the opening itself.\n//\n// Both apps have a function - `openAt` here, `openAndRecord` there - and under\n// two different names they do the same six things in the same order:\n//\n// 1. Ask the app to open it, which is the only app-specific step.\n// 2. On failure, drop it from recents (a project that has moved should stop\n// being offered) and rebuild the menu.\n// 3. On success, finish with the outgoing session.\n// 4. Record the opened ROOT in the store, which also clears the remembered\n// place when it is a different project.\n// 5. Invalidate everything main was caching FOR THE OLD PROJECT, and tell\n// each satellite tool window that the ground moved.\n// 6. Rebuild the menu, so File > Open Recent is current.\n//\n// Step 5 is the one worth extracting on its own. Both apps hand-wire it per\n// window - a cached result nulled, then `if (win && !win.isDestroyed())\n// win.webContents.send(...)` - Storyletter three times, Patterpad twice, and\n// every new tool window is another chance to forget one and leave a window\n// describing a project that is no longer open. Registering a satellite once\n// makes forgetting it impossible.\n//\n// It also carries `isKnownPath`, which only Patterpad had and which is a real\n// guard rather than a tidiness: a renderer must not be able to name an\n// arbitrary directory and have main open it. Fresh paths enter through the\n// native dialogs, which run in main where the person actually chose them;\n// everything else must already be in recents.\n//\n// Electron-free: a satellite is anything with the two methods it needs, which\n// a BrowserWindow satisfies and a test can fake.\n// ---------------------------------------------------------------------------\n\nimport { resolve } from \"node:path\";\nimport type { RecentProject } from \"./app-store.js\";\n\n/** As much of a BrowserWindow as this file has any business knowing. */\nexport interface SatelliteWindow {\n isDestroyed(): boolean;\n webContents: { send(channel: string, ...args: unknown[]): void };\n}\n\n/**\n * A tool window that shows something about the open project, and any state\n * main is holding on its behalf.\n */\nexport interface Satellite {\n /** Looked up each time rather than held: these windows open and close. */\n window: () => SatelliteWindow | undefined | null;\n /** What to send when the project changes. The window re-fetches; it is a\n * nudge, not a payload, because by then the new project is already open. */\n channel: string;\n /** Drop what main was caching for the old project - a coverage report, a\n * search anchor. Called before the window is told. */\n clear?: () => void;\n}\n\n/** The slice of the app store this needs. Structural, so the real store fits\n * without dragging its generic parameters in here. */\nexport interface SessionStore {\n get(): { recents: RecentProject[]; lastProject?: string };\n /** The name is what the app just learned by opening the project. Omitting it\n * keeps whatever was known, which is `createAppStore`'s rule. */\n touchProject(path: string, name?: string): void;\n forgetProject(path: string): void;\n}\n\nexport interface ProjectSessionOptions<S, R> {\n store: SessionStore;\n /**\n * Open it. The one app-specific step.\n *\n * `root` is what gets recorded, and it is not always the path asked for: a\n * file-association launch names a shard INSIDE a project, and it is the\n * project that belongs in recents. `reply` is whatever the app sends back to\n * its renderer - this never looks inside it.\n *\n * `name` is the project's display name, if opening it revealed one. Return it\n * and the session records it with the path; leave it out and whatever was\n * known is kept. This is here so no app has to touch the store a second time\n * just to name what it has already opened.\n */\n open: (path: string) => { session: S; root: string; name?: string; reply: R } | { error: string };\n /**\n * The outgoing session is finished with: sweep its temporary files, close its\n * handles. Called only once the replacement has opened successfully, so a\n * failed open leaves the current project entirely alone.\n */\n close?: (session: S) => void;\n /** Rebuild the application menu. Called after a change either way, because a\n * failed open changes recents too. */\n refreshMenu?: () => void;\n satellites?: Satellite[];\n}\n\nexport interface ProjectSession<S, R> {\n /** The open project, or undefined before the first one. */\n current: () => S | undefined;\n /** Open, with everything in the comment above. */\n openAt: (path: string) => R | { error: string };\n /** May the renderer ask for this path? See the note above: only paths the app\n * already knows, because a native dialog put them there. */\n isKnownPath: (path: string) => boolean;\n /** Register a tool window after construction, which is when they usually\n * appear. Returns a function that unregisters it. */\n addSatellite: (satellite: Satellite) => () => void;\n /** Fire every satellite by hand. For the cases that are not an open but do\n * invalidate the same things - a merge landing new content underneath. */\n invalidateSatellites: () => void;\n}\n\nexport function createProjectSession<S, R>(opts: ProjectSessionOptions<S, R>): ProjectSession<S, R> {\n let session: S | undefined;\n const satellites: Satellite[] = [...(opts.satellites ?? [])];\n\n const invalidateSatellites = (): void => {\n for (const sat of satellites) {\n sat.clear?.();\n const win = sat.window();\n if (win && !win.isDestroyed()) win.webContents.send(sat.channel);\n }\n };\n\n return {\n current: () => session,\n\n openAt(path) {\n const opened = opts.open(path);\n if (\"error\" in opened) {\n opts.store.forgetProject(path);\n opts.refreshMenu?.();\n return opened;\n }\n if (session !== undefined) opts.close?.(session);\n session = opened.session;\n opts.store.touchProject(opened.root, opened.name);\n invalidateSatellites();\n opts.refreshMenu?.();\n return opened.reply;\n },\n\n isKnownPath(path) {\n const s = opts.store.get();\n // Read `path` off each recent EXPLICITLY rather than filtering the list to\n // whatever looks like a string. The old form did the latter, and when\n // recents became objects in 0.25.0 every entry quietly failed the test and\n // was dropped: no error, no empty list to notice, just a launcher that had\n // stopped recognising every project but the last one. A defensive filter is\n // what turns a shape change into a silent behaviour change, so this one\n // states the shape it wants and lets the compiler enforce it.\n const paths = [s.lastProject, ...s.recents.map((r) => r.path)];\n const known = new Set(paths.filter((p): p is string => !!p).map((p) => resolve(p)));\n return known.has(resolve(path));\n },\n\n addSatellite(satellite) {\n satellites.push(satellite);\n return () => {\n const i = satellites.indexOf(satellite);\n if (i >= 0) satellites.splice(i, 1);\n };\n },\n\n invalidateSatellites,\n };\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/session.ts"],"names":["path","resolve"],"mappings":";;;;;AA0HO,SAAS,qBAA2B,IAAA,EAAyD;AAClG,EAAA,IAAI,OAAA;AACJ,EAAA,MAAM,aAA0B,CAAC,GAAI,IAAA,CAAK,UAAA,IAAc,EAAG,CAAA;AAE3D,EAAA,MAAM,uBAAuB,MAAY;AACvC,IAAA,KAAA,MAAW,OAAO,UAAA,EAAY;AAC5B,MAAA,GAAA,CAAI,KAAA,IAAQ;AACZ,MAAA,MAAM,GAAA,GAAM,IAAI,MAAA,EAAO;AACvB,MAAA,IAAI,GAAA,IAAO,CAAC,GAAA,CAAI,WAAA,IAAe,GAAA,CAAI,WAAA,CAAY,IAAA,CAAK,GAAA,CAAI,OAAO,CAAA;AAAA,IACjE;AAAA,EACF,CAAA;AAEA,EAAA,OAAO;AAAA,IACL,SAAS,MAAM,OAAA;AAAA,IAEf,OAAO,IAAA,EAAM;AACX,MAAA,MAAM,MAAA,GAAS,IAAA,CAAK,IAAA,CAAK,IAAI,CAAA;AAC7B,MAAA,IAAI,WAAW,MAAA,EAAQ;AACrB,QAAA,IAAA,CAAK,KAAA,CAAM,cAAc,IAAI,CAAA;AAC7B,QAAA,IAAA,CAAK,WAAA,IAAc;AACnB,QAAA,OAAO,MAAA;AAAA,MACT;AACA,MAAA,IAAI,OAAA,KAAY,MAAA,EAAW,IAAA,CAAK,KAAA,GAAQ,OAAO,CAAA;AAC/C,MAAA,OAAA,GAAU,MAAA,CAAO,OAAA;AACjB,MAAA,IAAA,CAAK,KAAA,CAAM,YAAA,CAAa,MAAA,CAAO,IAAA,EAAM,OAAO,IAAI,CAAA;AAChD,MAAA,oBAAA,EAAqB;AACrB,MAAA,IAAA,CAAK,WAAA,IAAc;AACnB,MAAA,OAAO,MAAA,CAAO,KAAA;AAAA,IAChB,CAAA;AAAA,IAEA,YAAA,GAAe;AACb,MAAA,IAAI,YAAY,MAAA,EAAW;AAC3B,MAAA,IAAA,CAAK,QAAQ,OAAO,CAAA;AACpB,MAAA,OAAA,GAAU,MAAA;AACV,MAAA,IAAA,CAAK,MAAM,gBAAA,IAAmB;AAC9B,MAAA,oBAAA,EAAqB;AACrB,MAAA,IAAA,CAAK,WAAA,IAAc;AAAA,IACrB,CAAA;AAAA,IAEA,YAAYA,MAAA,EAAM;AAChB,MAAA,MAAM,CAAA,GAAI,IAAA,CAAK,KAAA,CAAM,GAAA,EAAI;AAQzB,MAAA,MAAM,KAAA,GAAQ,CAAC,CAAA,CAAE,WAAA,EAAa,GAAG,CAAA,CAAE,OAAA,CAAQ,GAAA,CAAI,CAAC,CAAA,KAAM,CAAA,CAAE,IAAI,CAAC,CAAA;AAC7D,MAAA,MAAM,QAAQ,IAAI,GAAA,CAAI,KAAA,CAAM,MAAA,CAAO,CAAC,CAAA,KAAmB,CAAC,CAAC,CAAC,EAAE,GAAA,CAAI,CAAC,MAAMC,YAAA,CAAQ,CAAC,CAAC,CAAC,CAAA;AAClF,MAAA,OAAO,KAAA,CAAM,GAAA,CAAIA,YAAA,CAAQD,MAAI,CAAC,CAAA;AAAA,IAChC,CAAA;AAAA,IAEA,aAAa,SAAA,EAAW;AACtB,MAAA,UAAA,CAAW,KAAK,SAAS,CAAA;AACzB,MAAA,OAAO,MAAM;AACX,QAAA,MAAM,CAAA,GAAI,UAAA,CAAW,OAAA,CAAQ,SAAS,CAAA;AACtC,QAAA,IAAI,CAAA,IAAK,CAAA,EAAG,UAAA,CAAW,MAAA,CAAO,GAAG,CAAC,CAAA;AAAA,MACpC,CAAA;AAAA,IACF,CAAA;AAAA,IAEA;AAAA,GACF;AACF","file":"session.cjs","sourcesContent":["// ---------------------------------------------------------------------------\n// The project session: what happens around opening a project, as opposed to\n// the opening itself.\n//\n// Both apps have a function - `openAt` here, `openAndRecord` there - and under\n// two different names they do the same six things in the same order:\n//\n// 1. Ask the app to open it, which is the only app-specific step.\n// 2. On failure, drop it from recents (a project that has moved should stop\n// being offered) and rebuild the menu.\n// 3. On success, finish with the outgoing session.\n// 4. Record the opened ROOT in the store, which also clears the remembered\n// place when it is a different project.\n// 5. Invalidate everything main was caching FOR THE OLD PROJECT, and tell\n// each satellite tool window that the ground moved.\n// 6. Rebuild the menu, so File > Open Recent is current.\n//\n// Step 5 is the one worth extracting on its own. Both apps hand-wire it per\n// window - a cached result nulled, then `if (win && !win.isDestroyed())\n// win.webContents.send(...)` - Storyletter three times, Patterpad twice, and\n// every new tool window is another chance to forget one and leave a window\n// describing a project that is no longer open. Registering a satellite once\n// makes forgetting it impossible.\n//\n// It also carries `isKnownPath`, which only Patterpad had and which is a real\n// guard rather than a tidiness: a renderer must not be able to name an\n// arbitrary directory and have main open it. Fresh paths enter through the\n// native dialogs, which run in main where the person actually chose them;\n// everything else must already be in recents.\n//\n// Electron-free: a satellite is anything with the two methods it needs, which\n// a BrowserWindow satisfies and a test can fake.\n// ---------------------------------------------------------------------------\n\nimport { resolve } from \"node:path\";\nimport type { RecentProject } from \"./app-store.js\";\n\n/** As much of a BrowserWindow as this file has any business knowing. */\nexport interface SatelliteWindow {\n isDestroyed(): boolean;\n webContents: { send(channel: string, ...args: unknown[]): void };\n}\n\n/**\n * A tool window that shows something about the open project, and any state\n * main is holding on its behalf.\n */\nexport interface Satellite {\n /** Looked up each time rather than held: these windows open and close. */\n window: () => SatelliteWindow | undefined | null;\n /** What to send when the project changes. The window re-fetches; it is a\n * nudge, not a payload, because by then the new project is already open. */\n channel: string;\n /** Drop what main was caching for the old project - a coverage report, a\n * search anchor. Called before the window is told. */\n clear?: () => void;\n}\n\n/** The slice of the app store this needs. Structural, so the real store fits\n * without dragging its generic parameters in here. */\nexport interface SessionStore {\n get(): { recents: RecentProject[]; lastProject?: string };\n /** The name is what the app just learned by opening the project. Omitting it\n * keeps whatever was known, which is `createAppStore`'s rule. */\n touchProject(path: string, name?: string): void;\n forgetProject(path: string): void;\n /** Forget only WHICH project was open, keeping it in recents. Close Project\n * calls this so a quit after closing boots to the welcome screen instead of\n * silently reopening what the person just closed. Optional: a host without\n * it keeps its boot behaviour. */\n clearLastProject?(): void;\n}\n\nexport interface ProjectSessionOptions<S, R> {\n store: SessionStore;\n /**\n * Open it. The one app-specific step.\n *\n * `root` is what gets recorded, and it is not always the path asked for: a\n * file-association launch names a shard INSIDE a project, and it is the\n * project that belongs in recents. `reply` is whatever the app sends back to\n * its renderer - this never looks inside it.\n *\n * `name` is the project's display name, if opening it revealed one. Return it\n * and the session records it with the path; leave it out and whatever was\n * known is kept. This is here so no app has to touch the store a second time\n * just to name what it has already opened.\n */\n open: (path: string) => { session: S; root: string; name?: string; reply: R } | { error: string };\n /**\n * The outgoing session is finished with: sweep its temporary files, close its\n * handles. Called only once the replacement has opened successfully, so a\n * failed open leaves the current project entirely alone.\n */\n close?: (session: S) => void;\n /** Rebuild the application menu. Called after a change either way, because a\n * failed open changes recents too. */\n refreshMenu?: () => void;\n satellites?: Satellite[];\n}\n\nexport interface ProjectSession<S, R> {\n /** The open project, or undefined before the first one. */\n current: () => S | undefined;\n /** Open, with everything in the comment above. */\n openAt: (path: string) => R | { error: string };\n /** May the renderer ask for this path? See the note above: only paths the app\n * already knows, because a native dialog put them there. */\n isKnownPath: (path: string) => boolean;\n /** Close the open project and return to the no-project state (the welcome\n * screen, in both apps): the close hook runs, the satellites are told, the\n * menu rebuilds, and the store forgets WHICH project was open while keeping\n * it in recents. A no-op when nothing is open. */\n closeCurrent: () => void;\n /** Register a tool window after construction, which is when they usually\n * appear. Returns a function that unregisters it. */\n addSatellite: (satellite: Satellite) => () => void;\n /** Fire every satellite by hand. For the cases that are not an open but do\n * invalidate the same things - a merge landing new content underneath. */\n invalidateSatellites: () => void;\n}\n\nexport function createProjectSession<S, R>(opts: ProjectSessionOptions<S, R>): ProjectSession<S, R> {\n let session: S | undefined;\n const satellites: Satellite[] = [...(opts.satellites ?? [])];\n\n const invalidateSatellites = (): void => {\n for (const sat of satellites) {\n sat.clear?.();\n const win = sat.window();\n if (win && !win.isDestroyed()) win.webContents.send(sat.channel);\n }\n };\n\n return {\n current: () => session,\n\n openAt(path) {\n const opened = opts.open(path);\n if (\"error\" in opened) {\n opts.store.forgetProject(path);\n opts.refreshMenu?.();\n return opened;\n }\n if (session !== undefined) opts.close?.(session);\n session = opened.session;\n opts.store.touchProject(opened.root, opened.name);\n invalidateSatellites();\n opts.refreshMenu?.();\n return opened.reply;\n },\n\n closeCurrent() {\n if (session === undefined) return;\n opts.close?.(session);\n session = undefined;\n opts.store.clearLastProject?.();\n invalidateSatellites();\n opts.refreshMenu?.();\n },\n\n isKnownPath(path) {\n const s = opts.store.get();\n // Read `path` off each recent EXPLICITLY rather than filtering the list to\n // whatever looks like a string. The old form did the latter, and when\n // recents became objects in 0.25.0 every entry quietly failed the test and\n // was dropped: no error, no empty list to notice, just a launcher that had\n // stopped recognising every project but the last one. A defensive filter is\n // what turns a shape change into a silent behaviour change, so this one\n // states the shape it wants and lets the compiler enforce it.\n const paths = [s.lastProject, ...s.recents.map((r) => r.path)];\n const known = new Set(paths.filter((p): p is string => !!p).map((p) => resolve(p)));\n return known.has(resolve(path));\n },\n\n addSatellite(satellite) {\n satellites.push(satellite);\n return () => {\n const i = satellites.indexOf(satellite);\n if (i >= 0) satellites.splice(i, 1);\n };\n },\n\n invalidateSatellites,\n };\n}\n"]}
|
package/dist/session.d.cts
CHANGED
|
@@ -32,6 +32,11 @@ interface SessionStore {
|
|
|
32
32
|
* keeps whatever was known, which is `createAppStore`'s rule. */
|
|
33
33
|
touchProject(path: string, name?: string): void;
|
|
34
34
|
forgetProject(path: string): void;
|
|
35
|
+
/** Forget only WHICH project was open, keeping it in recents. Close Project
|
|
36
|
+
* calls this so a quit after closing boots to the welcome screen instead of
|
|
37
|
+
* silently reopening what the person just closed. Optional: a host without
|
|
38
|
+
* it keeps its boot behaviour. */
|
|
39
|
+
clearLastProject?(): void;
|
|
35
40
|
}
|
|
36
41
|
interface ProjectSessionOptions<S, R> {
|
|
37
42
|
store: SessionStore;
|
|
@@ -77,6 +82,11 @@ interface ProjectSession<S, R> {
|
|
|
77
82
|
/** May the renderer ask for this path? See the note above: only paths the app
|
|
78
83
|
* already knows, because a native dialog put them there. */
|
|
79
84
|
isKnownPath: (path: string) => boolean;
|
|
85
|
+
/** Close the open project and return to the no-project state (the welcome
|
|
86
|
+
* screen, in both apps): the close hook runs, the satellites are told, the
|
|
87
|
+
* menu rebuilds, and the store forgets WHICH project was open while keeping
|
|
88
|
+
* it in recents. A no-op when nothing is open. */
|
|
89
|
+
closeCurrent: () => void;
|
|
80
90
|
/** Register a tool window after construction, which is when they usually
|
|
81
91
|
* appear. Returns a function that unregisters it. */
|
|
82
92
|
addSatellite: (satellite: Satellite) => () => void;
|
package/dist/session.d.ts
CHANGED
|
@@ -32,6 +32,11 @@ interface SessionStore {
|
|
|
32
32
|
* keeps whatever was known, which is `createAppStore`'s rule. */
|
|
33
33
|
touchProject(path: string, name?: string): void;
|
|
34
34
|
forgetProject(path: string): void;
|
|
35
|
+
/** Forget only WHICH project was open, keeping it in recents. Close Project
|
|
36
|
+
* calls this so a quit after closing boots to the welcome screen instead of
|
|
37
|
+
* silently reopening what the person just closed. Optional: a host without
|
|
38
|
+
* it keeps its boot behaviour. */
|
|
39
|
+
clearLastProject?(): void;
|
|
35
40
|
}
|
|
36
41
|
interface ProjectSessionOptions<S, R> {
|
|
37
42
|
store: SessionStore;
|
|
@@ -77,6 +82,11 @@ interface ProjectSession<S, R> {
|
|
|
77
82
|
/** May the renderer ask for this path? See the note above: only paths the app
|
|
78
83
|
* already knows, because a native dialog put them there. */
|
|
79
84
|
isKnownPath: (path: string) => boolean;
|
|
85
|
+
/** Close the open project and return to the no-project state (the welcome
|
|
86
|
+
* screen, in both apps): the close hook runs, the satellites are told, the
|
|
87
|
+
* menu rebuilds, and the store forgets WHICH project was open while keeping
|
|
88
|
+
* it in recents. A no-op when nothing is open. */
|
|
89
|
+
closeCurrent: () => void;
|
|
80
90
|
/** Register a tool window after construction, which is when they usually
|
|
81
91
|
* appear. Returns a function that unregisters it. */
|
|
82
92
|
addSatellite: (satellite: Satellite) => () => void;
|
package/dist/session.js
CHANGED
|
@@ -27,6 +27,14 @@ function createProjectSession(opts) {
|
|
|
27
27
|
opts.refreshMenu?.();
|
|
28
28
|
return opened.reply;
|
|
29
29
|
},
|
|
30
|
+
closeCurrent() {
|
|
31
|
+
if (session === void 0) return;
|
|
32
|
+
opts.close?.(session);
|
|
33
|
+
session = void 0;
|
|
34
|
+
opts.store.clearLastProject?.();
|
|
35
|
+
invalidateSatellites();
|
|
36
|
+
opts.refreshMenu?.();
|
|
37
|
+
},
|
|
30
38
|
isKnownPath(path) {
|
|
31
39
|
const s = opts.store.get();
|
|
32
40
|
const paths = [s.lastProject, ...s.recents.map((r) => r.path)];
|
package/dist/session.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/session.ts"],"names":[],"mappings":";;;AAgHO,SAAS,qBAA2B,IAAA,EAAyD;AAClG,EAAA,IAAI,OAAA;AACJ,EAAA,MAAM,aAA0B,CAAC,GAAI,IAAA,CAAK,UAAA,IAAc,EAAG,CAAA;AAE3D,EAAA,MAAM,uBAAuB,MAAY;AACvC,IAAA,KAAA,MAAW,OAAO,UAAA,EAAY;AAC5B,MAAA,GAAA,CAAI,KAAA,IAAQ;AACZ,MAAA,MAAM,GAAA,GAAM,IAAI,MAAA,EAAO;AACvB,MAAA,IAAI,GAAA,IAAO,CAAC,GAAA,CAAI,WAAA,IAAe,GAAA,CAAI,WAAA,CAAY,IAAA,CAAK,GAAA,CAAI,OAAO,CAAA;AAAA,IACjE;AAAA,EACF,CAAA;AAEA,EAAA,OAAO;AAAA,IACL,SAAS,MAAM,OAAA;AAAA,IAEf,OAAO,IAAA,EAAM;AACX,MAAA,MAAM,MAAA,GAAS,IAAA,CAAK,IAAA,CAAK,IAAI,CAAA;AAC7B,MAAA,IAAI,WAAW,MAAA,EAAQ;AACrB,QAAA,IAAA,CAAK,KAAA,CAAM,cAAc,IAAI,CAAA;AAC7B,QAAA,IAAA,CAAK,WAAA,IAAc;AACnB,QAAA,OAAO,MAAA;AAAA,MACT;AACA,MAAA,IAAI,OAAA,KAAY,MAAA,EAAW,IAAA,CAAK,KAAA,GAAQ,OAAO,CAAA;AAC/C,MAAA,OAAA,GAAU,MAAA,CAAO,OAAA;AACjB,MAAA,IAAA,CAAK,KAAA,CAAM,YAAA,CAAa,MAAA,CAAO,IAAA,EAAM,OAAO,IAAI,CAAA;AAChD,MAAA,oBAAA,EAAqB;AACrB,MAAA,IAAA,CAAK,WAAA,IAAc;AACnB,MAAA,OAAO,MAAA,CAAO,KAAA;AAAA,IAChB,CAAA;AAAA,IAEA,YAAY,IAAA,EAAM;AAChB,MAAA,MAAM,CAAA,GAAI,IAAA,CAAK,KAAA,CAAM,GAAA,EAAI;AAQzB,MAAA,MAAM,KAAA,GAAQ,CAAC,CAAA,CAAE,WAAA,EAAa,GAAG,CAAA,CAAE,OAAA,CAAQ,GAAA,CAAI,CAAC,CAAA,KAAM,CAAA,CAAE,IAAI,CAAC,CAAA;AAC7D,MAAA,MAAM,QAAQ,IAAI,GAAA,CAAI,KAAA,CAAM,MAAA,CAAO,CAAC,CAAA,KAAmB,CAAC,CAAC,CAAC,EAAE,GAAA,CAAI,CAAC,MAAM,OAAA,CAAQ,CAAC,CAAC,CAAC,CAAA;AAClF,MAAA,OAAO,KAAA,CAAM,GAAA,CAAI,OAAA,CAAQ,IAAI,CAAC,CAAA;AAAA,IAChC,CAAA;AAAA,IAEA,aAAa,SAAA,EAAW;AACtB,MAAA,UAAA,CAAW,KAAK,SAAS,CAAA;AACzB,MAAA,OAAO,MAAM;AACX,QAAA,MAAM,CAAA,GAAI,UAAA,CAAW,OAAA,CAAQ,SAAS,CAAA;AACtC,QAAA,IAAI,CAAA,IAAK,CAAA,EAAG,UAAA,CAAW,MAAA,CAAO,GAAG,CAAC,CAAA;AAAA,MACpC,CAAA;AAAA,IACF,CAAA;AAAA,IAEA;AAAA,GACF;AACF","file":"session.js","sourcesContent":["// ---------------------------------------------------------------------------\n// The project session: what happens around opening a project, as opposed to\n// the opening itself.\n//\n// Both apps have a function - `openAt` here, `openAndRecord` there - and under\n// two different names they do the same six things in the same order:\n//\n// 1. Ask the app to open it, which is the only app-specific step.\n// 2. On failure, drop it from recents (a project that has moved should stop\n// being offered) and rebuild the menu.\n// 3. On success, finish with the outgoing session.\n// 4. Record the opened ROOT in the store, which also clears the remembered\n// place when it is a different project.\n// 5. Invalidate everything main was caching FOR THE OLD PROJECT, and tell\n// each satellite tool window that the ground moved.\n// 6. Rebuild the menu, so File > Open Recent is current.\n//\n// Step 5 is the one worth extracting on its own. Both apps hand-wire it per\n// window - a cached result nulled, then `if (win && !win.isDestroyed())\n// win.webContents.send(...)` - Storyletter three times, Patterpad twice, and\n// every new tool window is another chance to forget one and leave a window\n// describing a project that is no longer open. Registering a satellite once\n// makes forgetting it impossible.\n//\n// It also carries `isKnownPath`, which only Patterpad had and which is a real\n// guard rather than a tidiness: a renderer must not be able to name an\n// arbitrary directory and have main open it. Fresh paths enter through the\n// native dialogs, which run in main where the person actually chose them;\n// everything else must already be in recents.\n//\n// Electron-free: a satellite is anything with the two methods it needs, which\n// a BrowserWindow satisfies and a test can fake.\n// ---------------------------------------------------------------------------\n\nimport { resolve } from \"node:path\";\nimport type { RecentProject } from \"./app-store.js\";\n\n/** As much of a BrowserWindow as this file has any business knowing. */\nexport interface SatelliteWindow {\n isDestroyed(): boolean;\n webContents: { send(channel: string, ...args: unknown[]): void };\n}\n\n/**\n * A tool window that shows something about the open project, and any state\n * main is holding on its behalf.\n */\nexport interface Satellite {\n /** Looked up each time rather than held: these windows open and close. */\n window: () => SatelliteWindow | undefined | null;\n /** What to send when the project changes. The window re-fetches; it is a\n * nudge, not a payload, because by then the new project is already open. */\n channel: string;\n /** Drop what main was caching for the old project - a coverage report, a\n * search anchor. Called before the window is told. */\n clear?: () => void;\n}\n\n/** The slice of the app store this needs. Structural, so the real store fits\n * without dragging its generic parameters in here. */\nexport interface SessionStore {\n get(): { recents: RecentProject[]; lastProject?: string };\n /** The name is what the app just learned by opening the project. Omitting it\n * keeps whatever was known, which is `createAppStore`'s rule. */\n touchProject(path: string, name?: string): void;\n forgetProject(path: string): void;\n}\n\nexport interface ProjectSessionOptions<S, R> {\n store: SessionStore;\n /**\n * Open it. The one app-specific step.\n *\n * `root` is what gets recorded, and it is not always the path asked for: a\n * file-association launch names a shard INSIDE a project, and it is the\n * project that belongs in recents. `reply` is whatever the app sends back to\n * its renderer - this never looks inside it.\n *\n * `name` is the project's display name, if opening it revealed one. Return it\n * and the session records it with the path; leave it out and whatever was\n * known is kept. This is here so no app has to touch the store a second time\n * just to name what it has already opened.\n */\n open: (path: string) => { session: S; root: string; name?: string; reply: R } | { error: string };\n /**\n * The outgoing session is finished with: sweep its temporary files, close its\n * handles. Called only once the replacement has opened successfully, so a\n * failed open leaves the current project entirely alone.\n */\n close?: (session: S) => void;\n /** Rebuild the application menu. Called after a change either way, because a\n * failed open changes recents too. */\n refreshMenu?: () => void;\n satellites?: Satellite[];\n}\n\nexport interface ProjectSession<S, R> {\n /** The open project, or undefined before the first one. */\n current: () => S | undefined;\n /** Open, with everything in the comment above. */\n openAt: (path: string) => R | { error: string };\n /** May the renderer ask for this path? See the note above: only paths the app\n * already knows, because a native dialog put them there. */\n isKnownPath: (path: string) => boolean;\n /** Register a tool window after construction, which is when they usually\n * appear. Returns a function that unregisters it. */\n addSatellite: (satellite: Satellite) => () => void;\n /** Fire every satellite by hand. For the cases that are not an open but do\n * invalidate the same things - a merge landing new content underneath. */\n invalidateSatellites: () => void;\n}\n\nexport function createProjectSession<S, R>(opts: ProjectSessionOptions<S, R>): ProjectSession<S, R> {\n let session: S | undefined;\n const satellites: Satellite[] = [...(opts.satellites ?? [])];\n\n const invalidateSatellites = (): void => {\n for (const sat of satellites) {\n sat.clear?.();\n const win = sat.window();\n if (win && !win.isDestroyed()) win.webContents.send(sat.channel);\n }\n };\n\n return {\n current: () => session,\n\n openAt(path) {\n const opened = opts.open(path);\n if (\"error\" in opened) {\n opts.store.forgetProject(path);\n opts.refreshMenu?.();\n return opened;\n }\n if (session !== undefined) opts.close?.(session);\n session = opened.session;\n opts.store.touchProject(opened.root, opened.name);\n invalidateSatellites();\n opts.refreshMenu?.();\n return opened.reply;\n },\n\n isKnownPath(path) {\n const s = opts.store.get();\n // Read `path` off each recent EXPLICITLY rather than filtering the list to\n // whatever looks like a string. The old form did the latter, and when\n // recents became objects in 0.25.0 every entry quietly failed the test and\n // was dropped: no error, no empty list to notice, just a launcher that had\n // stopped recognising every project but the last one. A defensive filter is\n // what turns a shape change into a silent behaviour change, so this one\n // states the shape it wants and lets the compiler enforce it.\n const paths = [s.lastProject, ...s.recents.map((r) => r.path)];\n const known = new Set(paths.filter((p): p is string => !!p).map((p) => resolve(p)));\n return known.has(resolve(path));\n },\n\n addSatellite(satellite) {\n satellites.push(satellite);\n return () => {\n const i = satellites.indexOf(satellite);\n if (i >= 0) satellites.splice(i, 1);\n };\n },\n\n invalidateSatellites,\n };\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/session.ts"],"names":[],"mappings":";;;AA0HO,SAAS,qBAA2B,IAAA,EAAyD;AAClG,EAAA,IAAI,OAAA;AACJ,EAAA,MAAM,aAA0B,CAAC,GAAI,IAAA,CAAK,UAAA,IAAc,EAAG,CAAA;AAE3D,EAAA,MAAM,uBAAuB,MAAY;AACvC,IAAA,KAAA,MAAW,OAAO,UAAA,EAAY;AAC5B,MAAA,GAAA,CAAI,KAAA,IAAQ;AACZ,MAAA,MAAM,GAAA,GAAM,IAAI,MAAA,EAAO;AACvB,MAAA,IAAI,GAAA,IAAO,CAAC,GAAA,CAAI,WAAA,IAAe,GAAA,CAAI,WAAA,CAAY,IAAA,CAAK,GAAA,CAAI,OAAO,CAAA;AAAA,IACjE;AAAA,EACF,CAAA;AAEA,EAAA,OAAO;AAAA,IACL,SAAS,MAAM,OAAA;AAAA,IAEf,OAAO,IAAA,EAAM;AACX,MAAA,MAAM,MAAA,GAAS,IAAA,CAAK,IAAA,CAAK,IAAI,CAAA;AAC7B,MAAA,IAAI,WAAW,MAAA,EAAQ;AACrB,QAAA,IAAA,CAAK,KAAA,CAAM,cAAc,IAAI,CAAA;AAC7B,QAAA,IAAA,CAAK,WAAA,IAAc;AACnB,QAAA,OAAO,MAAA;AAAA,MACT;AACA,MAAA,IAAI,OAAA,KAAY,MAAA,EAAW,IAAA,CAAK,KAAA,GAAQ,OAAO,CAAA;AAC/C,MAAA,OAAA,GAAU,MAAA,CAAO,OAAA;AACjB,MAAA,IAAA,CAAK,KAAA,CAAM,YAAA,CAAa,MAAA,CAAO,IAAA,EAAM,OAAO,IAAI,CAAA;AAChD,MAAA,oBAAA,EAAqB;AACrB,MAAA,IAAA,CAAK,WAAA,IAAc;AACnB,MAAA,OAAO,MAAA,CAAO,KAAA;AAAA,IAChB,CAAA;AAAA,IAEA,YAAA,GAAe;AACb,MAAA,IAAI,YAAY,MAAA,EAAW;AAC3B,MAAA,IAAA,CAAK,QAAQ,OAAO,CAAA;AACpB,MAAA,OAAA,GAAU,MAAA;AACV,MAAA,IAAA,CAAK,MAAM,gBAAA,IAAmB;AAC9B,MAAA,oBAAA,EAAqB;AACrB,MAAA,IAAA,CAAK,WAAA,IAAc;AAAA,IACrB,CAAA;AAAA,IAEA,YAAY,IAAA,EAAM;AAChB,MAAA,MAAM,CAAA,GAAI,IAAA,CAAK,KAAA,CAAM,GAAA,EAAI;AAQzB,MAAA,MAAM,KAAA,GAAQ,CAAC,CAAA,CAAE,WAAA,EAAa,GAAG,CAAA,CAAE,OAAA,CAAQ,GAAA,CAAI,CAAC,CAAA,KAAM,CAAA,CAAE,IAAI,CAAC,CAAA;AAC7D,MAAA,MAAM,QAAQ,IAAI,GAAA,CAAI,KAAA,CAAM,MAAA,CAAO,CAAC,CAAA,KAAmB,CAAC,CAAC,CAAC,EAAE,GAAA,CAAI,CAAC,MAAM,OAAA,CAAQ,CAAC,CAAC,CAAC,CAAA;AAClF,MAAA,OAAO,KAAA,CAAM,GAAA,CAAI,OAAA,CAAQ,IAAI,CAAC,CAAA;AAAA,IAChC,CAAA;AAAA,IAEA,aAAa,SAAA,EAAW;AACtB,MAAA,UAAA,CAAW,KAAK,SAAS,CAAA;AACzB,MAAA,OAAO,MAAM;AACX,QAAA,MAAM,CAAA,GAAI,UAAA,CAAW,OAAA,CAAQ,SAAS,CAAA;AACtC,QAAA,IAAI,CAAA,IAAK,CAAA,EAAG,UAAA,CAAW,MAAA,CAAO,GAAG,CAAC,CAAA;AAAA,MACpC,CAAA;AAAA,IACF,CAAA;AAAA,IAEA;AAAA,GACF;AACF","file":"session.js","sourcesContent":["// ---------------------------------------------------------------------------\n// The project session: what happens around opening a project, as opposed to\n// the opening itself.\n//\n// Both apps have a function - `openAt` here, `openAndRecord` there - and under\n// two different names they do the same six things in the same order:\n//\n// 1. Ask the app to open it, which is the only app-specific step.\n// 2. On failure, drop it from recents (a project that has moved should stop\n// being offered) and rebuild the menu.\n// 3. On success, finish with the outgoing session.\n// 4. Record the opened ROOT in the store, which also clears the remembered\n// place when it is a different project.\n// 5. Invalidate everything main was caching FOR THE OLD PROJECT, and tell\n// each satellite tool window that the ground moved.\n// 6. Rebuild the menu, so File > Open Recent is current.\n//\n// Step 5 is the one worth extracting on its own. Both apps hand-wire it per\n// window - a cached result nulled, then `if (win && !win.isDestroyed())\n// win.webContents.send(...)` - Storyletter three times, Patterpad twice, and\n// every new tool window is another chance to forget one and leave a window\n// describing a project that is no longer open. Registering a satellite once\n// makes forgetting it impossible.\n//\n// It also carries `isKnownPath`, which only Patterpad had and which is a real\n// guard rather than a tidiness: a renderer must not be able to name an\n// arbitrary directory and have main open it. Fresh paths enter through the\n// native dialogs, which run in main where the person actually chose them;\n// everything else must already be in recents.\n//\n// Electron-free: a satellite is anything with the two methods it needs, which\n// a BrowserWindow satisfies and a test can fake.\n// ---------------------------------------------------------------------------\n\nimport { resolve } from \"node:path\";\nimport type { RecentProject } from \"./app-store.js\";\n\n/** As much of a BrowserWindow as this file has any business knowing. */\nexport interface SatelliteWindow {\n isDestroyed(): boolean;\n webContents: { send(channel: string, ...args: unknown[]): void };\n}\n\n/**\n * A tool window that shows something about the open project, and any state\n * main is holding on its behalf.\n */\nexport interface Satellite {\n /** Looked up each time rather than held: these windows open and close. */\n window: () => SatelliteWindow | undefined | null;\n /** What to send when the project changes. The window re-fetches; it is a\n * nudge, not a payload, because by then the new project is already open. */\n channel: string;\n /** Drop what main was caching for the old project - a coverage report, a\n * search anchor. Called before the window is told. */\n clear?: () => void;\n}\n\n/** The slice of the app store this needs. Structural, so the real store fits\n * without dragging its generic parameters in here. */\nexport interface SessionStore {\n get(): { recents: RecentProject[]; lastProject?: string };\n /** The name is what the app just learned by opening the project. Omitting it\n * keeps whatever was known, which is `createAppStore`'s rule. */\n touchProject(path: string, name?: string): void;\n forgetProject(path: string): void;\n /** Forget only WHICH project was open, keeping it in recents. Close Project\n * calls this so a quit after closing boots to the welcome screen instead of\n * silently reopening what the person just closed. Optional: a host without\n * it keeps its boot behaviour. */\n clearLastProject?(): void;\n}\n\nexport interface ProjectSessionOptions<S, R> {\n store: SessionStore;\n /**\n * Open it. The one app-specific step.\n *\n * `root` is what gets recorded, and it is not always the path asked for: a\n * file-association launch names a shard INSIDE a project, and it is the\n * project that belongs in recents. `reply` is whatever the app sends back to\n * its renderer - this never looks inside it.\n *\n * `name` is the project's display name, if opening it revealed one. Return it\n * and the session records it with the path; leave it out and whatever was\n * known is kept. This is here so no app has to touch the store a second time\n * just to name what it has already opened.\n */\n open: (path: string) => { session: S; root: string; name?: string; reply: R } | { error: string };\n /**\n * The outgoing session is finished with: sweep its temporary files, close its\n * handles. Called only once the replacement has opened successfully, so a\n * failed open leaves the current project entirely alone.\n */\n close?: (session: S) => void;\n /** Rebuild the application menu. Called after a change either way, because a\n * failed open changes recents too. */\n refreshMenu?: () => void;\n satellites?: Satellite[];\n}\n\nexport interface ProjectSession<S, R> {\n /** The open project, or undefined before the first one. */\n current: () => S | undefined;\n /** Open, with everything in the comment above. */\n openAt: (path: string) => R | { error: string };\n /** May the renderer ask for this path? See the note above: only paths the app\n * already knows, because a native dialog put them there. */\n isKnownPath: (path: string) => boolean;\n /** Close the open project and return to the no-project state (the welcome\n * screen, in both apps): the close hook runs, the satellites are told, the\n * menu rebuilds, and the store forgets WHICH project was open while keeping\n * it in recents. A no-op when nothing is open. */\n closeCurrent: () => void;\n /** Register a tool window after construction, which is when they usually\n * appear. Returns a function that unregisters it. */\n addSatellite: (satellite: Satellite) => () => void;\n /** Fire every satellite by hand. For the cases that are not an open but do\n * invalidate the same things - a merge landing new content underneath. */\n invalidateSatellites: () => void;\n}\n\nexport function createProjectSession<S, R>(opts: ProjectSessionOptions<S, R>): ProjectSession<S, R> {\n let session: S | undefined;\n const satellites: Satellite[] = [...(opts.satellites ?? [])];\n\n const invalidateSatellites = (): void => {\n for (const sat of satellites) {\n sat.clear?.();\n const win = sat.window();\n if (win && !win.isDestroyed()) win.webContents.send(sat.channel);\n }\n };\n\n return {\n current: () => session,\n\n openAt(path) {\n const opened = opts.open(path);\n if (\"error\" in opened) {\n opts.store.forgetProject(path);\n opts.refreshMenu?.();\n return opened;\n }\n if (session !== undefined) opts.close?.(session);\n session = opened.session;\n opts.store.touchProject(opened.root, opened.name);\n invalidateSatellites();\n opts.refreshMenu?.();\n return opened.reply;\n },\n\n closeCurrent() {\n if (session === undefined) return;\n opts.close?.(session);\n session = undefined;\n opts.store.clearLastProject?.();\n invalidateSatellites();\n opts.refreshMenu?.();\n },\n\n isKnownPath(path) {\n const s = opts.store.get();\n // Read `path` off each recent EXPLICITLY rather than filtering the list to\n // whatever looks like a string. The old form did the latter, and when\n // recents became objects in 0.25.0 every entry quietly failed the test and\n // was dropped: no error, no empty list to notice, just a launcher that had\n // stopped recognising every project but the last one. A defensive filter is\n // what turns a shape change into a silent behaviour change, so this one\n // states the shape it wants and lets the compiler enforce it.\n const paths = [s.lastProject, ...s.recents.map((r) => r.path)];\n const known = new Set(paths.filter((p): p is string => !!p).map((p) => resolve(p)));\n return known.has(resolve(path));\n },\n\n addSatellite(satellite) {\n satellites.push(satellite);\n return () => {\n const i = satellites.indexOf(satellite);\n if (i >= 0) satellites.splice(i, 1);\n };\n },\n\n invalidateSatellites,\n };\n}\n"]}
|
package/dist/tool-window.cjs
CHANGED
|
@@ -43,8 +43,35 @@ function rescueToolWindow(w, def) {
|
|
|
43
43
|
w.show();
|
|
44
44
|
w.moveTop();
|
|
45
45
|
}
|
|
46
|
-
|
|
46
|
+
var macPinned = /* @__PURE__ */ new Set();
|
|
47
|
+
var macActivationWired = false;
|
|
48
|
+
function wireMacActivation() {
|
|
49
|
+
if (macActivationWired) return;
|
|
50
|
+
macActivationWired = true;
|
|
51
|
+
electron.app.on("did-become-active", () => {
|
|
52
|
+
for (const w of macPinned) if (!w.isDestroyed()) w.setAlwaysOnTop(true, "floating");
|
|
53
|
+
});
|
|
54
|
+
electron.app.on("did-resign-active", () => {
|
|
55
|
+
for (const w of macPinned) if (!w.isDestroyed()) w.setAlwaysOnTop(false);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
function pinToolWindow(w, parent, on, platform = process.platform) {
|
|
47
59
|
if (!w || w.isDestroyed()) return;
|
|
60
|
+
if (platform === "darwin") {
|
|
61
|
+
w.setParentWindow(null);
|
|
62
|
+
if (on) {
|
|
63
|
+
if (!macPinned.has(w)) {
|
|
64
|
+
macPinned.add(w);
|
|
65
|
+
w.once("closed", () => macPinned.delete(w));
|
|
66
|
+
}
|
|
67
|
+
wireMacActivation();
|
|
68
|
+
w.setAlwaysOnTop(electron.BrowserWindow.getFocusedWindow() !== null, "floating");
|
|
69
|
+
} else {
|
|
70
|
+
macPinned.delete(w);
|
|
71
|
+
w.setAlwaysOnTop(false);
|
|
72
|
+
}
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
48
75
|
w.setAlwaysOnTop(false);
|
|
49
76
|
w.setParentWindow(on && parent && !parent.isDestroyed() ? parent : null);
|
|
50
77
|
}
|
package/dist/tool-window.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/tool-window.ts"],"names":["screen","BrowserWindow","join"],"mappings":"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","file":"tool-window.cjs","sourcesContent":["// ---------------------------------------------------------------------------\n// The tool-window kit, MAIN-PROCESS half: the detached helper-window\n// machinery both apps had grown four copies of (Patterpad's Play + Search +\n// Coverage, Storyletter's Board + Find). Remembered bounds with a\n// disconnected-monitor guard, debounced persistence, the Reset View rescue,\n// and the pinnable window factory they compose into.\n//\n// This module imports electron, so it ships as its own subpath\n// (@wildwinter/app-shell/tool-window) - never import it from renderer code.\n// The renderer half (pin button, .swin drag-bar grammar) lives in the root\n// export + tool-window.css.\n//\n// Two deliberate corrections over the Patterpad originals, for when it\n// migrates here: rescueToolWindow re-pins EVERY window it rescues (Patterpad\n// re-pinned only Play, so its store lied about Search + Coverage), and the\n// factory always wires closed-cleanup through an identity guard.\n// ---------------------------------------------------------------------------\n\nimport { BrowserWindow, screen } from \"electron\";\nimport { join } from \"node:path\";\n\nexport interface ToolWindowSize {\n width: number;\n height: number;\n}\n\nexport interface ToolWindowBounds {\n x?: number;\n y?: number;\n width: number;\n height: number;\n}\n\n/** The per-window store slice every app remembers: where it was, whether it\n * floats. (Patterpad's PlayWindowState, generalised.) */\nexport interface ToolWindowState {\n bounds?: ToolWindowBounds;\n pinned: boolean;\n}\n\n/** Use a remembered rect only if it still intersects a live display (a window\n * saved on a now-disconnected monitor must not open offscreen). Falls back\n * to the default size; a remembered size is kept even when the position is\n * dropped (Electron then centres it). */\nexport function savedWindowRect(\n saved: ToolWindowBounds | undefined,\n def: ToolWindowSize,\n min: ToolWindowSize,\n): ToolWindowBounds {\n if (!saved) return { ...def };\n const w = Math.max(min.width, saved.width), h = Math.max(min.height, saved.height);\n if (saved.x != null && saved.y != null) {\n const onScreen = screen.getAllDisplays().some((d) => {\n const a = d.workArea;\n return saved.x! + w > a.x + 40 && saved.x! < a.x + a.width - 40 && saved.y! + h > a.y + 20 && saved.y! < a.y + a.height - 20;\n });\n if (onScreen) return { x: saved.x, y: saved.y, width: w, height: h };\n }\n return { width: w, height: h };\n}\n\n/** Persist a helper window's bounds as the user moves / resizes / closes it\n * (debounced 400ms; close flushes synchronously). */\nexport function rememberBounds(w: BrowserWindow, write: (bounds: ToolWindowBounds) => void): void {\n const saveBounds = (): void => { if (!w.isDestroyed()) write(w.getBounds()); };\n let boundsTimer: ReturnType<typeof setTimeout> | undefined;\n const queueSave = (): void => { clearTimeout(boundsTimer); boundsTimer = setTimeout(saveBounds, 400); };\n w.on(\"resize\", queueSave);\n w.on(\"move\", queueSave);\n w.on(\"close\", () => { clearTimeout(boundsTimer); saveBounds(); });\n}\n\n/** Centre a rect on the primary display's work area. */\nexport function centeredOnPrimary(size: ToolWindowSize): { x: number; y: number } {\n const a = screen.getPrimaryDisplay().workArea;\n return { x: Math.round(a.x + (a.width - size.width) / 2), y: Math.round(a.y + (a.height - size.height) / 2) };\n}\n\n/** Reset View's per-window half: un-minimise, re-pin (always - the store\n * says pinned:true after a rescue, so the window must agree), default size,\n * centred on the primary display, shown. The host clears its remembered\n * bounds itself (that's a store concern). No focus steal: helpers show\n * behind the editor. */\nexport function rescueToolWindow(w: BrowserWindow | undefined | null, def: ToolWindowSize): void {\n if (!w || w.isDestroyed()) return;\n if (w.isMinimized()) w.restore();\n w.setBounds({ ...def, ...centeredOnPrimary(def) });\n w.show();\n // Raise within OUR stack. This used to setAlwaysOnTop(true), which on macOS\n // and Windows floats the window above every other APPLICATION - and stuck\n // that way, since nothing ever unset it.\n w.moveTop();\n}\n\n/**\n * The pin: keep a tool window above THIS APP'S MAIN WINDOW, not above the\n * world. `alwaysOnTop` was the first implementation and it floats the window\n * over every other application on macOS and Windows, so a pinned Board meant\n * nothing else on the machine could come to the front (a Storyletter user\n * report, 2026-08-25). A CHILD window is the semantic actually wanted on both\n * platforms: it stays over its parent and stacks normally against everything\n * else. Clearing any alwaysOnTop first also heals a window an older build\n * pinned the old way.\n */\nexport function pinToolWindow(\n w: BrowserWindow | undefined | null, parent: BrowserWindow | undefined | null, on: boolean,\n): void {\n if (!w || w.isDestroyed()) return;\n w.setAlwaysOnTop(false);\n w.setParentWindow(on && parent && !parent.isDestroyed() ? parent : null);\n}\n\nexport interface ToolWindowOptions {\n title: string;\n /** Renderer entry, relative to rendererDir (e.g. \"table.html\", \"search/index.html\"). */\n page: string;\n /** Absolute path of the built renderer dir (join(import.meta.dirname, \"../renderer\")). */\n rendererDir: string;\n /** Absolute path of the preload bridge. */\n preload: string;\n rect: ToolWindowBounds;\n min?: ToolWindowSize;\n /** false = frameless: the renderer draws its own slim drag bar (.swin-head). */\n frame?: boolean;\n /** The window to PIN ABOVE: the app's main window, resolved at open time (a\n * getter, because the main window can be recreated). With `pinned` true the\n * new tool window is parented to it, which keeps it above the editor and\n * stacks it normally against every other application - the semantic\n * `alwaysOnTop` (this option's late predecessor) got wrong on macOS and\n * Windows, where it floats over the whole machine. */\n pinTo?: () => BrowserWindow | undefined;\n /** Start pinned (the host remembers); flip later with `pinToolWindow`. */\n pinned?: boolean;\n /** Wire debounced bounds persistence into the host's store slice. */\n remember?: (bounds: ToolWindowBounds) => void;\n /** Fired when THIS window closes (identity-guarded; a stale close never\n * clears a newer window). Null the host's handle here. */\n onClosed?: () => void;\n}\n\n/** The pinnable detached-window factory (hardened webPreferences, one\n * preload, electron-vite's dev-URL/file split). Focuses and returns the\n * existing window when there is one; otherwise creates, wires, and loads. */\nexport function openToolWindow(existing: BrowserWindow | undefined | null, opts: ToolWindowOptions): BrowserWindow {\n if (existing && !existing.isDestroyed()) { existing.focus(); return existing; }\n const w = new BrowserWindow({\n ...opts.rect,\n ...(opts.min !== undefined ? { minWidth: opts.min.width, minHeight: opts.min.height } : {}),\n show: false,\n title: opts.title,\n ...(opts.frame !== undefined ? { frame: opts.frame } : {}),\n webPreferences: {\n contextIsolation: true, nodeIntegration: false, sandbox: true,\n preload: opts.preload,\n },\n });\n if (opts.pinTo !== undefined) pinToolWindow(w, opts.pinTo(), opts.pinned ?? false);\n if (opts.remember) rememberBounds(w, opts.remember);\n w.once(\"ready-to-show\", () => w.show());\n if (opts.onClosed) w.on(\"closed\", opts.onClosed);\n if (process.env[\"ELECTRON_RENDERER_URL\"]) void w.loadURL(`${process.env[\"ELECTRON_RENDERER_URL\"]}/${opts.page}`);\n else void w.loadFile(join(opts.rendererDir, opts.page));\n return w;\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/tool-window.ts"],"names":["screen","app","BrowserWindow","join"],"mappings":"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","file":"tool-window.cjs","sourcesContent":["// ---------------------------------------------------------------------------\n// The tool-window kit, MAIN-PROCESS half: the detached helper-window\n// machinery both apps had grown four copies of (Patterpad's Play + Search +\n// Coverage, Storyletter's Board + Find). Remembered bounds with a\n// disconnected-monitor guard, debounced persistence, the Reset View rescue,\n// and the pinnable window factory they compose into.\n//\n// This module imports electron, so it ships as its own subpath\n// (@wildwinter/app-shell/tool-window) - never import it from renderer code.\n// The renderer half (pin button, .swin drag-bar grammar) lives in the root\n// export + tool-window.css.\n//\n// Two deliberate corrections over the Patterpad originals, for when it\n// migrates here: rescueToolWindow re-pins EVERY window it rescues (Patterpad\n// re-pinned only Play, so its store lied about Search + Coverage), and the\n// factory always wires closed-cleanup through an identity guard.\n// ---------------------------------------------------------------------------\n\nimport { BrowserWindow, app, screen } from \"electron\";\nimport { join } from \"node:path\";\n\nexport interface ToolWindowSize {\n width: number;\n height: number;\n}\n\nexport interface ToolWindowBounds {\n x?: number;\n y?: number;\n width: number;\n height: number;\n}\n\n/** The per-window store slice every app remembers: where it was, whether it\n * floats. (Patterpad's PlayWindowState, generalised.) */\nexport interface ToolWindowState {\n bounds?: ToolWindowBounds;\n pinned: boolean;\n}\n\n/** Use a remembered rect only if it still intersects a live display (a window\n * saved on a now-disconnected monitor must not open offscreen). Falls back\n * to the default size; a remembered size is kept even when the position is\n * dropped (Electron then centres it). */\nexport function savedWindowRect(\n saved: ToolWindowBounds | undefined,\n def: ToolWindowSize,\n min: ToolWindowSize,\n): ToolWindowBounds {\n if (!saved) return { ...def };\n const w = Math.max(min.width, saved.width), h = Math.max(min.height, saved.height);\n if (saved.x != null && saved.y != null) {\n const onScreen = screen.getAllDisplays().some((d) => {\n const a = d.workArea;\n return saved.x! + w > a.x + 40 && saved.x! < a.x + a.width - 40 && saved.y! + h > a.y + 20 && saved.y! < a.y + a.height - 20;\n });\n if (onScreen) return { x: saved.x, y: saved.y, width: w, height: h };\n }\n return { width: w, height: h };\n}\n\n/** Persist a helper window's bounds as the user moves / resizes / closes it\n * (debounced 400ms; close flushes synchronously). */\nexport function rememberBounds(w: BrowserWindow, write: (bounds: ToolWindowBounds) => void): void {\n const saveBounds = (): void => { if (!w.isDestroyed()) write(w.getBounds()); };\n let boundsTimer: ReturnType<typeof setTimeout> | undefined;\n const queueSave = (): void => { clearTimeout(boundsTimer); boundsTimer = setTimeout(saveBounds, 400); };\n w.on(\"resize\", queueSave);\n w.on(\"move\", queueSave);\n w.on(\"close\", () => { clearTimeout(boundsTimer); saveBounds(); });\n}\n\n/** Centre a rect on the primary display's work area. */\nexport function centeredOnPrimary(size: ToolWindowSize): { x: number; y: number } {\n const a = screen.getPrimaryDisplay().workArea;\n return { x: Math.round(a.x + (a.width - size.width) / 2), y: Math.round(a.y + (a.height - size.height) / 2) };\n}\n\n/** Reset View's per-window half: un-minimise, re-pin (always - the store\n * says pinned:true after a rescue, so the window must agree), default size,\n * centred on the primary display, shown. The host clears its remembered\n * bounds itself (that's a store concern). No focus steal: helpers show\n * behind the editor. */\nexport function rescueToolWindow(w: BrowserWindow | undefined | null, def: ToolWindowSize): void {\n if (!w || w.isDestroyed()) return;\n if (w.isMinimized()) w.restore();\n w.setBounds({ ...def, ...centeredOnPrimary(def) });\n w.show();\n // Raise within OUR stack. This used to setAlwaysOnTop(true), which on macOS\n // and Windows floats the window above every other APPLICATION - and stuck\n // that way, since nothing ever unset it.\n w.moveTop();\n}\n\n/** macOS pinned windows, for the activation toggle below. */\nconst macPinned = new Set<BrowserWindow>();\nlet macActivationWired = false;\nfunction wireMacActivation(): void {\n if (macActivationWired) return;\n macActivationWired = true;\n // Floating is a GLOBAL window level: left on while another app is front,\n // the pinned window would sit over that app too - the exact 2026-08-25\n // complaint. Scoping it to our app's active state keeps the pin meaning\n // \"above my editor\" and nothing more.\n app.on(\"did-become-active\", () => { for (const w of macPinned) if (!w.isDestroyed()) w.setAlwaysOnTop(true, \"floating\"); });\n app.on(\"did-resign-active\", () => { for (const w of macPinned) if (!w.isDestroyed()) w.setAlwaysOnTop(false); });\n}\n\n/**\n * The pin: keep a tool window above THIS APP'S MAIN WINDOW, not above the\n * world. Two findings shaped the mechanism, one per platform family:\n *\n * - Bare `alwaysOnTop` (the first implementation) floats the window over\n * every other application on macOS and Windows, so a pinned Board meant\n * nothing else on the machine could come to the front (a Storyletter user\n * report, 2026-08-25).\n * - A CHILD window (the second implementation) is position-coupled to its\n * parent on macOS: dragging the editor dragged the Board with it (a\n * Storyletter user report, 2026-08-28). Windows and Linux owned windows do\n * not move with their owner, so the child mechanism stays right for them.\n *\n * So: on macOS the pin is ACTIVATION-SCOPED alwaysOnTop - floating while\n * this app is active, dropped the moment it resigns - which is above the\n * editor without following its drags and without covering anyone else. On\n * Windows and Linux the pin is a child window, which stays over its parent\n * and stacks normally against everything else. Each branch clears the other\n * mechanism first, which also heals a window an older build pinned the old\n * way. `platform` is a seam for the tests; leave it defaulted.\n */\nexport function pinToolWindow(\n w: BrowserWindow | undefined | null, parent: BrowserWindow | undefined | null, on: boolean,\n platform: NodeJS.Platform = process.platform,\n): void {\n if (!w || w.isDestroyed()) return;\n if (platform === \"darwin\") {\n w.setParentWindow(null);\n if (on) {\n if (!macPinned.has(w)) {\n macPinned.add(w);\n w.once(\"closed\", () => macPinned.delete(w));\n }\n wireMacActivation();\n // Raise only when the app is active now; otherwise the next\n // did-become-active raises it.\n w.setAlwaysOnTop(BrowserWindow.getFocusedWindow() !== null, \"floating\");\n } else {\n macPinned.delete(w);\n w.setAlwaysOnTop(false);\n }\n return;\n }\n w.setAlwaysOnTop(false);\n w.setParentWindow(on && parent && !parent.isDestroyed() ? parent : null);\n}\n\nexport interface ToolWindowOptions {\n title: string;\n /** Renderer entry, relative to rendererDir (e.g. \"table.html\", \"search/index.html\"). */\n page: string;\n /** Absolute path of the built renderer dir (join(import.meta.dirname, \"../renderer\")). */\n rendererDir: string;\n /** Absolute path of the preload bridge. */\n preload: string;\n rect: ToolWindowBounds;\n min?: ToolWindowSize;\n /** false = frameless: the renderer draws its own slim drag bar (.swin-head). */\n frame?: boolean;\n /** The window to PIN ABOVE: the app's main window, resolved at open time (a\n * getter, because the main window can be recreated). With `pinned` true\n * the new tool window rides above it - on macOS via activation-scoped\n * alwaysOnTop (a child window there follows the editor's drags), on\n * Windows and Linux as a child window (bare alwaysOnTop there floats over\n * the whole machine). See pinToolWindow for both findings. */\n pinTo?: () => BrowserWindow | undefined;\n /** Start pinned (the host remembers); flip later with `pinToolWindow`. */\n pinned?: boolean;\n /** Wire debounced bounds persistence into the host's store slice. */\n remember?: (bounds: ToolWindowBounds) => void;\n /** Fired when THIS window closes (identity-guarded; a stale close never\n * clears a newer window). Null the host's handle here. */\n onClosed?: () => void;\n}\n\n/** The pinnable detached-window factory (hardened webPreferences, one\n * preload, electron-vite's dev-URL/file split). Focuses and returns the\n * existing window when there is one; otherwise creates, wires, and loads. */\nexport function openToolWindow(existing: BrowserWindow | undefined | null, opts: ToolWindowOptions): BrowserWindow {\n if (existing && !existing.isDestroyed()) { existing.focus(); return existing; }\n const w = new BrowserWindow({\n ...opts.rect,\n ...(opts.min !== undefined ? { minWidth: opts.min.width, minHeight: opts.min.height } : {}),\n show: false,\n title: opts.title,\n ...(opts.frame !== undefined ? { frame: opts.frame } : {}),\n webPreferences: {\n contextIsolation: true, nodeIntegration: false, sandbox: true,\n preload: opts.preload,\n },\n });\n if (opts.pinTo !== undefined) pinToolWindow(w, opts.pinTo(), opts.pinned ?? false);\n if (opts.remember) rememberBounds(w, opts.remember);\n w.once(\"ready-to-show\", () => w.show());\n if (opts.onClosed) w.on(\"closed\", opts.onClosed);\n if (process.env[\"ELECTRON_RENDERER_URL\"]) void w.loadURL(`${process.env[\"ELECTRON_RENDERER_URL\"]}/${opts.page}`);\n else void w.loadFile(join(opts.rendererDir, opts.page));\n return w;\n}\n"]}
|
package/dist/tool-window.d.cts
CHANGED
|
@@ -37,15 +37,26 @@ declare function centeredOnPrimary(size: ToolWindowSize): {
|
|
|
37
37
|
declare function rescueToolWindow(w: BrowserWindow | undefined | null, def: ToolWindowSize): void;
|
|
38
38
|
/**
|
|
39
39
|
* The pin: keep a tool window above THIS APP'S MAIN WINDOW, not above the
|
|
40
|
-
* world.
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
40
|
+
* world. Two findings shaped the mechanism, one per platform family:
|
|
41
|
+
*
|
|
42
|
+
* - Bare `alwaysOnTop` (the first implementation) floats the window over
|
|
43
|
+
* every other application on macOS and Windows, so a pinned Board meant
|
|
44
|
+
* nothing else on the machine could come to the front (a Storyletter user
|
|
45
|
+
* report, 2026-08-25).
|
|
46
|
+
* - A CHILD window (the second implementation) is position-coupled to its
|
|
47
|
+
* parent on macOS: dragging the editor dragged the Board with it (a
|
|
48
|
+
* Storyletter user report, 2026-08-28). Windows and Linux owned windows do
|
|
49
|
+
* not move with their owner, so the child mechanism stays right for them.
|
|
50
|
+
*
|
|
51
|
+
* So: on macOS the pin is ACTIVATION-SCOPED alwaysOnTop - floating while
|
|
52
|
+
* this app is active, dropped the moment it resigns - which is above the
|
|
53
|
+
* editor without following its drags and without covering anyone else. On
|
|
54
|
+
* Windows and Linux the pin is a child window, which stays over its parent
|
|
55
|
+
* and stacks normally against everything else. Each branch clears the other
|
|
56
|
+
* mechanism first, which also heals a window an older build pinned the old
|
|
57
|
+
* way. `platform` is a seam for the tests; leave it defaulted.
|
|
47
58
|
*/
|
|
48
|
-
declare function pinToolWindow(w: BrowserWindow | undefined | null, parent: BrowserWindow | undefined | null, on: boolean): void;
|
|
59
|
+
declare function pinToolWindow(w: BrowserWindow | undefined | null, parent: BrowserWindow | undefined | null, on: boolean, platform?: NodeJS.Platform): void;
|
|
49
60
|
interface ToolWindowOptions {
|
|
50
61
|
title: string;
|
|
51
62
|
/** Renderer entry, relative to rendererDir (e.g. "table.html", "search/index.html"). */
|
|
@@ -59,11 +70,11 @@ interface ToolWindowOptions {
|
|
|
59
70
|
/** false = frameless: the renderer draws its own slim drag bar (.swin-head). */
|
|
60
71
|
frame?: boolean;
|
|
61
72
|
/** The window to PIN ABOVE: the app's main window, resolved at open time (a
|
|
62
|
-
* getter, because the main window can be recreated). With `pinned` true
|
|
63
|
-
* new tool window
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
73
|
+
* getter, because the main window can be recreated). With `pinned` true
|
|
74
|
+
* the new tool window rides above it - on macOS via activation-scoped
|
|
75
|
+
* alwaysOnTop (a child window there follows the editor's drags), on
|
|
76
|
+
* Windows and Linux as a child window (bare alwaysOnTop there floats over
|
|
77
|
+
* the whole machine). See pinToolWindow for both findings. */
|
|
67
78
|
pinTo?: () => BrowserWindow | undefined;
|
|
68
79
|
/** Start pinned (the host remembers); flip later with `pinToolWindow`. */
|
|
69
80
|
pinned?: boolean;
|
package/dist/tool-window.d.ts
CHANGED
|
@@ -37,15 +37,26 @@ declare function centeredOnPrimary(size: ToolWindowSize): {
|
|
|
37
37
|
declare function rescueToolWindow(w: BrowserWindow | undefined | null, def: ToolWindowSize): void;
|
|
38
38
|
/**
|
|
39
39
|
* The pin: keep a tool window above THIS APP'S MAIN WINDOW, not above the
|
|
40
|
-
* world.
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
40
|
+
* world. Two findings shaped the mechanism, one per platform family:
|
|
41
|
+
*
|
|
42
|
+
* - Bare `alwaysOnTop` (the first implementation) floats the window over
|
|
43
|
+
* every other application on macOS and Windows, so a pinned Board meant
|
|
44
|
+
* nothing else on the machine could come to the front (a Storyletter user
|
|
45
|
+
* report, 2026-08-25).
|
|
46
|
+
* - A CHILD window (the second implementation) is position-coupled to its
|
|
47
|
+
* parent on macOS: dragging the editor dragged the Board with it (a
|
|
48
|
+
* Storyletter user report, 2026-08-28). Windows and Linux owned windows do
|
|
49
|
+
* not move with their owner, so the child mechanism stays right for them.
|
|
50
|
+
*
|
|
51
|
+
* So: on macOS the pin is ACTIVATION-SCOPED alwaysOnTop - floating while
|
|
52
|
+
* this app is active, dropped the moment it resigns - which is above the
|
|
53
|
+
* editor without following its drags and without covering anyone else. On
|
|
54
|
+
* Windows and Linux the pin is a child window, which stays over its parent
|
|
55
|
+
* and stacks normally against everything else. Each branch clears the other
|
|
56
|
+
* mechanism first, which also heals a window an older build pinned the old
|
|
57
|
+
* way. `platform` is a seam for the tests; leave it defaulted.
|
|
47
58
|
*/
|
|
48
|
-
declare function pinToolWindow(w: BrowserWindow | undefined | null, parent: BrowserWindow | undefined | null, on: boolean): void;
|
|
59
|
+
declare function pinToolWindow(w: BrowserWindow | undefined | null, parent: BrowserWindow | undefined | null, on: boolean, platform?: NodeJS.Platform): void;
|
|
49
60
|
interface ToolWindowOptions {
|
|
50
61
|
title: string;
|
|
51
62
|
/** Renderer entry, relative to rendererDir (e.g. "table.html", "search/index.html"). */
|
|
@@ -59,11 +70,11 @@ interface ToolWindowOptions {
|
|
|
59
70
|
/** false = frameless: the renderer draws its own slim drag bar (.swin-head). */
|
|
60
71
|
frame?: boolean;
|
|
61
72
|
/** The window to PIN ABOVE: the app's main window, resolved at open time (a
|
|
62
|
-
* getter, because the main window can be recreated). With `pinned` true
|
|
63
|
-
* new tool window
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
73
|
+
* getter, because the main window can be recreated). With `pinned` true
|
|
74
|
+
* the new tool window rides above it - on macOS via activation-scoped
|
|
75
|
+
* alwaysOnTop (a child window there follows the editor's drags), on
|
|
76
|
+
* Windows and Linux as a child window (bare alwaysOnTop there floats over
|
|
77
|
+
* the whole machine). See pinToolWindow for both findings. */
|
|
67
78
|
pinTo?: () => BrowserWindow | undefined;
|
|
68
79
|
/** Start pinned (the host remembers); flip later with `pinToolWindow`. */
|
|
69
80
|
pinned?: boolean;
|
package/dist/tool-window.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { screen, BrowserWindow } from 'electron';
|
|
1
|
+
import { screen, BrowserWindow, app } from 'electron';
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
|
|
4
4
|
// src/tool-window.ts
|
|
@@ -41,8 +41,35 @@ function rescueToolWindow(w, def) {
|
|
|
41
41
|
w.show();
|
|
42
42
|
w.moveTop();
|
|
43
43
|
}
|
|
44
|
-
|
|
44
|
+
var macPinned = /* @__PURE__ */ new Set();
|
|
45
|
+
var macActivationWired = false;
|
|
46
|
+
function wireMacActivation() {
|
|
47
|
+
if (macActivationWired) return;
|
|
48
|
+
macActivationWired = true;
|
|
49
|
+
app.on("did-become-active", () => {
|
|
50
|
+
for (const w of macPinned) if (!w.isDestroyed()) w.setAlwaysOnTop(true, "floating");
|
|
51
|
+
});
|
|
52
|
+
app.on("did-resign-active", () => {
|
|
53
|
+
for (const w of macPinned) if (!w.isDestroyed()) w.setAlwaysOnTop(false);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
function pinToolWindow(w, parent, on, platform = process.platform) {
|
|
45
57
|
if (!w || w.isDestroyed()) return;
|
|
58
|
+
if (platform === "darwin") {
|
|
59
|
+
w.setParentWindow(null);
|
|
60
|
+
if (on) {
|
|
61
|
+
if (!macPinned.has(w)) {
|
|
62
|
+
macPinned.add(w);
|
|
63
|
+
w.once("closed", () => macPinned.delete(w));
|
|
64
|
+
}
|
|
65
|
+
wireMacActivation();
|
|
66
|
+
w.setAlwaysOnTop(BrowserWindow.getFocusedWindow() !== null, "floating");
|
|
67
|
+
} else {
|
|
68
|
+
macPinned.delete(w);
|
|
69
|
+
w.setAlwaysOnTop(false);
|
|
70
|
+
}
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
46
73
|
w.setAlwaysOnTop(false);
|
|
47
74
|
w.setParentWindow(on && parent && !parent.isDestroyed() ? parent : null);
|
|
48
75
|
}
|
package/dist/tool-window.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/tool-window.ts"],"names":[],"mappings":"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","file":"tool-window.js","sourcesContent":["// ---------------------------------------------------------------------------\n// The tool-window kit, MAIN-PROCESS half: the detached helper-window\n// machinery both apps had grown four copies of (Patterpad's Play + Search +\n// Coverage, Storyletter's Board + Find). Remembered bounds with a\n// disconnected-monitor guard, debounced persistence, the Reset View rescue,\n// and the pinnable window factory they compose into.\n//\n// This module imports electron, so it ships as its own subpath\n// (@wildwinter/app-shell/tool-window) - never import it from renderer code.\n// The renderer half (pin button, .swin drag-bar grammar) lives in the root\n// export + tool-window.css.\n//\n// Two deliberate corrections over the Patterpad originals, for when it\n// migrates here: rescueToolWindow re-pins EVERY window it rescues (Patterpad\n// re-pinned only Play, so its store lied about Search + Coverage), and the\n// factory always wires closed-cleanup through an identity guard.\n// ---------------------------------------------------------------------------\n\nimport { BrowserWindow, screen } from \"electron\";\nimport { join } from \"node:path\";\n\nexport interface ToolWindowSize {\n width: number;\n height: number;\n}\n\nexport interface ToolWindowBounds {\n x?: number;\n y?: number;\n width: number;\n height: number;\n}\n\n/** The per-window store slice every app remembers: where it was, whether it\n * floats. (Patterpad's PlayWindowState, generalised.) */\nexport interface ToolWindowState {\n bounds?: ToolWindowBounds;\n pinned: boolean;\n}\n\n/** Use a remembered rect only if it still intersects a live display (a window\n * saved on a now-disconnected monitor must not open offscreen). Falls back\n * to the default size; a remembered size is kept even when the position is\n * dropped (Electron then centres it). */\nexport function savedWindowRect(\n saved: ToolWindowBounds | undefined,\n def: ToolWindowSize,\n min: ToolWindowSize,\n): ToolWindowBounds {\n if (!saved) return { ...def };\n const w = Math.max(min.width, saved.width), h = Math.max(min.height, saved.height);\n if (saved.x != null && saved.y != null) {\n const onScreen = screen.getAllDisplays().some((d) => {\n const a = d.workArea;\n return saved.x! + w > a.x + 40 && saved.x! < a.x + a.width - 40 && saved.y! + h > a.y + 20 && saved.y! < a.y + a.height - 20;\n });\n if (onScreen) return { x: saved.x, y: saved.y, width: w, height: h };\n }\n return { width: w, height: h };\n}\n\n/** Persist a helper window's bounds as the user moves / resizes / closes it\n * (debounced 400ms; close flushes synchronously). */\nexport function rememberBounds(w: BrowserWindow, write: (bounds: ToolWindowBounds) => void): void {\n const saveBounds = (): void => { if (!w.isDestroyed()) write(w.getBounds()); };\n let boundsTimer: ReturnType<typeof setTimeout> | undefined;\n const queueSave = (): void => { clearTimeout(boundsTimer); boundsTimer = setTimeout(saveBounds, 400); };\n w.on(\"resize\", queueSave);\n w.on(\"move\", queueSave);\n w.on(\"close\", () => { clearTimeout(boundsTimer); saveBounds(); });\n}\n\n/** Centre a rect on the primary display's work area. */\nexport function centeredOnPrimary(size: ToolWindowSize): { x: number; y: number } {\n const a = screen.getPrimaryDisplay().workArea;\n return { x: Math.round(a.x + (a.width - size.width) / 2), y: Math.round(a.y + (a.height - size.height) / 2) };\n}\n\n/** Reset View's per-window half: un-minimise, re-pin (always - the store\n * says pinned:true after a rescue, so the window must agree), default size,\n * centred on the primary display, shown. The host clears its remembered\n * bounds itself (that's a store concern). No focus steal: helpers show\n * behind the editor. */\nexport function rescueToolWindow(w: BrowserWindow | undefined | null, def: ToolWindowSize): void {\n if (!w || w.isDestroyed()) return;\n if (w.isMinimized()) w.restore();\n w.setBounds({ ...def, ...centeredOnPrimary(def) });\n w.show();\n // Raise within OUR stack. This used to setAlwaysOnTop(true), which on macOS\n // and Windows floats the window above every other APPLICATION - and stuck\n // that way, since nothing ever unset it.\n w.moveTop();\n}\n\n/**\n * The pin: keep a tool window above THIS APP'S MAIN WINDOW, not above the\n * world. `alwaysOnTop` was the first implementation and it floats the window\n * over every other application on macOS and Windows, so a pinned Board meant\n * nothing else on the machine could come to the front (a Storyletter user\n * report, 2026-08-25). A CHILD window is the semantic actually wanted on both\n * platforms: it stays over its parent and stacks normally against everything\n * else. Clearing any alwaysOnTop first also heals a window an older build\n * pinned the old way.\n */\nexport function pinToolWindow(\n w: BrowserWindow | undefined | null, parent: BrowserWindow | undefined | null, on: boolean,\n): void {\n if (!w || w.isDestroyed()) return;\n w.setAlwaysOnTop(false);\n w.setParentWindow(on && parent && !parent.isDestroyed() ? parent : null);\n}\n\nexport interface ToolWindowOptions {\n title: string;\n /** Renderer entry, relative to rendererDir (e.g. \"table.html\", \"search/index.html\"). */\n page: string;\n /** Absolute path of the built renderer dir (join(import.meta.dirname, \"../renderer\")). */\n rendererDir: string;\n /** Absolute path of the preload bridge. */\n preload: string;\n rect: ToolWindowBounds;\n min?: ToolWindowSize;\n /** false = frameless: the renderer draws its own slim drag bar (.swin-head). */\n frame?: boolean;\n /** The window to PIN ABOVE: the app's main window, resolved at open time (a\n * getter, because the main window can be recreated). With `pinned` true the\n * new tool window is parented to it, which keeps it above the editor and\n * stacks it normally against every other application - the semantic\n * `alwaysOnTop` (this option's late predecessor) got wrong on macOS and\n * Windows, where it floats over the whole machine. */\n pinTo?: () => BrowserWindow | undefined;\n /** Start pinned (the host remembers); flip later with `pinToolWindow`. */\n pinned?: boolean;\n /** Wire debounced bounds persistence into the host's store slice. */\n remember?: (bounds: ToolWindowBounds) => void;\n /** Fired when THIS window closes (identity-guarded; a stale close never\n * clears a newer window). Null the host's handle here. */\n onClosed?: () => void;\n}\n\n/** The pinnable detached-window factory (hardened webPreferences, one\n * preload, electron-vite's dev-URL/file split). Focuses and returns the\n * existing window when there is one; otherwise creates, wires, and loads. */\nexport function openToolWindow(existing: BrowserWindow | undefined | null, opts: ToolWindowOptions): BrowserWindow {\n if (existing && !existing.isDestroyed()) { existing.focus(); return existing; }\n const w = new BrowserWindow({\n ...opts.rect,\n ...(opts.min !== undefined ? { minWidth: opts.min.width, minHeight: opts.min.height } : {}),\n show: false,\n title: opts.title,\n ...(opts.frame !== undefined ? { frame: opts.frame } : {}),\n webPreferences: {\n contextIsolation: true, nodeIntegration: false, sandbox: true,\n preload: opts.preload,\n },\n });\n if (opts.pinTo !== undefined) pinToolWindow(w, opts.pinTo(), opts.pinned ?? false);\n if (opts.remember) rememberBounds(w, opts.remember);\n w.once(\"ready-to-show\", () => w.show());\n if (opts.onClosed) w.on(\"closed\", opts.onClosed);\n if (process.env[\"ELECTRON_RENDERER_URL\"]) void w.loadURL(`${process.env[\"ELECTRON_RENDERER_URL\"]}/${opts.page}`);\n else void w.loadFile(join(opts.rendererDir, opts.page));\n return w;\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/tool-window.ts"],"names":[],"mappings":"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","file":"tool-window.js","sourcesContent":["// ---------------------------------------------------------------------------\n// The tool-window kit, MAIN-PROCESS half: the detached helper-window\n// machinery both apps had grown four copies of (Patterpad's Play + Search +\n// Coverage, Storyletter's Board + Find). Remembered bounds with a\n// disconnected-monitor guard, debounced persistence, the Reset View rescue,\n// and the pinnable window factory they compose into.\n//\n// This module imports electron, so it ships as its own subpath\n// (@wildwinter/app-shell/tool-window) - never import it from renderer code.\n// The renderer half (pin button, .swin drag-bar grammar) lives in the root\n// export + tool-window.css.\n//\n// Two deliberate corrections over the Patterpad originals, for when it\n// migrates here: rescueToolWindow re-pins EVERY window it rescues (Patterpad\n// re-pinned only Play, so its store lied about Search + Coverage), and the\n// factory always wires closed-cleanup through an identity guard.\n// ---------------------------------------------------------------------------\n\nimport { BrowserWindow, app, screen } from \"electron\";\nimport { join } from \"node:path\";\n\nexport interface ToolWindowSize {\n width: number;\n height: number;\n}\n\nexport interface ToolWindowBounds {\n x?: number;\n y?: number;\n width: number;\n height: number;\n}\n\n/** The per-window store slice every app remembers: where it was, whether it\n * floats. (Patterpad's PlayWindowState, generalised.) */\nexport interface ToolWindowState {\n bounds?: ToolWindowBounds;\n pinned: boolean;\n}\n\n/** Use a remembered rect only if it still intersects a live display (a window\n * saved on a now-disconnected monitor must not open offscreen). Falls back\n * to the default size; a remembered size is kept even when the position is\n * dropped (Electron then centres it). */\nexport function savedWindowRect(\n saved: ToolWindowBounds | undefined,\n def: ToolWindowSize,\n min: ToolWindowSize,\n): ToolWindowBounds {\n if (!saved) return { ...def };\n const w = Math.max(min.width, saved.width), h = Math.max(min.height, saved.height);\n if (saved.x != null && saved.y != null) {\n const onScreen = screen.getAllDisplays().some((d) => {\n const a = d.workArea;\n return saved.x! + w > a.x + 40 && saved.x! < a.x + a.width - 40 && saved.y! + h > a.y + 20 && saved.y! < a.y + a.height - 20;\n });\n if (onScreen) return { x: saved.x, y: saved.y, width: w, height: h };\n }\n return { width: w, height: h };\n}\n\n/** Persist a helper window's bounds as the user moves / resizes / closes it\n * (debounced 400ms; close flushes synchronously). */\nexport function rememberBounds(w: BrowserWindow, write: (bounds: ToolWindowBounds) => void): void {\n const saveBounds = (): void => { if (!w.isDestroyed()) write(w.getBounds()); };\n let boundsTimer: ReturnType<typeof setTimeout> | undefined;\n const queueSave = (): void => { clearTimeout(boundsTimer); boundsTimer = setTimeout(saveBounds, 400); };\n w.on(\"resize\", queueSave);\n w.on(\"move\", queueSave);\n w.on(\"close\", () => { clearTimeout(boundsTimer); saveBounds(); });\n}\n\n/** Centre a rect on the primary display's work area. */\nexport function centeredOnPrimary(size: ToolWindowSize): { x: number; y: number } {\n const a = screen.getPrimaryDisplay().workArea;\n return { x: Math.round(a.x + (a.width - size.width) / 2), y: Math.round(a.y + (a.height - size.height) / 2) };\n}\n\n/** Reset View's per-window half: un-minimise, re-pin (always - the store\n * says pinned:true after a rescue, so the window must agree), default size,\n * centred on the primary display, shown. The host clears its remembered\n * bounds itself (that's a store concern). No focus steal: helpers show\n * behind the editor. */\nexport function rescueToolWindow(w: BrowserWindow | undefined | null, def: ToolWindowSize): void {\n if (!w || w.isDestroyed()) return;\n if (w.isMinimized()) w.restore();\n w.setBounds({ ...def, ...centeredOnPrimary(def) });\n w.show();\n // Raise within OUR stack. This used to setAlwaysOnTop(true), which on macOS\n // and Windows floats the window above every other APPLICATION - and stuck\n // that way, since nothing ever unset it.\n w.moveTop();\n}\n\n/** macOS pinned windows, for the activation toggle below. */\nconst macPinned = new Set<BrowserWindow>();\nlet macActivationWired = false;\nfunction wireMacActivation(): void {\n if (macActivationWired) return;\n macActivationWired = true;\n // Floating is a GLOBAL window level: left on while another app is front,\n // the pinned window would sit over that app too - the exact 2026-08-25\n // complaint. Scoping it to our app's active state keeps the pin meaning\n // \"above my editor\" and nothing more.\n app.on(\"did-become-active\", () => { for (const w of macPinned) if (!w.isDestroyed()) w.setAlwaysOnTop(true, \"floating\"); });\n app.on(\"did-resign-active\", () => { for (const w of macPinned) if (!w.isDestroyed()) w.setAlwaysOnTop(false); });\n}\n\n/**\n * The pin: keep a tool window above THIS APP'S MAIN WINDOW, not above the\n * world. Two findings shaped the mechanism, one per platform family:\n *\n * - Bare `alwaysOnTop` (the first implementation) floats the window over\n * every other application on macOS and Windows, so a pinned Board meant\n * nothing else on the machine could come to the front (a Storyletter user\n * report, 2026-08-25).\n * - A CHILD window (the second implementation) is position-coupled to its\n * parent on macOS: dragging the editor dragged the Board with it (a\n * Storyletter user report, 2026-08-28). Windows and Linux owned windows do\n * not move with their owner, so the child mechanism stays right for them.\n *\n * So: on macOS the pin is ACTIVATION-SCOPED alwaysOnTop - floating while\n * this app is active, dropped the moment it resigns - which is above the\n * editor without following its drags and without covering anyone else. On\n * Windows and Linux the pin is a child window, which stays over its parent\n * and stacks normally against everything else. Each branch clears the other\n * mechanism first, which also heals a window an older build pinned the old\n * way. `platform` is a seam for the tests; leave it defaulted.\n */\nexport function pinToolWindow(\n w: BrowserWindow | undefined | null, parent: BrowserWindow | undefined | null, on: boolean,\n platform: NodeJS.Platform = process.platform,\n): void {\n if (!w || w.isDestroyed()) return;\n if (platform === \"darwin\") {\n w.setParentWindow(null);\n if (on) {\n if (!macPinned.has(w)) {\n macPinned.add(w);\n w.once(\"closed\", () => macPinned.delete(w));\n }\n wireMacActivation();\n // Raise only when the app is active now; otherwise the next\n // did-become-active raises it.\n w.setAlwaysOnTop(BrowserWindow.getFocusedWindow() !== null, \"floating\");\n } else {\n macPinned.delete(w);\n w.setAlwaysOnTop(false);\n }\n return;\n }\n w.setAlwaysOnTop(false);\n w.setParentWindow(on && parent && !parent.isDestroyed() ? parent : null);\n}\n\nexport interface ToolWindowOptions {\n title: string;\n /** Renderer entry, relative to rendererDir (e.g. \"table.html\", \"search/index.html\"). */\n page: string;\n /** Absolute path of the built renderer dir (join(import.meta.dirname, \"../renderer\")). */\n rendererDir: string;\n /** Absolute path of the preload bridge. */\n preload: string;\n rect: ToolWindowBounds;\n min?: ToolWindowSize;\n /** false = frameless: the renderer draws its own slim drag bar (.swin-head). */\n frame?: boolean;\n /** The window to PIN ABOVE: the app's main window, resolved at open time (a\n * getter, because the main window can be recreated). With `pinned` true\n * the new tool window rides above it - on macOS via activation-scoped\n * alwaysOnTop (a child window there follows the editor's drags), on\n * Windows and Linux as a child window (bare alwaysOnTop there floats over\n * the whole machine). See pinToolWindow for both findings. */\n pinTo?: () => BrowserWindow | undefined;\n /** Start pinned (the host remembers); flip later with `pinToolWindow`. */\n pinned?: boolean;\n /** Wire debounced bounds persistence into the host's store slice. */\n remember?: (bounds: ToolWindowBounds) => void;\n /** Fired when THIS window closes (identity-guarded; a stale close never\n * clears a newer window). Null the host's handle here. */\n onClosed?: () => void;\n}\n\n/** The pinnable detached-window factory (hardened webPreferences, one\n * preload, electron-vite's dev-URL/file split). Focuses and returns the\n * existing window when there is one; otherwise creates, wires, and loads. */\nexport function openToolWindow(existing: BrowserWindow | undefined | null, opts: ToolWindowOptions): BrowserWindow {\n if (existing && !existing.isDestroyed()) { existing.focus(); return existing; }\n const w = new BrowserWindow({\n ...opts.rect,\n ...(opts.min !== undefined ? { minWidth: opts.min.width, minHeight: opts.min.height } : {}),\n show: false,\n title: opts.title,\n ...(opts.frame !== undefined ? { frame: opts.frame } : {}),\n webPreferences: {\n contextIsolation: true, nodeIntegration: false, sandbox: true,\n preload: opts.preload,\n },\n });\n if (opts.pinTo !== undefined) pinToolWindow(w, opts.pinTo(), opts.pinned ?? false);\n if (opts.remember) rememberBounds(w, opts.remember);\n w.once(\"ready-to-show\", () => w.show());\n if (opts.onClosed) w.on(\"closed\", opts.onClosed);\n if (process.env[\"ELECTRON_RENDERER_URL\"]) void w.loadURL(`${process.env[\"ELECTRON_RENDERER_URL\"]}/${opts.page}`);\n else void w.loadFile(join(opts.rendererDir, opts.page));\n return w;\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wildwinter/app-shell",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.1",
|
|
4
4
|
"description": "The shared shell for the editor family (Patterpad, Storylet Studio): the app-agnostic design-token grammar, the per-entity colour hash, and the vanilla-TS DOM primitive. Two palettes, one grammar - each app supplies its own palette/fonts over these tokens.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|