@wildwinter/app-shell 0.34.0 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -136,7 +136,7 @@ body.pane-resizing .panes {
136
136
  border-radius: var(--radius-sm);
137
137
  background: none;
138
138
  color: var(--muted);
139
- font-size: 1.05rem;
139
+ font-size: 0.95rem;
140
140
  line-height: 1;
141
141
  cursor: pointer;
142
142
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/pane-shell.css"],"sourcesContent":["/* ============================================================================\n The pane-shell frame: top bar + nav | centre | inspector grid with collapse\n and drag-resize. Reads the shared token grammar (tokens.css) and the app's\n palette vars (--surface / --ink / --muted / --line / --accent, and an\n optional --line-soft). The host fills the pane bodies and the top-bar\n lead/trail slots.\n ============================================================================ */\n\n.pane-shell { flex: 1; display: flex; flex-direction: column; min-height: 0; }\n\n.topbar {\n display: flex; align-items: center; gap: 10px;\n padding: 8px 14px;\n border-bottom: 1px solid var(--line-soft, var(--line));\n background: var(--surface);\n}\n.topbar-lead { display: flex; align-items: center; gap: 8px; min-width: 0; }\n.topbar-spacer { flex: 1; }\n.topbar-trail { display: flex; align-items: center; gap: 10px; }\n.pane-toggle {\n color: var(--muted); font: inherit; font-size: 0.95rem;\n background: none; border: 0; border-radius: var(--radius-sm); padding: 2px 8px; cursor: pointer;\n}\n.pane-toggle:hover { color: var(--accent); }\n\n/* --- the fused title bar (the family ruling, 2026-08-27) --------------------\n On macOS the topbar IS the window's title bar: the app's main window uses\n titleBarStyle \"hiddenInset\" and marks the bar with these classes, so one\n row carries the traffic lights, the identity and the top-level controls\n instead of a native strip whose only cargo is a title. `titlebar` makes the\n bar the drag surface (its controls stay clickable; add `no-drag` to any\n other interactive thing the host mounts); `titlebar-inset` leaves room for\n the traffic lights and is added ONLY on macOS - elsewhere the window keeps\n its native frame and the same bar sits beneath it, identically in every\n app of the family. */\n.topbar.titlebar { -webkit-app-region: drag; }\n.topbar.titlebar :is(button, input, select, a, .no-drag) { -webkit-app-region: no-drag; }\n.topbar.titlebar-inset { padding-left: 84px; }\n\n.panes {\n --nav-open-w: 14rem; --insp-open-w: 22rem;\n --nav-w: var(--nav-open-w); --insp-w: var(--insp-open-w);\n position: relative; flex: 1; min-height: 0;\n display: grid; grid-template-columns: var(--nav-w) minmax(0, 1fr) var(--insp-w);\n transition: grid-template-columns var(--dur-panel) var(--ease-standard);\n}\n.panes.no-nav { --nav-w: 0px; }\n.panes.no-inspector { --insp-w: 0px; }\n\n/* Side panes clip; their inner holds a fixed width so a collapse wipes cleanly\n rather than reflowing while the column animates to 0. */\n.pane { overflow: hidden; min-height: 0; }\n.pane-inner { height: 100%; overflow: auto; }\n.pane-nav .pane-inner { width: var(--nav-open-w); }\n.pane-inspector .pane-inner { width: var(--insp-open-w); }\n.pane-centre { min-width: 0; min-height: 0; overflow: auto; }\n\n.panes > .pane { transition: opacity var(--dur-panel) var(--ease-standard); }\n.panes.no-nav > .pane-nav, .panes.no-inspector > .pane-inspector { opacity: 0; pointer-events: none; }\n\n/* Drag seams over each side pane's inner edge, tracking the grid columns. */\n.pane-resizer { position: absolute; top: 0; bottom: 0; width: 7px; z-index: 6; cursor: col-resize; touch-action: none; }\n.pane-resizer-nav { left: calc(var(--nav-w) - 3px); }\n.pane-resizer-insp { right: calc(var(--insp-w) - 3px); }\n.panes.no-nav .pane-resizer-nav, .panes.no-inspector .pane-resizer-insp { display: none; }\nbody.pane-resizing { cursor: col-resize; user-select: none; }\nbody.pane-resizing .panes { transition: none; } /* 1:1 feel: suspend the grid tween mid-drag */\n\n/* --- the back/forward pair (nav-history.ts) --------------------------------\n VS Code's quietness: two plain chevrons, borderless, greyed where there is\n nowhere to go. They sit to the LEFT of the app's normal navigation (the\n crumb trail here, whatever the host's equivalent is), so they read as part\n of the chrome, not a control demanding attention. */\n.shell-histnav { display: inline-flex; gap: 2px; margin-right: 6px; }\n.shell-histnav-btn { display: inline-flex; align-items: center; justify-content: center; width: 22px; height: 22px; padding: 0; border: none; border-radius: var(--radius-sm); background: none; color: var(--muted); font-size: 1.05rem; line-height: 1; cursor: pointer; }\n.shell-histnav-btn:hover:not(:disabled) { color: var(--ink); background: var(--accent-soft); }\n.shell-histnav-btn:disabled { opacity: 0.35; cursor: default; }\n"],"mappings":";AAQA,CAAC;AAAa,QAAM;AAAG,WAAS;AAAM,kBAAgB;AAAQ,cAAY;AAAG;AAE7E,CAAC;AACC,WAAS;AAAM,eAAa;AAAQ,OAAK;AACzC,WAAS,IAAI;AACb,iBAAe,IAAI,MAAM,IAAI,WAAW,EAAE,IAAI;AAC9C,cAAY,IAAI;AAClB;AACA,CAAC;AAAc,WAAS;AAAM,eAAa;AAAQ,OAAK;AAAK,aAAW;AAAG;AAC3E,CAAC;AAAgB,QAAM;AAAG;AAC1B,CAAC;AAAe,WAAS;AAAM,eAAa;AAAQ,OAAK;AAAM;AAC/D,CAAC;AACC,SAAO,IAAI;AAAU,QAAM;AAAS,aAAW;AAC/C,cAAY;AAAM,UAAQ;AAAG,iBAAe,IAAI;AAAc,WAAS,IAAI;AAAK,UAAQ;AAC1F;AACA,CAJC,WAIW;AAAS,SAAO,IAAI;AAAW;AAY3C,CAzBC,MAyBM,CAAC;AAAW,sBAAoB;AAAM;AAC7C,CA1BC,MA0BM,CADC,SACS,IAAI,QAAQ,OAAO,QAAQ,GAAG,CAAC;AAAW,sBAAoB;AAAS;AACxF,CA3BC,MA2BM,CAAC;AAAiB,gBAAc;AAAM;AAE7C,CAAC;AACC,gBAAc;AAAO,iBAAe;AACpC,WAAS,IAAI;AAAe,YAAU,IAAI;AAC1C,YAAU;AAAU,QAAM;AAAG,cAAY;AACzC,WAAS;AAAM,yBAAuB,IAAI,SAAS,OAAO,CAAC,EAAE,KAAK,IAAI;AACtE,cAAY,sBAAsB,IAAI,aAAa,IAAI;AACzD;AACA,CAPC,KAOK,CAAC;AAAS,WAAS;AAAK;AAC9B,CARC,KAQK,CAAC;AAAe,YAAU;AAAK;AAIrC,CAAC;AAAO,YAAU;AAAQ,cAAY;AAAG;AACzC,CAAC;AAAa,UAAQ;AAAM,YAAU;AAAM;AAC5C,CAAC,SAAS,CADT;AACuB,SAAO,IAAI;AAAe;AAClD,CAAC,eAAe,CAFf;AAE6B,SAAO,IAAI;AAAgB;AACzD,CAAC;AAAc,aAAW;AAAG,cAAY;AAAG,YAAU;AAAM;AAE5D,CAlBC,MAkBM,EAAE,CANR;AAMgB,cAAY,QAAQ,IAAI,aAAa,IAAI;AAAkB;AAC5E,CAnBC,KAmBK,CAZC,OAYO,EAAE,CALf;AAK0B,CAnB1B,KAmBgC,CAX1B,aAWwC,EAAE,CAJhD;AAIkE,WAAS;AAAG,kBAAgB;AAAM;AAGrG,CAAC;AAAe,YAAU;AAAU,OAAK;AAAG,UAAQ;AAAG,SAAO;AAAK,WAAS;AAAG,UAAQ;AAAY,gBAAc;AAAM;AACvH,CAAC;AAAmB,QAAM,KAAK,IAAI,SAAS,EAAE;AAAM;AACpD,CAAC;AAAoB,SAAO,KAAK,IAAI,UAAU,EAAE;AAAM;AACvD,CAzBC,KAyBK,CAlBC,OAkBO,CAFb;AAEgC,CAzBhC,KAyBsC,CAjBhC,aAiB8C,CADpD;AACyE,WAAS;AAAM;AACzF,IAAI,CAAC;AAAgB,UAAQ;AAAY,eAAa;AAAM;AAC5D,IAAI,CADC,cACc,CA3BlB;AA2B2B,cAAY;AAAM;AAO9C,CAAC;AAAgB,WAAS;AAAa,OAAK;AAAK,gBAAc;AAAK;AACpE,CAAC;AAAoB,WAAS;AAAa,eAAa;AAAQ,mBAAiB;AAAQ,SAAO;AAAM,UAAQ;AAAM,WAAS;AAAG,UAAQ;AAAM,iBAAe,IAAI;AAAc,cAAY;AAAM,SAAO,IAAI;AAAU,aAAW;AAAS,eAAa;AAAG,UAAQ;AAAS;AAC3Q,CADC,iBACiB,MAAM,KAAK;AAAa,SAAO,IAAI;AAAQ,cAAY,IAAI;AAAgB;AAC7F,CAFC,iBAEiB;AAAY,WAAS;AAAM,UAAQ;AAAS;","names":[]}
1
+ {"version":3,"sources":["../src/pane-shell.css"],"sourcesContent":["/* ============================================================================\n The pane-shell frame: top bar + nav | centre | inspector grid with collapse\n and drag-resize. Reads the shared token grammar (tokens.css) and the app's\n palette vars (--surface / --ink / --muted / --line / --accent, and an\n optional --line-soft). The host fills the pane bodies and the top-bar\n lead/trail slots.\n ============================================================================ */\n\n.pane-shell { flex: 1; display: flex; flex-direction: column; min-height: 0; }\n\n.topbar {\n display: flex; align-items: center; gap: 10px;\n padding: 8px 14px;\n border-bottom: 1px solid var(--line-soft, var(--line));\n background: var(--surface);\n}\n.topbar-lead { display: flex; align-items: center; gap: 8px; min-width: 0; }\n.topbar-spacer { flex: 1; }\n.topbar-trail { display: flex; align-items: center; gap: 10px; }\n.pane-toggle {\n color: var(--muted); font: inherit; font-size: 0.95rem;\n background: none; border: 0; border-radius: var(--radius-sm); padding: 2px 8px; cursor: pointer;\n}\n.pane-toggle:hover { color: var(--accent); }\n\n/* --- the fused title bar (the family ruling, 2026-08-27) --------------------\n On macOS the topbar IS the window's title bar: the app's main window uses\n titleBarStyle \"hiddenInset\" and marks the bar with these classes, so one\n row carries the traffic lights, the identity and the top-level controls\n instead of a native strip whose only cargo is a title. `titlebar` makes the\n bar the drag surface (its controls stay clickable; add `no-drag` to any\n other interactive thing the host mounts); `titlebar-inset` leaves room for\n the traffic lights and is added ONLY on macOS - elsewhere the window keeps\n its native frame and the same bar sits beneath it, identically in every\n app of the family. */\n.topbar.titlebar { -webkit-app-region: drag; }\n.topbar.titlebar :is(button, input, select, a, .no-drag) { -webkit-app-region: no-drag; }\n.topbar.titlebar-inset { padding-left: 84px; }\n\n.panes {\n --nav-open-w: 14rem; --insp-open-w: 22rem;\n --nav-w: var(--nav-open-w); --insp-w: var(--insp-open-w);\n position: relative; flex: 1; min-height: 0;\n display: grid; grid-template-columns: var(--nav-w) minmax(0, 1fr) var(--insp-w);\n transition: grid-template-columns var(--dur-panel) var(--ease-standard);\n}\n.panes.no-nav { --nav-w: 0px; }\n.panes.no-inspector { --insp-w: 0px; }\n\n/* Side panes clip; their inner holds a fixed width so a collapse wipes cleanly\n rather than reflowing while the column animates to 0. */\n.pane { overflow: hidden; min-height: 0; }\n.pane-inner { height: 100%; overflow: auto; }\n.pane-nav .pane-inner { width: var(--nav-open-w); }\n.pane-inspector .pane-inner { width: var(--insp-open-w); }\n.pane-centre { min-width: 0; min-height: 0; overflow: auto; }\n\n.panes > .pane { transition: opacity var(--dur-panel) var(--ease-standard); }\n.panes.no-nav > .pane-nav, .panes.no-inspector > .pane-inspector { opacity: 0; pointer-events: none; }\n\n/* Drag seams over each side pane's inner edge, tracking the grid columns. */\n.pane-resizer { position: absolute; top: 0; bottom: 0; width: 7px; z-index: 6; cursor: col-resize; touch-action: none; }\n.pane-resizer-nav { left: calc(var(--nav-w) - 3px); }\n.pane-resizer-insp { right: calc(var(--insp-w) - 3px); }\n.panes.no-nav .pane-resizer-nav, .panes.no-inspector .pane-resizer-insp { display: none; }\nbody.pane-resizing { cursor: col-resize; user-select: none; }\nbody.pane-resizing .panes { transition: none; } /* 1:1 feel: suspend the grid tween mid-drag */\n\n/* --- the back/forward pair (nav-history.ts) --------------------------------\n VS Code's quietness: two plain chevrons, borderless, greyed where there is\n nowhere to go. They sit to the LEFT of the app's normal navigation (the\n crumb trail here, whatever the host's equivalent is), so they read as part\n of the chrome, not a control demanding attention. */\n.shell-histnav { display: inline-flex; gap: 2px; margin-right: 6px; }\n.shell-histnav-btn { display: inline-flex; align-items: center; justify-content: center; width: 22px; height: 22px; padding: 0; border: none; border-radius: var(--radius-sm); background: none; color: var(--muted); font-size: 0.95rem; line-height: 1; cursor: pointer; }\n.shell-histnav-btn:hover:not(:disabled) { color: var(--ink); background: var(--accent-soft); }\n.shell-histnav-btn:disabled { opacity: 0.35; cursor: default; }\n"],"mappings":";AAQA,CAAC;AAAa,QAAM;AAAG,WAAS;AAAM,kBAAgB;AAAQ,cAAY;AAAG;AAE7E,CAAC;AACC,WAAS;AAAM,eAAa;AAAQ,OAAK;AACzC,WAAS,IAAI;AACb,iBAAe,IAAI,MAAM,IAAI,WAAW,EAAE,IAAI;AAC9C,cAAY,IAAI;AAClB;AACA,CAAC;AAAc,WAAS;AAAM,eAAa;AAAQ,OAAK;AAAK,aAAW;AAAG;AAC3E,CAAC;AAAgB,QAAM;AAAG;AAC1B,CAAC;AAAe,WAAS;AAAM,eAAa;AAAQ,OAAK;AAAM;AAC/D,CAAC;AACC,SAAO,IAAI;AAAU,QAAM;AAAS,aAAW;AAC/C,cAAY;AAAM,UAAQ;AAAG,iBAAe,IAAI;AAAc,WAAS,IAAI;AAAK,UAAQ;AAC1F;AACA,CAJC,WAIW;AAAS,SAAO,IAAI;AAAW;AAY3C,CAzBC,MAyBM,CAAC;AAAW,sBAAoB;AAAM;AAC7C,CA1BC,MA0BM,CADC,SACS,IAAI,QAAQ,OAAO,QAAQ,GAAG,CAAC;AAAW,sBAAoB;AAAS;AACxF,CA3BC,MA2BM,CAAC;AAAiB,gBAAc;AAAM;AAE7C,CAAC;AACC,gBAAc;AAAO,iBAAe;AACpC,WAAS,IAAI;AAAe,YAAU,IAAI;AAC1C,YAAU;AAAU,QAAM;AAAG,cAAY;AACzC,WAAS;AAAM,yBAAuB,IAAI,SAAS,OAAO,CAAC,EAAE,KAAK,IAAI;AACtE,cAAY,sBAAsB,IAAI,aAAa,IAAI;AACzD;AACA,CAPC,KAOK,CAAC;AAAS,WAAS;AAAK;AAC9B,CARC,KAQK,CAAC;AAAe,YAAU;AAAK;AAIrC,CAAC;AAAO,YAAU;AAAQ,cAAY;AAAG;AACzC,CAAC;AAAa,UAAQ;AAAM,YAAU;AAAM;AAC5C,CAAC,SAAS,CADT;AACuB,SAAO,IAAI;AAAe;AAClD,CAAC,eAAe,CAFf;AAE6B,SAAO,IAAI;AAAgB;AACzD,CAAC;AAAc,aAAW;AAAG,cAAY;AAAG,YAAU;AAAM;AAE5D,CAlBC,MAkBM,EAAE,CANR;AAMgB,cAAY,QAAQ,IAAI,aAAa,IAAI;AAAkB;AAC5E,CAnBC,KAmBK,CAZC,OAYO,EAAE,CALf;AAK0B,CAnB1B,KAmBgC,CAX1B,aAWwC,EAAE,CAJhD;AAIkE,WAAS;AAAG,kBAAgB;AAAM;AAGrG,CAAC;AAAe,YAAU;AAAU,OAAK;AAAG,UAAQ;AAAG,SAAO;AAAK,WAAS;AAAG,UAAQ;AAAY,gBAAc;AAAM;AACvH,CAAC;AAAmB,QAAM,KAAK,IAAI,SAAS,EAAE;AAAM;AACpD,CAAC;AAAoB,SAAO,KAAK,IAAI,UAAU,EAAE;AAAM;AACvD,CAzBC,KAyBK,CAlBC,OAkBO,CAFb;AAEgC,CAzBhC,KAyBsC,CAjBhC,aAiB8C,CADpD;AACyE,WAAS;AAAM;AACzF,IAAI,CAAC;AAAgB,UAAQ;AAAY,eAAa;AAAM;AAC5D,IAAI,CADC,cACc,CA3BlB;AA2B2B,cAAY;AAAM;AAO9C,CAAC;AAAgB,WAAS;AAAa,OAAK;AAAK,gBAAc;AAAK;AACpE,CAAC;AAAoB,WAAS;AAAa,eAAa;AAAQ,mBAAiB;AAAQ,SAAO;AAAM,UAAQ;AAAM,WAAS;AAAG,UAAQ;AAAM,iBAAe,IAAI;AAAc,cAAY;AAAM,SAAO,IAAI;AAAU,aAAW;AAAS,eAAa;AAAG,UAAQ;AAAS;AAC3Q,CADC,iBACiB,MAAM,KAAK;AAAa,SAAO,IAAI;AAAQ,cAAY,IAAI;AAAgB;AAC7F,CAFC,iBAEiB;AAAY,WAAS;AAAM,UAAQ;AAAS;","names":[]}
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)];
@@ -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"]}
@@ -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)];
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wildwinter/app-shell",
3
- "version": "0.34.0",
3
+ "version": "0.35.0",
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",