flexdesk 0.6.3 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flexdesk",
3
- "version": "0.6.3",
3
+ "version": "0.8.0",
4
4
  "description": "A tiling window manager and widget set for desktop-class web applications. Zero runtime dependencies.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -27,7 +27,9 @@
27
27
  * ══ GESTURES ════════════════════════════════════════════════════════════
28
28
  *
29
29
  * A press SELECTS — it toggles classes and fills the settings panel, and
30
- * rebuilds no row, so the click lands on the node the press went down on.
30
+ * rebuilds no row, so the click lands on the node the press went down on. In
31
+ * a narrow editor, where the panel is stacked under the list, a press (like
32
+ * Enter, Go to it and `select()`) scrolls the panel on screen when it is off it.
31
33
  * "+" between rows (on hover AND on focus), the add rows and a row's menu
32
34
  * open the step picker and the step menu, both the kit's popovers. A step is
33
35
  * dragged by its grip with POINTER EVENTS and a ghost on `document.body`
@@ -528,7 +530,7 @@ export function createOutlineEditor(host, options = {}) {
528
530
  if (fs.length) row.appendChild(badge(fs));
529
531
  item.appendChild(row);
530
532
  item.setAttribute('aria-label', [map.startLabel(S), sum].filter(Boolean).join(', '));
531
- row.addEventListener('click', () => select('flow', { focus: true }));
533
+ row.addEventListener('click', () => select('flow', { focus: true, picked: true }));
532
534
  if (filter) item.classList.add('twm-flow-outline__item--context');
533
535
  itemEls.set('flow', item);
534
536
  return item;
@@ -645,7 +647,7 @@ export function createOutlineEditor(host, options = {}) {
645
647
  row.appendChild(menu);
646
648
  row.addEventListener('contextmenu', (ev) => { ev.preventDefault(); select(item.id); openMenu(item.id, menu); });
647
649
  }
648
- row.addEventListener('click', () => select(item.id, { focus: true }));
650
+ row.addEventListener('click', () => select(item.id, { focus: true, picked: true }));
649
651
  return { row, label: [name, type?.label !== name ? type?.label : null, summary].filter(Boolean).join(', ') };
650
652
  }
651
653
 
@@ -836,7 +838,13 @@ export function createOutlineEditor(host, options = {}) {
836
838
  }
837
839
  }
838
840
 
839
- function select(id, { focus = false, silent = false } = {}) {
841
+ /**
842
+ * `picked`: the step was PICKED — a press on its row, Go to it, `select()`
843
+ * — so its settings are what the person looks at next, and they are
844
+ * brought on screen (`revealSettings`). ↑ ↓ ← → only move the selection
845
+ * through the list and leave the list where it is.
846
+ */
847
+ function select(id, { focus = false, silent = false, picked = false } = {}) {
840
848
  const next = id === null || id === undefined ? 'flow' : id;
841
849
  const changed = next !== selected;
842
850
  selected = next;
@@ -844,9 +852,33 @@ export function createOutlineEditor(host, options = {}) {
844
852
  if (changed) { showPanel(); drawAside(); }
845
853
  void silent;
846
854
  if (focus) itemEls.get(selected)?.focus({ preventScroll: false });
855
+ if (picked) revealSettings();
847
856
  if (changed) onSelect?.({ kind: selected === 'flow' ? 'flow' : 'step', id: selected === 'flow' ? null : selected });
848
857
  }
849
858
 
859
+ /**
860
+ * BRING THE SETTINGS ON SCREEN when the layout has put them off it — the
861
+ * one rule for Enter, a press on a row, Go to it and `select()`. Under
862
+ * 1000px the columns wrap and the body scrolls as one (the container query
863
+ * in the sheet), so the panel sits under the whole list: a step picked
864
+ * there filled a panel ~860px below with nothing scrolled, and the press
865
+ * looked like it did nothing. A panel wholly inside the body's visible box
866
+ * — always, side by side, where the panel IS the body's height — or one
867
+ * that covers it is left alone, so nothing moves, the page included;
868
+ * otherwise it is scrolled into view, 'nearest' (all of it when it fits,
869
+ * its top when it does not).
870
+ */
871
+ function revealSettings() {
872
+ if (destroyed) return;
873
+ const box = body.getBoundingClientRect();
874
+ if (!box.height) return; // not laid out: hidden, detached
875
+ const r = settingsHost.getBoundingClientRect();
876
+ const inside = r.top >= box.top - 1 && r.bottom <= box.bottom + 1;
877
+ const covers = r.top <= box.top + 1 && r.bottom >= box.bottom - 1;
878
+ if (inside || covers) return;
879
+ settingsHost.scrollIntoView?.({ block: 'nearest' });
880
+ }
881
+
850
882
  function focusSelected() {
851
883
  const li = itemEls.get(selected) ?? itemEls.get('flow');
852
884
  if (li) li.focus({ preventScroll: true });
@@ -1152,7 +1184,8 @@ export function createOutlineEditor(host, options = {}) {
1152
1184
  }
1153
1185
 
1154
1186
  function rename(id) {
1155
- select(id);
1187
+ // The title is focused without scrolling, so it is brought on screen first.
1188
+ select(id, { picked: true });
1156
1189
  const input = settingsHost.querySelector('[data-twm-flow-title]');
1157
1190
  if (input) { input.focus({ preventScroll: true }); input.select?.(); }
1158
1191
  }
@@ -1168,7 +1201,7 @@ export function createOutlineEditor(host, options = {}) {
1168
1201
  function goTo(f) {
1169
1202
  const id = f?.node_id && nodeOf(f.node_id) ? f.node_id : 'flow';
1170
1203
  if (reveal(id)) render();
1171
- select(id, { focus: true });
1204
+ select(id, { focus: true, picked: true });
1172
1205
  if (f?.field) panel.focusField(f.field);
1173
1206
  }
1174
1207
 
@@ -1208,6 +1241,7 @@ export function createOutlineEditor(host, options = {}) {
1208
1241
  const f = settingsHost.querySelector('.twm-flow-panel__fields input, .twm-flow-panel__fields select, '
1209
1242
  + '.twm-flow-panel__fields textarea, .twm-flow-panel__fields [contenteditable="true"], .twm-flow-panel__fields button')
1210
1243
  || settingsHost.querySelector('[data-twm-flow-title]');
1244
+ if (f) revealSettings();
1211
1245
  f?.focus({ preventScroll: false });
1212
1246
  return Boolean(f);
1213
1247
  },
@@ -1477,11 +1511,15 @@ export function createOutlineEditor(host, options = {}) {
1477
1511
  updateTools();
1478
1512
  },
1479
1513
  setStatus(text) { statusEl.textContent = text ?? ''; },
1480
- /** Select a step (unfolding the blocks around it, so it is on the screen), the flow, or nothing. */
1514
+ /**
1515
+ * Select a step (unfolding the blocks around it, so it is on the
1516
+ * screen), the flow, or nothing — and, in a narrow editor whose panel
1517
+ * is scrolled off, bring its settings on screen as a press does.
1518
+ */
1481
1519
  select(id) {
1482
1520
  const want = id === null || id === undefined ? 'flow' : id;
1483
1521
  if (want !== 'flow' && reveal(want)) render();
1484
- select(want);
1522
+ select(want, { picked: true });
1485
1523
  },
1486
1524
  focus() { focusSelected(); },
1487
1525
  undo() { if (editable() && history.undo()) emit('undo'); },
@@ -95,13 +95,17 @@ import { openForm } from '../ui/components/modal.js';
95
95
  * only the tab on screen, leaving its
96
96
  * siblings in the tile. Default off: the
97
97
  * whole pane floats, strip and all (R8).
98
- * @param {'top'|'bottom'} [cfg.tabLayout] C22. Where a multi-tab leaf draws its
98
+ * @param {'top'|'bottom'|'header'} [cfg.tabLayout] C22. Where a multi-tab leaf
99
+ * draws its
99
100
  * tabs. `'bottom'` is the framework's own
100
101
  * spreadsheet strip under the tile body
101
102
  * and is the DEFAULT, so no existing
102
103
  * embedder's panes rearrange on upgrade;
103
104
  * `'top'` mounts the editor tab bar
104
- * between the chrome and the body. The
105
+ * between the chrome and the body;
106
+ * `'header'` (C38) mounts it inside the
107
+ * chrome in place of the title, on every
108
+ * tile, with a `+` for a new tab. The
105
109
  * renderer mirrors this onto `root` as
106
110
  * `data-twm-tabs` and watches it, so an
107
111
  * embedder whose settings pane holds only
@@ -22,6 +22,34 @@
22
22
  * tab strip, mounted BETWEEN the chrome and the body. The chrome
23
23
  * is the pane's identity — its glyph, its title, its verbs — and
24
24
  * the tabs are its contents, so the tabs go under it, not over.
25
+ * `header` — C38. The same `NotebookTabBar`, mounted INSIDE the chrome in
26
+ * place of the title, with a `+` after the last tab. One strip
27
+ * instead of two, so a pane spends one row on chrome, not two.
28
+ *
29
+ * ── C38. THE TABS ARE THE TITLE ─────────────────────────────────────────
30
+ *
31
+ * *"Replace the title with one element with the tab — the title is always in
32
+ * the tabs and we always can create more tabs. This is a more compact layout."*
33
+ *
34
+ * Under `header` the strip is drawn for EVERY content leaf, one tab included,
35
+ * because it is now the only place the pane's name appears. The chrome's own
36
+ * title and glyph are hidden by a wrap class (`twm-leaf--tabs-in-header`), not
37
+ * removed: a panel leaf has no tabs to draw, keeps its title, and a switch
38
+ * back to another layout finds the title element where it left it.
39
+ *
40
+ * The active tab's label is the CONTENT's title when the factory returned one
41
+ * — the string the chrome title used to show — so nothing the title said is
42
+ * lost by moving it into the tab.
43
+ *
44
+ * `+` appends a tab holding the taxonomy root, which is what a split seeds the
45
+ * new pane with (`wm.newTab`). It is a button, so `CHROME_NO_FLOAT` already
46
+ * makes it inert to the pull and the double-click.
47
+ *
48
+ * The chrome stops being a drag source here (C33): a single-tab leaf now has a
49
+ * tab to grab, and a strip that names the tab is the reason C33 limited the
50
+ * chrome to the case where there was none. A drag that begins on a tab
51
+ * belongs to the strip, so the chrome's `dragstart` lets it pass untouched.
52
+ * The empty chrome around the tabs still takes the pull and the double-click.
25
53
  *
26
54
  * The tab bar is reused rather than reimplemented. It already has the
27
55
  * behaviours a top strip is expected to have — drag-to-reorder, a scrolling
@@ -153,9 +181,9 @@
153
181
  import { NotebookTabBar } from '../editor/notebook_tab_bar.js';
154
182
 
155
183
  const SPLITTER_PX = 4;
156
- /** The two tab layouts, and the framework's default. `bottom` is the default
184
+ /** The three tab layouts, and the framework's default. `bottom` is the default
157
185
  * because it is what every existing embedder already renders. */
158
- const TAB_LAYOUTS = ['bottom', 'top'];
186
+ const TAB_LAYOUTS = ['bottom', 'top', 'header'];
159
187
  const DEFAULT_TAB_LAYOUT = 'bottom';
160
188
 
161
189
  /** Anything that is not one of the two layouts is the default rather than an
@@ -195,8 +223,9 @@ const TILE_LIFT_PX = 24;
195
223
  *
196
224
  * A tile's chrome is a grip with controls sitting on it — the split, float
197
225
  * and close buttons on the right, whatever the content contributed on the
198
- * left, and (defensively; the strip is a sibling of the chrome today, not a
199
- * child) a tab. Neither the downward pull nor the double-click may fire when
226
+ * left, and a tab — the strip is a CHILD of the chrome under the `header`
227
+ * layout (C38), and a sibling of it otherwise. Neither the downward pull nor
228
+ * the double-click may fire when
200
229
  * the press landed on one of those.
201
230
  *
202
231
  * ONE CONSTANT, TWO LISTENERS, and that is the point of writing it down
@@ -204,7 +233,7 @@ const TILE_LIFT_PX = 24;
204
233
  * about where the door is are worse than one door. A button added to this
205
234
  * strip later must become inert under BOTH gestures at once, and it cannot
206
235
  * do that if each of them carries its own copy of the rule. */
207
- const CHROME_NO_FLOAT = 'button, .twm-leaf__tab';
236
+ const CHROME_NO_FLOAT = 'button, .twm-leaf__tab, .notebook-tabs .tab';
208
237
 
209
238
  /**
210
239
  * IS THIS GESTURE THE DOWNWARD PULL? Asked in TWO places that must not be
@@ -498,6 +527,11 @@ export class TileRenderer {
498
527
  // that can change while the content stays put has to be refreshed
499
528
  // here or it is frozen at whatever it was when the tile was built.
500
529
  _paintLeafIcon(entry.iconEl, leaf, entry.content, this.ctx);
530
+ // C38. …and under `header` the tabs ARE the title, so a renamed
531
+ // tab is a renamed title and has to be refreshed for the same
532
+ // reason. Only when a label changed: rebuilding the strip's tabs
533
+ // on every repaint would detach a tab mid-drag.
534
+ if (this.tabLayout === 'header') this._refreshHeaderLabels(leaf, entry);
501
535
  return entry.wrapEl;
502
536
  }
503
537
  // Build fresh.
@@ -589,9 +623,11 @@ export class TileRenderer {
589
623
  }
590
624
  // C22. CHROME FIRST IN BOTH LAYOUTS. The chrome is the pane's identity
591
625
  // and the tabs are what is inside it; a strip above the title would say
592
- // the tabs own the pane rather than the other way round.
593
- wrap.append(chrome, ...(this.tabLayout === 'top' ? [tabBar, body]
594
- : [body, tabBar]));
626
+ // the tabs own the pane rather than the other way round. C38's
627
+ // `header` puts the strip inside the chrome — `_placeTabBar` decides.
628
+ wrap.append(chrome, body, tabBar);
629
+ this._placeTabBar({ wrapEl: wrap, bodyEl: body, chromeEl: chrome,
630
+ titleEl: title, tabBarEl: tabBar });
595
631
 
596
632
  wrap.addEventListener('mousedown', (e) => {
597
633
  if (e.target.closest('.twm-splitter')) return;
@@ -947,9 +983,12 @@ export class TileRenderer {
947
983
  * attached element is a re-parent, not a rebuild — the body keeps its
948
984
  * DOM, its listeners and its scroll. */
949
985
  _placeTabBar(entry) {
950
- const { wrapEl, bodyEl, tabBarEl } = entry;
986
+ const { wrapEl, bodyEl, tabBarEl, chromeEl, titleEl } = entry;
951
987
  if (!wrapEl || !tabBarEl || !bodyEl) return;
952
- if (this.tabLayout === 'top') wrapEl.insertBefore(tabBarEl, bodyEl);
988
+ // C38. Right after the title, so it takes the title's place and the
989
+ // content's actions and the structural buttons stay on the right.
990
+ if (this.tabLayout === 'header' && chromeEl && titleEl) titleEl.after(tabBarEl);
991
+ else if (this.tabLayout === 'top') wrapEl.insertBefore(tabBarEl, bodyEl);
953
992
  else wrapEl.appendChild(tabBarEl);
954
993
  }
955
994
 
@@ -975,16 +1014,48 @@ export class TileRenderer {
975
1014
  const bar = entry.tabBarEl;
976
1015
  if (!bar) return;
977
1016
  const tabs = Array.isArray(leaf.tabs) ? leaf.tabs : [];
1017
+ const header = this.tabLayout === 'header';
978
1018
  bar.classList.toggle('twm-leaf__tabbar--top', this.tabLayout === 'top');
979
- if (tabs.length <= 1) {
1019
+ bar.classList.toggle('twm-leaf__tabbar--header', header);
1020
+ // C38. One tab is enough under `header`: the strip is the title. A
1021
+ // panel, or a leaf with no content, has no tab to name and keeps the
1022
+ // chrome's title.
1023
+ const isPanel = String(leaf.content?.kind || '').startsWith('panel:');
1024
+ const inHeader = header && !isPanel && !!leaf.content && tabs.length >= 1;
1025
+ entry.wrapEl?.classList.toggle('twm-leaf--tabs-in-header', inHeader);
1026
+ if (!inHeader && tabs.length <= 1) {
980
1027
  bar.classList.add('twm-leaf__tabbar--hidden');
981
1028
  this._disposeTabStrip(entry);
982
1029
  bar.innerHTML = '';
983
1030
  return;
984
1031
  }
985
1032
  bar.classList.remove('twm-leaf__tabbar--hidden');
986
- if (this.tabLayout === 'top') this._renderTopTabBar(leaf, entry, tabs);
987
- else this._renderBottomTabBar(leaf, entry, tabs);
1033
+ if (this.tabLayout === 'bottom') this._renderBottomTabBar(leaf, entry, tabs);
1034
+ else this._renderTopTabBar(leaf, entry, tabs);
1035
+ }
1036
+
1037
+ /** C38. The labels the `header` strip shows for `leaf`, in one string, so
1038
+ * a repaint can tell whether anything it draws has changed. */
1039
+ _headerLabels(leaf, entry) {
1040
+ const tabs = Array.isArray(leaf.tabs) ? leaf.tabs : [];
1041
+ const activeIdx = Math.max(0, Math.min(tabs.length - 1, leaf.activeTabIdx || 0));
1042
+ return tabs.map((t, i) => this._tabLabel(t, i === activeIdx, entry)).join('\n');
1043
+ }
1044
+
1045
+ /** C38. A cached leaf is not re-mounted, so a tab renamed in the tree
1046
+ * (`TileTree.renameMatchingTabs`) reaches the strip only through here. */
1047
+ _refreshHeaderLabels(leaf, entry) {
1048
+ const labels = this._headerLabels(leaf, entry);
1049
+ if (entry.headerLabels === labels) return;
1050
+ this._renderTabBar(leaf, entry);
1051
+ }
1052
+
1053
+ /** What a tab in the `top` or `header` strip is called. A tab with no
1054
+ * title of its own falls back, when it is the active one, to the title
1055
+ * its content gave at mount — the string the chrome title would have
1056
+ * shown, and under `header` the chrome title is hidden (C38). */
1057
+ _tabLabel(t, active, entry) {
1058
+ return t.title || (active && entry?.content?.title) || t.kind || '';
988
1059
  }
989
1060
 
990
1061
  /**
@@ -1021,12 +1092,19 @@ export class TileRenderer {
1021
1092
  const tabs = Array.isArray(leaf.tabs) ? leaf.tabs : [];
1022
1093
  const isPanel = String(leaf.content?.kind || '').startsWith('panel:');
1023
1094
  const vetoed = this.leafChrome(leaf.id)?.promote === false;
1024
- const on = tabs.length === 1 && !isPanel && !vetoed;
1095
+ // C38. Not under `header`: the one tab is drawn in the strip, and the
1096
+ // strip is its drag source.
1097
+ const on = tabs.length === 1 && !isPanel && !vetoed && this.tabLayout !== 'header';
1025
1098
  if (on) chrome.setAttribute('draggable', 'true');
1026
1099
  else chrome.removeAttribute('draggable');
1027
1100
  if (chrome.__twmTabDragBound) return;
1028
1101
  chrome.__twmTabDragBound = true;
1029
1102
  chrome.addEventListener('dragstart', (ev) => {
1103
+ // C38. A drag that began in the strip — which the `header` layout
1104
+ // puts INSIDE the chrome — is the strip's, and has bubbled here.
1105
+ // Not refused (that would cancel the tab's own drag) and not
1106
+ // re-begun as tab 0: left alone.
1107
+ if (entry.tabBarEl?.contains(ev.target)) return;
1030
1108
  // A drag that began on a BUTTON is not a drag of the pane. Buttons
1031
1109
  // are not draggable on their own, but a draggable ancestor makes
1032
1110
  // them so — hence the same guard the two float gestures apply, from
@@ -1143,11 +1221,31 @@ export class TileRenderer {
1143
1221
  { idx, x: ev.clientX, y: ev.clientY });
1144
1222
  }, true);
1145
1223
  }
1224
+ // C38. The `+` after the last tab, `header` only. Outside the
1225
+ // component's container so its `update()` never rewrites it, and
1226
+ // built with the strip so `setTabLayout` (which empties the bar and
1227
+ // disposes the strip) takes it away with the strip.
1228
+ if (this.tabLayout === 'header' && !bar.querySelector('.twm-leaf__tab-new')) {
1229
+ const add = document.createElement('button');
1230
+ add.type = 'button';
1231
+ add.className = 'twm-leaf__tab-new';
1232
+ add.dataset.action = 'tab-new';
1233
+ add.title = 'New tab';
1234
+ add.setAttribute('aria-label', 'New tab');
1235
+ add.innerHTML = '<span class="material-symbols-outlined">add</span>';
1236
+ add.addEventListener('click', (ev) => {
1237
+ ev.preventDefault();
1238
+ ev.stopPropagation();
1239
+ this.ctx.onLeafTabAction?.(leaf.id, 'new', {});
1240
+ });
1241
+ bar.appendChild(add);
1242
+ }
1146
1243
  const activeIdx = Math.max(0, Math.min(tabs.length - 1, leaf.activeTabIdx || 0));
1244
+ entry.headerLabels = this._headerLabels(leaf, entry);
1147
1245
  entry.tabStrip.update(
1148
1246
  tabs.map((t, i) => ({
1149
1247
  filePath: _tabKey(i),
1150
- label: t.title || t.kind || '',
1248
+ label: this._tabLabel(t, i === activeIdx, entry),
1151
1249
  // The component's `fileType` picks a glyph out of a static map
1152
1250
  // of FILE kinds. A tile's kinds are the embedder's, and the
1153
1251
  // taxonomy already answers for them — see `_paintTopTabs`.
@@ -1159,7 +1257,7 @@ export class TileRenderer {
1159
1257
  isDirty: !!t.dirty,
1160
1258
  })),
1161
1259
  _tabKey(activeIdx));
1162
- this._paintTopTabs(entry.tabStripHostEl, tabs);
1260
+ this._paintTopTabs(entry.tabStripHostEl, tabs, activeIdx, entry);
1163
1261
  }
1164
1262
 
1165
1263
  /**
@@ -1179,13 +1277,13 @@ export class TileRenderer {
1179
1277
  * rendered last. The class names used here are the component's published
1180
1278
  * contract, stated in its header comment.
1181
1279
  */
1182
- _paintTopTabs(hostEl, tabs) {
1280
+ _paintTopTabs(hostEl, tabs, activeIdx = -1, entry = null) {
1183
1281
  if (!hostEl) return;
1184
1282
  const els = hostEl.querySelectorAll('.tab');
1185
1283
  els.forEach((el, i) => {
1186
1284
  const spec = tabs[i];
1187
1285
  if (!spec) return;
1188
- el.title = spec.title || spec.kind || '';
1286
+ el.title = this._tabLabel(spec, i === activeIdx, entry);
1189
1287
  const icon = spec.kind ? this.ctx?.taxonomy?.meta?.(spec.kind)?.icon : null;
1190
1288
  const glyph = el.querySelector('.tab-icon');
1191
1289
  if (glyph && icon) glyph.textContent = icon;
package/src/tiling/wm.js CHANGED
@@ -177,6 +177,24 @@ export class WindowManager {
177
177
  this.renderer.setTabLayout(layout);
178
178
  }
179
179
 
180
+ /** C38. Open a new tab in a tile and show it. It holds the taxonomy root —
181
+ * what a split seeds its new pane with, so "a new tab" and "a new pane"
182
+ * start in the same place. Defaults to the focused tile. A panel tile
183
+ * grows no tabs (`TileTree.appendLeafTab`); then this does nothing and
184
+ * answers -1, else the new tab's index. */
185
+ newTab(leafId = null) {
186
+ const tree = this._tree();
187
+ const id = leafId ?? tree.focusedLeafId;
188
+ const seed = this._rootLeaf();
189
+ const idx = tree.appendLeafTab(id, seed.content, seed.title);
190
+ if (idx < 0) return -1;
191
+ tree.focus(id);
192
+ this.renderer.render();
193
+ this._persist();
194
+ this._notifyChange('tab-new');
195
+ return idx;
196
+ }
197
+
180
198
  /** Emit on bus + call onChange. Use this instead of the bare callback
181
199
  * so other surfaces (palette, top-bar toggles, page shortcuts) can
182
200
  * subscribe through the existing event system. */
@@ -3449,6 +3467,12 @@ export class WindowManager {
3449
3467
  this._notifyChange('twm-tab-close');
3450
3468
  return;
3451
3469
  }
3470
+ if (action === 'new') {
3471
+ // C38. The `header` strip's `+`. `newTab` renders, persists and
3472
+ // notifies on its own.
3473
+ this.newTab(leafId);
3474
+ return;
3475
+ }
3452
3476
  if (action === 'move') {
3453
3477
  tree.moveLeafTab(leafId, data.from, data.to);
3454
3478
  this.renderer.render();