flexdesk 0.17.18 → 0.18.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.
Files changed (118) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +1 -0
  3. package/css/flexdesk.css +7544 -5916
  4. package/css/reset.css +1 -1
  5. package/css/tokens.css +23 -1
  6. package/dist/charts.js +10 -6
  7. package/dist/charts.js.map +1 -1
  8. package/dist/chunk-5W4EEKSK.js +778 -0
  9. package/dist/chunk-5W4EEKSK.js.map +7 -0
  10. package/dist/chunk-DNJ3N4T4.js +82 -0
  11. package/dist/chunk-DNJ3N4T4.js.map +7 -0
  12. package/dist/{chunk-REJ5HLIM.js → chunk-HYJYYEPK.js} +2 -2
  13. package/dist/{chunk-7U4IB65H.js → chunk-LLBJ2SJH.js} +5 -3
  14. package/dist/{chunk-7U4IB65H.js.map → chunk-LLBJ2SJH.js.map} +1 -1
  15. package/dist/{chunk-7OL6F2AM.js → chunk-P74F6I6P.js} +6 -4
  16. package/dist/{chunk-7OL6F2AM.js.map → chunk-P74F6I6P.js.map} +1 -1
  17. package/dist/chunk-PAI2ZUWV.js +260 -0
  18. package/dist/chunk-PAI2ZUWV.js.map +7 -0
  19. package/dist/chunk-Q7BALEPX.js +527 -0
  20. package/dist/chunk-Q7BALEPX.js.map +7 -0
  21. package/dist/chunk-QXAOTGCC.js +40 -0
  22. package/dist/chunk-QXAOTGCC.js.map +7 -0
  23. package/dist/{chunk-XJNADRGR.js → chunk-RKVYIXDC.js} +664 -91
  24. package/dist/chunk-RKVYIXDC.js.map +7 -0
  25. package/dist/{chunk-2Z2ENHR3.js → chunk-U5GKJUOC.js} +3 -3
  26. package/dist/{chunk-2Z2ENHR3.js.map → chunk-U5GKJUOC.js.map} +1 -1
  27. package/dist/chunk-UKP22RUG.js +219 -0
  28. package/dist/chunk-UKP22RUG.js.map +7 -0
  29. package/dist/chunk-VLOAI3HS.js +43 -0
  30. package/dist/chunk-VLOAI3HS.js.map +7 -0
  31. package/dist/{chunk-WUFLGETN.js → chunk-WVFGV5FT.js} +2 -39
  32. package/dist/{chunk-WUFLGETN.js.map → chunk-WVFGV5FT.js.map} +3 -3
  33. package/dist/{chunk-PGZ4EHSE.js → chunk-ZL3VKINP.js} +41 -390
  34. package/dist/chunk-ZL3VKINP.js.map +7 -0
  35. package/dist/editor.js +3 -2
  36. package/dist/editor.js.map +1 -1
  37. package/dist/flexdesk.css +7544 -5916
  38. package/dist/flow.js +29 -97
  39. package/dist/flow.js.map +3 -3
  40. package/dist/git.js +1356 -0
  41. package/dist/git.js.map +7 -0
  42. package/dist/reset.css +1 -1
  43. package/dist/tiles.js +20 -15
  44. package/dist/tiles.js.map +2 -2
  45. package/dist/tokens.css +23 -1
  46. package/dist/widgets.js +3967 -1188
  47. package/dist/widgets.js.map +4 -4
  48. package/dist/wm.js +120 -32
  49. package/dist/wm.js.map +2 -2
  50. package/docs/README.md +9 -4
  51. package/docs/best-practices.md +2 -2
  52. package/docs/components/chat-shell.md +198 -0
  53. package/docs/components/conversation.md +10 -0
  54. package/docs/components/filter-builder.md +246 -0
  55. package/docs/components/forms-and-dialogs.md +22 -0
  56. package/docs/components/git.md +355 -0
  57. package/docs/components/input-fields.md +167 -2
  58. package/docs/components/keymap.md +8 -0
  59. package/docs/components/menus.md +82 -2
  60. package/docs/components/panels-and-desktops.md +16 -5
  61. package/docs/components/shell.md +104 -1
  62. package/docs/components/workspace-picker.md +149 -0
  63. package/docs/concepts/building-and-packaging.md +2 -2
  64. package/docs/concepts/packages-and-entry-points.md +14 -13
  65. package/docs/concepts/state-guard.md +3 -3
  66. package/docs/quickstart.md +1 -1
  67. package/git.js +13 -0
  68. package/package.json +6 -1
  69. package/src/flow/kit/dom.js +28 -119
  70. package/src/flow/kit/step_picker.js +1 -1
  71. package/src/flow/kit/value_picker.js +1 -1
  72. package/src/flow/outline/menu.js +1 -1
  73. package/src/git/diff_viewer.js +212 -0
  74. package/src/git/diff_window.js +51 -0
  75. package/src/git/git_graph.js +401 -0
  76. package/src/git/git_ops.js +61 -0
  77. package/src/git/git_panel.js +755 -0
  78. package/src/git/index.js +9 -0
  79. package/src/git/line_diff.js +202 -0
  80. package/src/tiles/tile_base.js +5 -4
  81. package/src/tiling/desktops.js +10 -8
  82. package/src/tiling/keymap.js +6 -5
  83. package/src/tiling/kind_taxonomy.js +15 -2
  84. package/src/tiling/shell.js +124 -17
  85. package/src/tiling/tile_renderer.js +2 -3
  86. package/src/ui/components/action_dropdown.js +4 -78
  87. package/src/ui/components/anchored_layer.js +255 -0
  88. package/src/ui/components/chat_shell.js +920 -0
  89. package/src/ui/components/chat_state_machine.js +223 -0
  90. package/src/ui/components/choice_controls.js +45 -2
  91. package/src/ui/components/color_field.js +3 -2
  92. package/src/ui/components/color_list_field.js +3 -2
  93. package/src/ui/components/combobox.js +422 -0
  94. package/src/ui/components/conversation_view.js +17 -2
  95. package/src/ui/components/data_table.js +2 -2
  96. package/src/ui/components/filter_builder.js +766 -0
  97. package/src/ui/components/filter_model.js +614 -0
  98. package/src/ui/components/managed_window.js +24 -47
  99. package/src/ui/components/modal.js +79 -109
  100. package/src/ui/components/option_list.js +160 -0
  101. package/src/ui/components/popover.js +94 -0
  102. package/src/ui/components/recent_items.js +169 -0
  103. package/src/ui/components/select.js +322 -0
  104. package/src/ui/components/tag_input.js +77 -8
  105. package/src/ui/components/workspace_picker.js +1155 -0
  106. package/src/ui/utils/fire_event.js +17 -0
  107. package/src/ui/utils/focusable.js +25 -0
  108. package/src/ui/utils/modal_host.js +27 -0
  109. package/widgets.js +12 -2
  110. package/css/base.css +0 -10547
  111. package/css/overrides.css +0 -2596
  112. package/dist/chunk-7JI7HHSY.js +0 -107
  113. package/dist/chunk-7JI7HHSY.js.map +0 -7
  114. package/dist/chunk-MIW7D6WC.js +0 -526
  115. package/dist/chunk-MIW7D6WC.js.map +0 -7
  116. package/dist/chunk-PGZ4EHSE.js.map +0 -7
  117. package/dist/chunk-XJNADRGR.js.map +0 -7
  118. /package/dist/{chunk-REJ5HLIM.js.map → chunk-HYJYYEPK.js.map} +0 -0
@@ -157,8 +157,7 @@ Text that is not JSON was always handled: the host logs `[host] bad JSON for
157
157
  state key desktops` and answers `null`. A layout that is valid JSON but not a
158
158
  layout (a split whose `sizes` is a number, a `null` desktop, a split with no
159
159
  `children`) used to make `createShell` throw, on every start, until the file was
160
- deleted by hand, so an app had to vet the file before booting. Others were let
161
- through half understood: a desktop with no `tree` came back as an empty one.
160
+ deleted by hand, so an app had to vet the file before booting.
162
161
 
163
162
  Now such a layout is ignored. `createShell` resolves, the shell opens its
164
163
  default layout exactly as it does when nothing was saved, and one warning names
@@ -181,8 +180,8 @@ loggers) records that line as the message, with the key and the cause in it.
181
180
  (any change to it) that save replaces the file, and until then every start
182
181
  warns again.
183
182
  - **What is unreadable** is a shape `serialize()` could not have written:
184
- `desktops` not a non-empty array; a desktop that is not an object, or has no
185
- `tree` (a `tree` of `null` is a desktop with nothing on it, and is fine); a
183
+ `desktops` not a non-empty array; a desktop that is not an object, or whose
184
+ `tree` is something other than `{ root }`, `null` or absent; a
186
185
  `panels` or `panelShares` that is not an object; a node that is not a `leaf`
187
186
  or a `split`; a split without a `dir` of `h` or `v`, without children, or
188
187
  whose `sizes` are not one non-negative number per child; a tab without a
@@ -193,7 +192,7 @@ loggers) records that line as the message, with the key and the cause in it.
193
192
  `group` and `fixed`, a panel's flag, a tab's props. A layout saved by an
194
193
  earlier release loads too: the keys that came later (`panelShares`, `group`,
195
194
  `fixed`, a leaf's `pageTabs`) may be missing, and so may a desktop's `id`,
196
- `label` and `panels`, and a leaf's `tabs`. Keys it does not know are ignored, a
195
+ `label`, `tree` and `panels`, and a leaf's `tabs`. Keys it does not know are ignored, a
197
196
  tab of a kind with no factory registered is the usual placeholder tile, and an
198
197
  `activeIdx` past the last desktop is clamped.
199
198
 
@@ -201,5 +200,17 @@ loggers) records that line as the message, with the key and the cause in it.
201
200
  throws the `TypeError` above for a blob that cannot be read, which is what
202
201
  `wm.load()` catches; `wm.load()` itself does not throw for one.
203
202
 
203
+ ### A desktop with no `tree` is an empty desktop (0.18.0)
204
+
205
+ A desktop whose `tree` is `null` or absent has nothing on it. It loads, its
206
+ siblings load with it, and the shell seeds it with the root kind. A host that
207
+ composes the blob can leave the key out: one that rebuilds a desktop per page on
208
+ every boot, say, and keeps only the desktops the user made.
209
+
210
+ From 0.17.14 to 0.17.18 an absent `tree` counted as unreadable. All or nothing
211
+ then set the user's desktops aside with it, and the next layout change saved the
212
+ default layout over them for good. A host like that must not ship on those
213
+ releases unless it writes `tree: null`.
214
+
204
215
  `tests/saved_layout_unreadable.test.mjs` boots a real shell over each of 78
205
216
  shapes of broken layout and over the readable ones.
@@ -2,13 +2,14 @@
2
2
 
3
3
  > **Package:** `@flexdesk/wm` · **Next:** [Panels and desktops](panels-and-desktops.md), [Command palette](command-palette.md), [Keys, Back and tab layout](keymap.md) · [All docs](../README.md)
4
4
 
5
- `createShell` boots the tiling window manager into an element of your page: tiles that split and tab, the top navigation, dockable panels, virtual desktops and the command palette. It knows nothing about your domain. You give it a taxonomy of *kinds*, a content factory for each kind and a [host](../concepts/host-port.md), and it draws and persists the rest.
5
+ `createShell` boots the tiling window manager into an element of your page: tiles that split and tab, the top navigation or a page rail, dockable panels, virtual desktops and the command palette. It knows nothing about your domain. You give it a taxonomy of *kinds*, a content factory for each kind and a [host](../concepts/host-port.md), and it draws and persists the rest.
6
6
 
7
7
  A complete first boot is in the [quickstart](../quickstart.md#hello-shell). This page covers what you can add to it: the opt-in shell features, kinds that exist only once, what happens when a link names a kind nobody registered, and what a pane can tell its own chrome.
8
8
 
9
9
  **On this page**
10
10
 
11
11
  - [Opt-in shell features](#opt-in-shell-features)
12
+ - [The pages as a vertical rail: `chrome.rail` (0.18.0)](#the-pages-as-a-vertical-rail-chromerail-0180)
12
13
  - [A kind that exists once: `single` (0.17.0)](#a-kind-that-exists-once-single-0170)
13
14
  - [A kind that exists once per desktop: `single: 'desktop'` (0.17.17)](#a-kind-that-exists-once-per-desktop-single-desktop-01717)
14
15
  - [An open single instance takes new props: `navigate(props)` (0.17.2)](#an-open-single-instance-takes-new-props-navigateprops-0172)
@@ -36,6 +37,108 @@ await createShell({
36
37
  });
37
38
  ```
38
39
 
40
+ ## The pages as a vertical rail: `chrome.rail` (0.18.0)
41
+
42
+ `chrome.topNav` paints the pages as a strip in the top bar. `chrome.rail` paints
43
+ them as a fixed-left icon bar instead. Give it the element, and put the element
44
+ beside the window manager's host in a `.twm-workspace`:
45
+
46
+ ```html
47
+ <body class="twm-body">
48
+ <div class="twm-global-top-bar">…</div>
49
+ <div class="twm-workspace">
50
+ <nav id="rail" aria-label="Sections"></nav>
51
+ <main id="root" class="twm-host"></main>
52
+ </div>
53
+ <div class="twm-global-bottom-bar">…</div>
54
+ </body>
55
+ ```
56
+
57
+ ```js
58
+ const taxonomy = createTaxonomy({
59
+ root: 'start',
60
+ kinds: {
61
+ start: { label: 'Start', icon: 'apps' },
62
+ atlas: { label: 'Atlas', icon: 'map', isTopNav: true, order: 10 },
63
+ log: { label: 'Ship log', icon: 'article', isTopNav: true, order: 20 },
64
+ settings: { label: 'Settings', icon: 'settings', isTopNav: true, railPin: 'bottom' },
65
+ },
66
+ });
67
+
68
+ await createShell({
69
+ root: document.getElementById('root'),
70
+ taxonomy, entities, content,
71
+ chrome: { rail: document.getElementById('rail') },
72
+ });
73
+ ```
74
+
75
+ - **The look.** 50px wide, 44px items, 22px icons at weight 300. An item is grey
76
+ at rest and white on hover; the active one is white, with a white 2px bar at
77
+ its left on a transparent background; keyboard focus draws the accent outline.
78
+ A window too short for the items scrolls the bar, with no scrollbar. No
79
+ stylesheet of yours is needed.
80
+ - **`railPin: 'bottom'`** on a top-nav kind holds it at the bottom of the bar.
81
+ Pinned kinds come after the rest, in their own `order`, and stay together.
82
+ A `railPin` of any other value, or on a kind that is not `isTopNav`, is a boot
83
+ error. The top strip ignores it. A kind such as a settings page can be
84
+ `isTopNav` and `appGlobal` at once.
85
+ - **F1..F12 press the rail's buttons in the order they are drawn**, pinned ones
86
+ last, with no `navSelector`. `navSelector` remains for page buttons in markup
87
+ of your own.
88
+ - **Tooltips open to the right** of the bar, flipping at the window's edge, with
89
+ no attribute or patch of yours. They name the kind's `label`, and so do the
90
+ buttons' `aria-label`s (a `shortLabel` is for the strip's chips). As for every
91
+ `twm-has-tooltip` element, `TooltipService` draws them, and it comes with
92
+ `@flexdesk/widgets`: a page that imports only `@flexdesk/wm` has the labels
93
+ and no tooltips.
94
+ - **The layout.** `.twm-workspace` takes the host's place in `body.twm-body`'s
95
+ column, and a `.twm-host` inside it fills what the rail leaves, with the rule
96
+ a host directly under the body has. Outside `body.twm-body`, a `.twm-workspace`
97
+ needs a parent that gives it a height, as a host does.
98
+ - **The element is yours.** It gains `.twm-fl-bar` and, if it has none, the
99
+ `aria-label` "Pages". `shell.chrome.railEl` hands it back. `shell.dispose()`
100
+ empties it and takes the listener off, so a rebuilt shell paints its own.
101
+ - **One row of pages.** `chrome.rail` with `chrome.topNav` is a boot error.
102
+
103
+ Retheme or resize it from `tokens.css`:
104
+
105
+ | Token | Default | |
106
+ |---|---|---|
107
+ | `--twm-rail-bg` | `#333333` | the bar |
108
+ | `--twm-rail-icon` | `#9e9e9e` | an item at rest |
109
+ | `--twm-rail-icon-on` | `var(--text-bright)` | an item hovered, the active item and its bar |
110
+ | `--twm-rail-width` | `50px` | the bar's width |
111
+ | `--twm-rail-item-height` | `44px` | an item's height |
112
+ | `--twm-rail-icon-size` | `22px` | the icon's size |
113
+
114
+ If you supply your own token definitions instead of `tokens.css`, define these
115
+ too: the rules that read them have no fallback.
116
+
117
+ ### Without `createShell`: `mountPageRail(el, { wm, activeKind?, decorate? })`
118
+
119
+ `@flexdesk/wm` exports the painter, for an embedder that constructs its own
120
+ `WindowManager`. It answers `{ el, sync(), dispose() }`; call `sync()` when the
121
+ window manager changes (`onChange`, `wm:changed`), once after the first load, and
122
+ `dispose()` when the rail goes. The keymap's default finds these buttons too.
123
+
124
+ ```js
125
+ const rail = mountPageRail(document.getElementById('rail'), {
126
+ wm,
127
+ // Which page to light, or null for none. Default: the primary tile's page.
128
+ activeKind: (w) => pageOf(w),
129
+ // Once per button, with its topNavEntries() entry.
130
+ decorate: (btn, entry) => {
131
+ if (entry.kind === 'archive') btn.dataset.tooltip = 'Archive: shared by every crew';
132
+ },
133
+ });
134
+ ```
135
+
136
+ **What changed for existing markup.** `.twm-fl-bar` and `.twm-fl-item` had rules
137
+ and no code that drew them. `.twm-fl-bar` now has the rail's width, background
138
+ and overflow and no `height: 100%` (it stretches in its flex row), and
139
+ `.twm-fl-item`'s grey comes from `--twm-rail-icon`. Nothing changes for a shell
140
+ that uses `chrome.topNav`. `topNavEntries()` gains `railPin` (null unless set).
141
+
39
142
  ## A kind that exists once: `single` (0.17.0)
40
143
 
41
144
  Some content holds heavy app-wide state and must not be mounted twice. Mark its
@@ -0,0 +1,149 @@
1
+ # Workspace picker
2
+
3
+ > **Package:** `@flexdesk/widgets` · **See also:** [Menus](menus.md) · [Host port](../concepts/host-port.md) · [All docs](../README.md)
4
+
5
+ A field in the top bar that names what the window is working in, a switcher that changes it, and a store for "recently opened". FlexDesk does not know what is being picked: the items, the words and what a pick does are yours.
6
+
7
+ ## A field and a switcher: `createWorkspacePicker` (0.18.0)
8
+
9
+ ```js
10
+ import { createWorkspacePicker } from '@flexdesk/widgets';
11
+
12
+ const picker = createWorkspacePicker(topBar, {
13
+ field: {
14
+ layout: 'centered',
15
+ avoid: '.bar-left > *, .bar-right > *',
16
+ icon: 'folder',
17
+ placeholder: 'Nothing open',
18
+ tooltip: 'Click to switch workspace',
19
+ },
20
+ chord: 'Ctrl+P', // none by default
21
+ label: 'Switch workspace',
22
+ placeholder: 'Switch workspace: type to filter, Enter to open',
23
+ glyph: 'folder_open',
24
+ load: () => recent.list(),
25
+ describe: (item, { current }) => ({
26
+ id: item.id,
27
+ name: item.name,
28
+ detail: item.location,
29
+ icon: current ? 'folder_open' : 'folder',
30
+ badge: current ? 'current' : '',
31
+ }),
32
+ onPick: (item) => openWorkspace(item),
33
+ onRemove: (item) => recent.remove(item),
34
+ actions: () => [
35
+ { id: 'new', label: 'New workspace', icon: 'add', run: () => createOne() },
36
+ { id: 'open', label: 'Open…', icon: 'folder_open', run: () => openOne() },
37
+ ],
38
+ });
39
+
40
+ picker.setCurrent(openNow); // named on the field, marked in the list
41
+ ```
42
+
43
+ A click on the field or the chord opens the switcher in the field's place, with its input over the field, so the field reads as a search box growing its results. A pick closes it, gives focus back to where it was, and calls `onPick`, or nothing if the row is the current one. What `onPick` does is yours: navigate the whole window, or swap what is open in place.
44
+
45
+ It returns `{ el, field, switcher, isOpen, current, open({select}), close(), toggle(), setCurrent(item), refresh(), reload(), destroy() }`. `createWorkspacePicker(hostEl, options)` takes everything `createPickerSwitcher` takes, and:
46
+
47
+ | Option | |
48
+ |---|---|
49
+ | `field` | `mountPickerField`'s options, plus `placeholder`: its name while nothing is current (drawn muted). `field.icon` is its icon whatever is current; without it the current item's `icon` is used. |
50
+ | `chord` | `'Ctrl+P'`, `'Ctrl+Shift+O'`. None by default. Ctrl also answers to Cmd, modifiers are exact, and a held key does not repeat. It is taken on `window` in the capture phase, ahead of an embedded editor and the browser's own use of the key, and it becomes the field's key hint unless `field.keyHint` says otherwise. It still works while the field is hidden for want of room. |
51
+
52
+ ## The field: `mountPickerField` (0.18.0)
53
+
54
+ `mountPickerField(hostEl, options)` appends a real `<button>` to `hostEl` and returns `{ el, update(patch), setOpen(open), fit(), destroy() }`.
55
+
56
+ | Option | Default | |
57
+ |---|---|---|
58
+ | `name`, `icon`, `keyHint`, `tooltip`, `ariaLabel` | empty | Drawn when given. `update` changes any of them, and `muted`. |
59
+ | `muted` | `false` | The name drawn as naming nothing (`update({ name: 'n/a', muted: true })`). |
60
+ | `caret` | `false` | A trailing caret, for a field that reads as a dropdown. |
61
+ | `className` | | Extra classes. |
62
+ | `layout` | `'inline'` | `'centered'` stands it in the middle of `hostEl`, whatever stands left and right. |
63
+ | `avoid` | | Centred: a selector, within `hostEl`, of what it must not cover. An element counts on the side its centre is on; one taking no room does not count. |
64
+ | `maxWidth`, `minWidth`, `tightWidth`, `gap` | 440, 120, 230, 24 | Centred, its width is the room the tighter side leaves, twice over, less `gap`, up to `maxWidth`. Below `tightWidth` the key hint goes. Below `minWidth` the field is hidden. |
65
+ | `onActivate` | | A click. |
66
+
67
+ A centred field's `hostEl` must be positioned. It measures again whenever `hostEl` resizes and when the fonts arrive; call `fit()` after you change what stands beside it. An inline field's width is its host's, and only the key hint responds, to its own width.
68
+
69
+ ## The switcher: `createPickerSwitcher` (0.18.0)
70
+
71
+ `createPickerSwitcher(options)` is the panel alone, on any anchor or on none. `createWorkspacePicker` is this plus a field.
72
+
73
+ **Where it opens.** `placement: 'over'` (the default) lays the input over `anchor`, at least `minWidth` (520) wide, kept inside the window; an anchor in the lower half of the window grows the panel upward instead. `placement: 'below'` hangs from it through `ActionDropdown.position`, flipped above when there is no room. With no `anchor`, or one that takes no room, it stands centred over a dimmed page. A window resize places it again.
74
+
75
+ **What it lists.** `load({ query, offset, limit, signal })` answers an array or `{ items, total?, status? }`, now or in a promise, and is asked on every open, so a list edited behind the page is never stale for long. The last unsearched answer per `cacheKey()` is drawn at once while it is asked again. `status` is the footer's left end, a string or `{ text, error }`. Only the newest question's answer paints; `signal` aborts the older ones.
76
+
77
+ | `search` | |
78
+ |---|---|
79
+ | `'local'` (default) | Asked once per open. Typing filters what it answered, on the name, `detail` and `keywords`, with name matches first. |
80
+ | `'remote'` | Typing is debounced (`debounceMs`, 200) and asked again. A `total` above what is drawn offers **Load more**, `pageSize` (20) at a time. A server that hands back only what is drawn has no more, whatever it said before. A failed page keeps the rows and says so. |
81
+
82
+ `describe(item, { current })` turns an item into a row:
83
+
84
+ | Field | |
85
+ |---|---|
86
+ | `id` | Required. Two items with one `id` are one row. Make it the form you compare by: `setCurrent` and `open({ select })` go through it. |
87
+ | `name` | Required. |
88
+ | `detail` | A second line. |
89
+ | `icon`, `badge` | An icon, and a short word on the right (the word for the current row, if you want one; the check mark is always drawn). |
90
+ | `keywords` | A string or an array: searched, not drawn. |
91
+ | `disabled` | Drawn greyed, stepped over by the arrows. |
92
+ | `removable` | `false` takes the bin off this row. |
93
+ | `title` | The row's tooltip; by default its name and detail. |
94
+
95
+ **The rest of the panel**, every part optional:
96
+
97
+ | Option | |
98
+ |---|---|
99
+ | `onRemove(item)` | A bin on each row, and Shift+Delete on the highlighted one. May answer a promise: while it is unsettled the panel stays open and it and its dimmed page step below managed windows, so a confirmation dialog can be answered with the mouse. Answering `false` keeps the row, rejecting keeps it and shows the error, anything else drops it and asks `load` again. The highlight moves to the next row. |
100
+ | `actions({ query })` | Rows after the items: `{ id, label, detail?, icon?, always?, run({ query }) }`. Typing narrows them on their `label` unless `always`, which is how a row offering to create what was typed stays; when nothing else matches, it is what Enter takes. |
101
+ | `heading({ query, shown, total })` | `{ title, count }` over the list. |
102
+ | `card({ current, row })` | A panel under the list for the current item: `{ heading?, icon?, name, detail?, open?: { label, icon?, run }, columns?, tiles?: [{ id, label, icon?, run }] }`, or `null`. `row` is the current item as the list read it, when it has. |
103
+ | `footer()` | `{ start?, end?, keys? }`. `start` and `end` are buttons, `{ id, label, icon?, trailing?, primary?, run }`. `keys`, `[{ keys: ['Shift', 'Del'], label: 'remove' }]`, are drawn only when the panel is centred. |
104
+ | `visibleRows` | Scroll past this many two-line rows. |
105
+ | `labels` | Any of `loading`, `empty()`, `noMatch(query)`, `failed(err)`, `failedMore(err)`, `failedRemove(err)`, `loadMore({ next, shown, total })`, `remove(name)`. |
106
+ | `label`, `listLabel`, `placeholder`, `glyph`, `className`, `onOpenChange(open)` | The panel's and the list's accessible names, the input's placeholder and icon, extra classes, and a callback. |
107
+
108
+ `refresh()` asks `actions`, `card` and `footer` again. `reload()` asks `load` again. `open({ select })` highlights the row with that `id` when the answer lands. `setCurrent(item)` takes the item whether or not any page holds it.
109
+
110
+ **The keyboard.** The caret stays in the input. The arrows walk a highlight over the rows, Load more, the action rows, the card's buttons and the footer's, wrapping. Until they move, the highlight is the first row or action row, and Enter takes it, or, while the answer to what was typed is on its way, the first one when it lands. Esc closes and gives focus back to where it was. A press outside closes and leaves focus where it was pressed. Tab goes to the panel's buttons, and out of the panel closes it.
111
+
112
+ **Nothing is rebuilt under a pointer.** Every part is reconciled by key, so an answer landing while a row is being pressed leaves that row's node in place and the click still fires.
113
+
114
+ ## Recently opened: `createRecentItems` (0.18.0)
115
+
116
+ A most-recent-first list kept through the [host's](../concepts/host-port.md) `state` capability.
117
+
118
+ ```js
119
+ import { createRecentItems } from '@flexdesk/widgets';
120
+
121
+ const recent = createRecentItems({
122
+ host,
123
+ key: 'recent-workspaces',
124
+ identity: (entry) => entry.path.replace(/\\/g, '/'),
125
+ isValid: (entry) => typeof entry?.path === 'string' && entry.path !== '',
126
+ onChange: () => bus.emit('recent:changed'),
127
+ logger,
128
+ });
129
+ await recent.load();
130
+
131
+ recent.add({ path, name }); // stamped `openedAt`, moved to the top
132
+ recent.remove({ path }); // by identity
133
+ recent.clear();
134
+ recent.list(); // frozen copies, most recent first
135
+ ```
136
+
137
+ | Option | Default | |
138
+ |---|---|---|
139
+ | `host`, `key` | required | The host, and the state key. |
140
+ | `identity` | `entry.id` | Which two entries are the same one. |
141
+ | `isValid` | `identity` answers something | Stored entries failing this are dropped on read, as is one that makes it, `identity` or `groupOf` throw, so `identity` may assume an entry's shape. |
142
+ | `capacity` | 10 | The most entries kept, per group. |
143
+ | `groupOf` | one group | Counted per group, so one busy kind of entry never pushes another out. |
144
+ | `onChange` | | After every change, and after a read that replayed changes. |
145
+ | `logger` | | Its `warn` hears a failed read or write. |
146
+
147
+ **A read that fails is not an empty list.** While `host.state.read` rejects, `load()` answers `false` and the stored list is never written, because writing would replace entries nobody has seen. Changes made meanwhile show in `list()` at once, each asks for the read again, and they are replayed onto the stored list when it is read, which is then written. A read answering something other than an array is a read of an empty store. Once a read has succeeded, `load()` does not read again.
148
+
149
+ Writes run one at a time and the last carries the latest list, so a host answering out of order cannot leave the older list stored. A host with no `state` keeps the list in memory for the session.
@@ -19,8 +19,8 @@ map. See `.gitignore` — it says so there too, because it is the kind of thing
19
19
  Every JavaScript entry in the `exports` map has two conditions: `import`, the
20
20
  prebuilt bundle under `dist/`, and `development`, the source barrel at the
21
21
  package root (`core.js`, `host.js`, `wm.js`, `widgets.js`, `charts.js`,
22
- `tiles.js`, `editor.js`, `canvas.js`, `flow.js`), which re-exports `src/`.
23
- Up to 0.17.0 those nine barrels were missing from `files`, so the published
22
+ `tiles.js`, `editor.js`, `canvas.js`, `flow.js`, `git.js`), which re-exports `src/`.
23
+ Up to 0.17.0 those barrels were missing from `files`, so the published
24
24
  package did not contain them and a resolver that took the `development` branch
25
25
  (`require.resolve` under `--conditions=development`, for one) got a path to
26
26
  nothing. From 0.17.1 they ship, beside the `src/` they import.
@@ -2,26 +2,27 @@
2
2
 
3
3
  > **Install:** see the [quickstart](../quickstart.md) · **Component pages:** [docs index](../README.md#components) · [All docs](../README.md)
4
4
 
5
- ## Nine entry points
5
+ ## Ten entry points
6
6
 
7
7
  Because someone who wants a DataTable should not have to download a window
8
8
  manager, and neither of them should drag in a plotting engine or a code editor.
9
9
 
10
10
  | Entry | Size | What is in it |
11
11
  |---|---|---|
12
- | `@flexdesk/core` | 30 kB | Event bus, settings store, logging, state machine, state guard |
13
- | `@flexdesk/host` | 5 kB | The [host port](host-port.md) |
14
- | `@flexdesk/wm` | 166 kB | Tiling window manager: tabs, splits, desktops, command palette, taxonomy, `createShell()` |
15
- | `@flexdesk/widgets` | 192 kB | DataTable, modals, managed windows, toasts, trees, panels, context menus, menu bars, a document outline, progress, a conversation view |
16
- | `@flexdesk/charts` | 26 kB | Plotly wrapper, chart types, subplot layouts, downsampling, a plot grid, a plot-config editor, plot windows, injected series transforms |
17
- | `@flexdesk/tiles` | 67 kB | Draggable/resizable widget grid, tile base class, widget registry, layout persistence, figure, chart and KPI tiles |
18
- | `@flexdesk/editor` | 62 kB | Monaco loader + factory, multi-file tab bar, search, split panes, undo manager |
19
- | `@flexdesk/canvas` | 14 kB | The canvas kernel: a pannable, zoomable surface, the edge router, a generic graph layer ([Flow editors](../components/flow-editors.md#flow-editors)) |
20
- | `@flexdesk/flow` | 381 kB | The flow-editor kit and the canvas, outline and lane editors ([Flow editors](../components/flow-editors.md#flow-editors)) |
12
+ | `@flexdesk/core` | 32 kB | Event bus, settings store, logging, state machine, state guard |
13
+ | `@flexdesk/host` | 18 kB | The [host port](host-port.md) |
14
+ | `@flexdesk/wm` | 403 kB | Tiling window manager: tabs, splits, desktops, command palette, taxonomy, `createShell()` |
15
+ | `@flexdesk/widgets` | 413 kB | DataTable, modals, managed windows, toasts, trees, panels, context menus, menu bars, a document outline, progress, a conversation view, an assistant chat shell, a rich select, a combobox, a popover, a filter builder, a workspace picker |
16
+ | `@flexdesk/charts` | 49 kB | Plotly wrapper, chart types, subplot layouts, downsampling, a plot grid, a plot-config editor, plot windows, injected series transforms |
17
+ | `@flexdesk/tiles` | 133 kB | Draggable/resizable widget grid, tile base class, widget registry, layout persistence, figure, chart and KPI tiles |
18
+ | `@flexdesk/editor` | 49 kB | Monaco loader + factory, multi-file tab bar, search, split panes, undo manager |
19
+ | `@flexdesk/canvas` | 5 kB | The canvas kernel: a pannable, zoomable surface, the edge router, a generic graph layer ([Flow editors](../components/flow-editors.md#flow-editors)) |
20
+ | `@flexdesk/flow` | 480 kB | The flow-editor kit and the canvas, outline and lane editors ([Flow editors](../components/flow-editors.md#flow-editors)) |
21
+ | `@flexdesk/git` | 47 kB | A source-control panel, an SVG commit graph and a diff viewer, over a written adapter contract ([Git panel](../components/git.md)) |
21
22
 
22
- The sizes of `@flexdesk/canvas` and `@flexdesk/flow` are the entry files as
23
- `node build.mjs` reported them for the flow editors' integration build; like
24
- every entry they also import shared chunks. The other rows are older figures.
23
+ The sizes are the entry files as `node build.mjs` reports them for 0.18.0,
24
+ unminified. Every entry also imports shared chunks, which load once however many
25
+ entries use them; the build prints their total.
25
26
 
26
27
  They share code through chunks, so importing two of them does **not** give you two
27
28
  event buses — and therefore not two copies of every module-level singleton. A test
@@ -40,7 +40,7 @@ global; choose strict mode in your own code.
40
40
  write, so the shell, dialogs, forms, tooltips, tiles and flow editors run under
41
41
  `mode: 'strict'` with no violation. The list also carries keys that FlexDesk
42
42
  itself does not write, and it has not been audited key by key
43
- ([what 0.18.0 took out and what stayed](#thirteen-application-keys-leave-the-defaults-0180)).
43
+ ([what 0.17.14 took out and what stayed](#thirteen-application-keys-leave-the-defaults-01714)).
44
44
  A key that names your application's concepts is yours to add, on top:
45
45
 
46
46
  ```js
@@ -59,13 +59,13 @@ capital letter becomes `-` plus its lower-case form. So `rowID` is `row-i-d`
59
59
  Before 0.17.0 a camelCase entry never matched, and some entries named keys no
60
60
  element carries.
61
61
 
62
- ### Thirteen application keys leave the defaults (0.18.0)
62
+ ### Thirteen application keys leave the defaults (0.17.14)
63
63
 
64
64
  **Breaking for an application that writes one of these keys under a strict
65
65
  guard.** `DEFAULT_STATE_GUARD_CONFIG.allowedDatasetKeys` no longer lists
66
66
  thirteen keys that belonged to one embedding application, not to FlexDesk. No
67
67
  FlexDesk widget writes any of them, so nothing FlexDesk draws changes; what
68
- changes is that a write of one is now on no allow-list. Before 0.18.0 the
68
+ changes is that a write of one is now on no allow-list. Before 0.17.14 the
69
69
  defaults let it through silently, for every application that used them.
70
70
 
71
71
  | Group, as the list labelled it | Keys removed |
@@ -14,7 +14,7 @@ The package ships a prebuilt `dist/`. Copy it into your site (the examples below
14
14
  mkdir -p vendor && cp -r node_modules/flexdesk/dist vendor/flexdesk
15
15
  ```
16
16
 
17
- Under a bundler you need none of that. Import the entries as `flexdesk/core`, `flexdesk/host`, `flexdesk/wm`, `flexdesk/widgets` (and `flexdesk/charts`, `flexdesk/tiles`, `flexdesk/editor`, `flexdesk/canvas`, `flexdesk/flow`), and the stylesheets as `flexdesk/css/tokens`, `flexdesk/css/reset` and `flexdesk/css`. The rest of these docs write the entries as `@flexdesk/wm` and so on, which is the name the import map below gives them.
17
+ Under a bundler you need none of that. Import the entries as `flexdesk/core`, `flexdesk/host`, `flexdesk/wm`, `flexdesk/widgets` (and `flexdesk/charts`, `flexdesk/tiles`, `flexdesk/editor`, `flexdesk/canvas`, `flexdesk/flow`, `flexdesk/git`), and the stylesheets as `flexdesk/css/tokens`, `flexdesk/css/reset` and `flexdesk/css`. The rest of these docs write the entries as `@flexdesk/wm` and so on, which is the name the import map below gives them.
18
18
 
19
19
  ## The page
20
20
 
package/git.js ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * @flexdesk/git — a source-control panel, an SVG swimlane commit graph and a
3
+ * diff viewer, over a written adapter contract (docs/components/git.md).
4
+ *
5
+ * It runs no git and reaches no backend by itself: every repository operation
6
+ * is a method of the adapter object the application passes in.
7
+ *
8
+ * HAND-WRITTEN, not generated. The barrels that say "GENERATED by
9
+ * scripts/gen_barrels.py" name a script that is not in this repository: add a
10
+ * module to src/git/index.js when you add one under src/git/.
11
+ */
12
+
13
+ export * from './src/git/index.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flexdesk",
3
- "version": "0.17.18",
3
+ "version": "0.18.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,6 +27,7 @@
27
27
  "editor.js",
28
28
  "canvas.js",
29
29
  "flow.js",
30
+ "git.js",
30
31
  "css",
31
32
  "docs",
32
33
  "README.md",
@@ -73,6 +74,10 @@
73
74
  "./flow": {
74
75
  "import": "./dist/flow.js",
75
76
  "development": "./flow.js"
77
+ },
78
+ "./git": {
79
+ "import": "./dist/git.js",
80
+ "development": "./git.js"
76
81
  }
77
82
  },
78
83
  "scripts": {
@@ -1,27 +1,16 @@
1
1
  /**
2
- * THE KIT'S SMALL DOM HELPERS, AND ITS ONE POPOVER.
2
+ * THE KIT'S SMALL DOM HELPERS, AND ITS POPOVER.
3
3
  *
4
4
  * Everything here builds elements; nothing here reads the network, storage or
5
- * a consumer's vocabulary. The popover is the step picker's and *Insert a
6
- * value*'s shell (36 §3.5, §3.7):
7
- *
8
- * - it is appended to FlexDesk's modal host (`modalHost()`, C25) or to
9
- * `document.body` — the same question a modal and a context menu ask, so a
10
- * consumer spanning two browser windows gets it in the window it set;
11
- * - it is `position: fixed`, placed under its anchor (above when there is no
12
- * room below) and CLAMPED to the viewport;
13
- * - it is dismissed by a press outside it, by Escape, or by `close()`, and
14
- * **it returns focus to its anchor** when it closes with focus inside it —
15
- * a picker that drops focus on `<body>` drops it where an editor's keys
16
- * never arrive — except when focus has just left it by Tab, which the
17
- * pickers close it for (`'blur'`): that focus is going where it was sent;
18
- * - there is ONE at a time: opening a second closes the first, and opening one
19
- * from the anchor that holds the open one closes it (a toggle);
20
- * - `closeFlowPopovers()` closes whichever is open, which is what an editor's
21
- * `destroy()` calls so that nothing it opened outlives it.
5
+ * a consumer's vocabulary. The popover, the step picker's and *Insert a value*'s
6
+ * shell (36 §3.5, §3.7), is FlexDesk's `openPopover`, which says how it is placed,
7
+ * dismissed and toggled and that it returns focus to its anchor: a picker that
8
+ * drops focus on `<body>` drops it where an editor's keys never arrive. What the
9
+ * kit adds is `closeFlowPopovers()`, which closes whichever of its own is open:
10
+ * what an editor's `destroy()` calls so that nothing it opened outlives it.
22
11
  */
23
12
 
24
- import { modalHost } from '../../ui/components/modal.js';
13
+ import { openPopover as openAnchoredPopover } from '../../ui/components/popover.js';
25
14
 
26
15
  /** The category tones (36 §3.1, §3.12); the stylesheet maps each to tokens. */
27
16
  export const TONES = Object.freeze(['violet', 'teal', 'amber', 'grey', 'blue', 'indigo']);
@@ -81,118 +70,38 @@ export function uid(stem = 'flow') {
81
70
 
82
71
  // ── the popover ──────────────────────────────────────────────────────────
83
72
 
84
- const EDGE = 8;
85
- const GAP = 4;
86
- let ACTIVE = null;
73
+ /** The kit's open popovers, oldest first. */
74
+ const OPEN = [];
87
75
 
88
- /** Close whichever kit popover is open. */
76
+ /** Close every kit popover that is open. */
89
77
  export function closeFlowPopovers() {
90
- ACTIVE?.close('closed');
78
+ OPEN[0]?.close('closed');
91
79
  }
92
80
 
93
- /** The open popover's element, or null — for a test. */
81
+ /** The open popover's element (the innermost's), or null — for a test. */
94
82
  export function openFlowPopover() {
95
- return ACTIVE ? ACTIVE.el : null;
83
+ return OPEN.length ? OPEN[OPEN.length - 1].el : null;
96
84
  }
97
85
 
98
86
  /**
99
- * Put `content` in a popover under `anchor`.
87
+ * FlexDesk's popover (`openPopover` in `ui/components/popover.js`), wearing the
88
+ * kit's class and remembered so that an editor can close what it opened.
100
89
  *
101
- * @param {object} o
102
- * @param {HTMLElement} o.anchor what opened it; focus returns here
103
- * @param {HTMLElement} o.content the popover's body
104
- * @param {string} o.label its accessible name (`role="dialog"`)
105
- * @param {string} [o.className] an extra block class
106
- * @param {(reason: string) => void} [o.onClose] after it is gone
107
- * @param {HTMLElement} [o.focus] what takes focus on open
108
- * @returns {{el: HTMLElement, close: (reason?: string) => void, reposition: () => void, toggled: boolean}}
109
- * `toggled` is true when the call only CLOSED the popover its anchor held.
90
+ * @param {object} o What `openPopover` takes.
91
+ * @returns {{el: HTMLElement|null, close: (reason?: string) => void, reposition: () => void, toggled: boolean}}
92
+ * `el` is null when nothing opened; `toggled` is true when the call only
93
+ * CLOSED the popover its anchor held.
110
94
  */
111
- export function openPopover({ anchor, content, label, className = '', onClose = null, focus = null }) {
112
- if (ACTIVE && anchor && ACTIVE.anchor === anchor) {
113
- ACTIVE.close('toggle');
114
- return { el: null, close() {}, reposition() {}, toggled: true };
115
- }
116
- ACTIVE?.close('replaced');
117
-
118
- const pop = el('div', `twm-flow-popover${className ? ` ${className}` : ''}`);
119
- pop.setAttribute('role', 'dialog');
120
- pop.setAttribute('aria-label', label || '');
121
- pop.tabIndex = -1;
122
- pop.appendChild(content);
123
- (modalHost() || document.body).appendChild(pop);
124
-
125
- let closed = false;
126
- const place = () => {
127
- if (closed) return;
128
- // The CLIENT width: a page scrollbar is not room to draw in.
129
- const view = { w: document.documentElement.clientWidth || window.innerWidth || 1024,
130
- h: document.documentElement.clientHeight || window.innerHeight || 768 };
131
- const a = anchor?.getBoundingClientRect?.() || { left: 0, top: 0, bottom: 0, right: 0 };
132
- const box = pop.getBoundingClientRect();
133
- // Under the anchor, its left edge on the anchor's; an anchor near the
134
- // right edge gets the popover's RIGHT edge on its own instead.
135
- let left = a.left;
136
- if (left + box.width > view.w - EDGE && a.right - box.width >= EDGE) left = a.right - box.width;
137
- let top = a.bottom + GAP;
138
- if (top + box.height > view.h - EDGE && a.top - GAP - box.height >= EDGE) top = a.top - GAP - box.height;
139
- left = Math.max(EDGE, Math.min(left, view.w - box.width - EDGE));
140
- top = Math.max(EDGE, Math.min(top, view.h - box.height - EDGE));
141
- pop.style.left = `${Math.round(left)}px`;
142
- pop.style.top = `${Math.round(top)}px`;
143
- };
144
-
145
- const onPress = (ev) => {
146
- if (pop.contains(ev.target)) return;
147
- if (anchor && anchor.contains?.(ev.target)) return; // the anchor's own click toggles
148
- handle.close('outside');
149
- };
150
- const onKey = (ev) => {
151
- if (ev.key !== 'Escape') return;
152
- ev.preventDefault();
153
- ev.stopPropagation();
154
- handle.close('escape');
155
- };
156
- const onResize = () => place();
157
- // Placed before its content is drawn — a picker fills its list after this
158
- // returns — so it is placed again whenever its size changes, or it would
159
- // run past the window's edge.
160
- const sized = typeof ResizeObserver === 'function' ? new ResizeObserver(() => place()) : null;
161
-
162
- const handle = {
163
- el: pop,
164
- anchor,
165
- toggled: false,
166
- reposition: place,
167
- close(reason = 'closed') {
168
- if (closed) return;
169
- closed = true;
170
- const held = pop.contains(document.activeElement) || document.activeElement === document.body;
171
- document.removeEventListener('pointerdown', onPress, true);
172
- document.removeEventListener('mousedown', onPress, true);
173
- pop.removeEventListener('keydown', onKey);
174
- window.removeEventListener('resize', onResize);
175
- sized?.disconnect();
176
- pop.remove();
177
- if (ACTIVE === handle) ACTIVE = null;
178
- // Focus that LEFT the popover (a Tab out of it) is going somewhere the
179
- // reader sent it; taking it back to the anchor would fight that move.
180
- if (held && reason !== 'blur' && anchor?.isConnected) anchor.focus?.({ preventScroll: true });
95
+ export function openPopover({ className = '', onClose = null, ...rest }) {
96
+ const handle = openAnchoredPopover({
97
+ ...rest,
98
+ className: `twm-flow-popover${className ? ` ${className}` : ''}`,
99
+ onClose(reason) {
100
+ OPEN.splice(OPEN.indexOf(handle), 1);
181
101
  onClose?.(reason);
182
102
  },
183
- };
184
- ACTIVE = handle;
185
- // The press that OPENED it is still being dispatched; listen from the next turn.
186
- setTimeout(() => {
187
- if (closed) return;
188
- document.addEventListener('pointerdown', onPress, true);
189
- document.addEventListener('mousedown', onPress, true);
190
- }, 0);
191
- pop.addEventListener('keydown', onKey);
192
- window.addEventListener('resize', onResize);
193
- sized?.observe(pop);
194
- place();
195
- (focus || pop).focus?.({ preventScroll: true });
103
+ });
104
+ if (handle.el) OPEN.push(handle);
196
105
  return handle;
197
106
  }
198
107