flexdesk 0.17.18 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/README.md +1 -0
  3. package/css/flexdesk.css +7547 -5891
  4. package/css/reset.css +1 -1
  5. package/css/tokens.css +30 -1
  6. package/dist/charts.js +83 -13
  7. package/dist/charts.js.map +2 -2
  8. package/dist/chunk-3BLIVOFD.js +778 -0
  9. package/dist/chunk-3BLIVOFD.js.map +7 -0
  10. package/dist/{chunk-7OL6F2AM.js → chunk-4X4SY3CP.js} +7 -5
  11. package/dist/chunk-4X4SY3CP.js.map +7 -0
  12. package/dist/chunk-DNJ3N4T4.js +82 -0
  13. package/dist/chunk-DNJ3N4T4.js.map +7 -0
  14. package/dist/{chunk-PGZ4EHSE.js → chunk-HL3IIHU3.js} +501 -359
  15. package/dist/chunk-HL3IIHU3.js.map +7 -0
  16. package/dist/{chunk-REJ5HLIM.js → chunk-HYJYYEPK.js} +2 -2
  17. package/dist/{chunk-7U4IB65H.js → chunk-LLBJ2SJH.js} +5 -3
  18. package/dist/{chunk-7U4IB65H.js.map → chunk-LLBJ2SJH.js.map} +1 -1
  19. package/dist/{chunk-XJNADRGR.js → chunk-NFZJ6P42.js} +188 -96
  20. package/dist/chunk-NFZJ6P42.js.map +7 -0
  21. package/dist/chunk-PAI2ZUWV.js +260 -0
  22. package/dist/chunk-PAI2ZUWV.js.map +7 -0
  23. package/dist/chunk-Q7BALEPX.js +527 -0
  24. package/dist/chunk-Q7BALEPX.js.map +7 -0
  25. package/dist/{chunk-2Z2ENHR3.js → chunk-QRBOIUU3.js} +7 -8
  26. package/dist/chunk-QRBOIUU3.js.map +7 -0
  27. package/dist/chunk-QXAOTGCC.js +40 -0
  28. package/dist/chunk-QXAOTGCC.js.map +7 -0
  29. package/dist/chunk-UKP22RUG.js +219 -0
  30. package/dist/chunk-UKP22RUG.js.map +7 -0
  31. package/dist/chunk-VLOAI3HS.js +43 -0
  32. package/dist/chunk-VLOAI3HS.js.map +7 -0
  33. package/dist/{chunk-WUFLGETN.js → chunk-WVFGV5FT.js} +2 -39
  34. package/dist/{chunk-WUFLGETN.js.map → chunk-WVFGV5FT.js.map} +3 -3
  35. package/dist/editor.js +3 -2
  36. package/dist/editor.js.map +1 -1
  37. package/dist/flexdesk.css +7547 -5891
  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 +30 -1
  46. package/dist/widgets.js +4146 -1199
  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/charts.md +40 -0
  53. package/docs/components/chat-shell.md +204 -0
  54. package/docs/components/conversation.md +10 -0
  55. package/docs/components/filter-builder.md +292 -0
  56. package/docs/components/forms-and-dialogs.md +30 -0
  57. package/docs/components/git.md +355 -0
  58. package/docs/components/input-fields.md +191 -2
  59. package/docs/components/keymap.md +8 -0
  60. package/docs/components/menus.md +86 -2
  61. package/docs/components/panels-and-desktops.md +16 -5
  62. package/docs/components/shell.md +108 -1
  63. package/docs/components/workspace-picker.md +193 -0
  64. package/docs/concepts/building-and-packaging.md +2 -2
  65. package/docs/concepts/packages-and-entry-points.md +14 -13
  66. package/docs/concepts/state-guard.md +3 -3
  67. package/docs/quickstart.md +1 -1
  68. package/git.js +13 -0
  69. package/package.json +6 -1
  70. package/src/charts/plot_config_editor.js +92 -6
  71. package/src/charts/plot_grid.js +6 -1
  72. package/src/flow/kit/dom.js +28 -119
  73. package/src/flow/kit/step_picker.js +1 -1
  74. package/src/flow/kit/value_picker.js +1 -1
  75. package/src/flow/outline/menu.js +1 -1
  76. package/src/git/diff_viewer.js +212 -0
  77. package/src/git/diff_window.js +51 -0
  78. package/src/git/git_graph.js +401 -0
  79. package/src/git/git_ops.js +61 -0
  80. package/src/git/git_panel.js +755 -0
  81. package/src/git/index.js +9 -0
  82. package/src/git/line_diff.js +202 -0
  83. package/src/tiles/tile_base.js +5 -4
  84. package/src/tiling/desktops.js +10 -8
  85. package/src/tiling/keymap.js +6 -5
  86. package/src/tiling/kind_taxonomy.js +15 -2
  87. package/src/tiling/shell.js +124 -17
  88. package/src/tiling/tile_renderer.js +2 -3
  89. package/src/ui/components/action_dropdown.js +4 -78
  90. package/src/ui/components/anchored_layer.js +255 -0
  91. package/src/ui/components/chat_shell.js +926 -0
  92. package/src/ui/components/chat_state_machine.js +223 -0
  93. package/src/ui/components/choice_controls.js +45 -2
  94. package/src/ui/components/color_field.js +3 -2
  95. package/src/ui/components/color_list_field.js +3 -2
  96. package/src/ui/components/combobox.js +422 -0
  97. package/src/ui/components/context_menu.js +8 -2
  98. package/src/ui/components/conversation_view.js +17 -2
  99. package/src/ui/components/data_table.js +2 -2
  100. package/src/ui/components/filter_builder.js +791 -0
  101. package/src/ui/components/filter_model.js +682 -0
  102. package/src/ui/components/inline_renamer.js +10 -5
  103. package/src/ui/components/managed_window.js +24 -47
  104. package/src/ui/components/modal.js +104 -117
  105. package/src/ui/components/option_list.js +160 -0
  106. package/src/ui/components/popover.js +94 -0
  107. package/src/ui/components/recent_items.js +169 -0
  108. package/src/ui/components/select.js +322 -0
  109. package/src/ui/components/tag_input.js +77 -8
  110. package/src/ui/components/workspace_picker.js +1253 -0
  111. package/src/ui/utils/fire_event.js +17 -0
  112. package/src/ui/utils/focusable.js +25 -0
  113. package/src/ui/utils/modal_host.js +27 -0
  114. package/src/ui/utils/tooltip_service.js +81 -14
  115. package/widgets.js +12 -2
  116. package/css/base.css +0 -10547
  117. package/css/overrides.css +0 -2596
  118. package/dist/chunk-2Z2ENHR3.js.map +0 -7
  119. package/dist/chunk-7JI7HHSY.js +0 -107
  120. package/dist/chunk-7JI7HHSY.js.map +0 -7
  121. package/dist/chunk-7OL6F2AM.js.map +0 -7
  122. package/dist/chunk-MIW7D6WC.js +0 -526
  123. package/dist/chunk-MIW7D6WC.js.map +0 -7
  124. package/dist/chunk-PGZ4EHSE.js.map +0 -7
  125. package/dist/chunk-XJNADRGR.js.map +0 -7
  126. /package/dist/{chunk-REJ5HLIM.js.map → chunk-HYJYYEPK.js.map} +0 -0
@@ -0,0 +1,51 @@
1
+ /**
2
+ * diff_window.js — a diff in a window of its own: FlexDesk's modal around a
3
+ * createDiffViewer, showing "Loading…" until the data arrives.
4
+ */
5
+
6
+ import { openModal } from '../ui/components/modal.js';
7
+ import { createDiffViewer } from './diff_viewer.js';
8
+
9
+ /**
10
+ * @param {Object} options
11
+ * @param {string} options.title
12
+ * @param {() => Promise<Object>|Object} options.load - Answers what `createDiffViewer`'s `update` takes, or
13
+ * `{ ok: false, error }`; a throw is shown the same way.
14
+ * @param {string} [options.icon='difference']
15
+ * @param {number} [options.width=880]
16
+ * @param {number} [options.height=600]
17
+ * @param {Object} [options.labels] - As for createDiffViewer.
18
+ * @returns {Promise<null>} Settles when the window closes.
19
+ */
20
+ export function openDiffWindow({ title, load, icon = 'difference', width = 880, height = 600, labels } = {}) {
21
+ const content = document.createElement('div');
22
+ content.className = 'twm-diff-window';
23
+ const viewer = createDiffViewer(content, { labels });
24
+ viewer.showMessage('Loading…');
25
+ const closed = openModal({ title, icon, content, width, height, maximizable: true });
26
+ Promise.resolve()
27
+ .then(load)
28
+ .then((data) => {
29
+ if (data?.ok === false) viewer.showMessage(data.error || 'The diff could not be read.');
30
+ else viewer.update(data);
31
+ })
32
+ .catch((e) => viewer.showMessage(e?.message || String(e)));
33
+ return closed;
34
+ }
35
+
36
+ /**
37
+ * The diff of one file, from an adapter's `diffFile`, in a window.
38
+ * @param {Object} options
39
+ * @param {Object} options.adapter - A git adapter with `diffFile` (docs/components/git.md).
40
+ * @param {string} options.path
41
+ * @param {string} [options.status]
42
+ * @param {string} [options.renamedFrom] - The path it had before, for a rename.
43
+ * @param {string} [options.ref] - A commit: the file's change in that commit, not the uncommitted one.
44
+ * @returns {Promise<null>}
45
+ */
46
+ export function openGitDiffWindow({ adapter, path, status, renamedFrom, ref }) {
47
+ return openDiffWindow({
48
+ title: renamedFrom ? `${renamedFrom} → ${path}` : path,
49
+ load: () => adapter.diffFile({ path, status, ...(renamedFrom ? { renamedFrom } : {}), ...(ref ? { ref } : {}) }),
50
+ });
51
+ }
@@ -0,0 +1,401 @@
1
+ /**
2
+ * git_graph.js — a commit history drawn as a vertical SVG swimlane graph, one
3
+ * row a commit, loaded a page at a time from the adapter's `log`.
4
+ *
5
+ * const graph = createGitGraph(el, { adapter, onOpenDiff, onChanged });
6
+ * graph.refresh();
7
+ * graph.destroy();
8
+ *
9
+ * Give `el` a height: the graph scrolls inside it and asks for the next page
10
+ * when the last row comes into view. `refresh()` re-reads what is loaded and
11
+ * redraws only if something differs, so it is safe to call on every status
12
+ * change without losing the scroll position.
13
+ *
14
+ * A row's context menu offers what the adapter can do (docs/components/git.md);
15
+ * each call goes through the adapter and a failure is an error toast with the
16
+ * backend's words. Nothing is stored on the DOM.
17
+ */
18
+
19
+ import { showContextMenu } from '../ui/components/context_menu.js';
20
+ import { reportBridgeError, toastInfo } from '../ui/components/toast.js';
21
+ import { askFor, callGit, openDiffHandler } from './git_ops.js';
22
+
23
+ const SVG_NS = 'http://www.w3.org/2000/svg';
24
+ /** The tokens `--twm-git-lane-0` … `-7`; a lane past the last takes them round again. */
25
+ const LANE_COLOURS = 8;
26
+ const LANE_WIDTH = 11;
27
+ const ROW_HEIGHT = 22; // .twm-git-graph__row's height in flexdesk.css
28
+ const NODE_RADIUS = 4;
29
+ const DEFAULT_PAGE_SIZE = 50;
30
+
31
+ /**
32
+ * Assign each commit to a lane. Commits come newest first, children before
33
+ * their parents (`git log --topo-order`). The answer is the same for a list
34
+ * and for any longer list that starts with it, so pages only ever add rows.
35
+ *
36
+ * Each row says what to draw in its own 22 pixels:
37
+ * lane the commit's own lane
38
+ * hasChild a line comes down into the commit (a branch tip has none)
39
+ * outputLanes `{ lane, parentHash }`: a line leaving the commit for each parent
40
+ * passLanes lanes that run straight through this row to a later commit
41
+ *
42
+ * @param {Array<{hash: string, parentHashes: string[]}>} commits
43
+ * @returns {{ rows: Array<Object>, laneCount: number }}
44
+ */
45
+ export function computeGraphLanes(commits) {
46
+ const active = []; // active[i]: the hash lane i is waiting for; null when free
47
+ const allocate = () => {
48
+ const free = active.indexOf(null);
49
+ if (free !== -1) return free;
50
+ active.push(null);
51
+ return active.length - 1;
52
+ };
53
+ const rows = commits.map((commit) => {
54
+ // A lane waits for one hash and no hash waits in two lanes: a parent
55
+ // already waited for is joined at its lane, not given another.
56
+ const waiting = active.indexOf(commit.hash);
57
+ const hasChild = waiting !== -1;
58
+ const lane = hasChild ? waiting : allocate();
59
+ active[lane] = null;
60
+ const passLanes = [];
61
+ active.forEach((hash, l) => { if (hash !== null) passLanes.push(l); });
62
+
63
+ const outputLanes = (commit.parentHashes ?? []).map((parentHash, i) => {
64
+ let parentLane = active.indexOf(parentHash);
65
+ if (parentLane === -1) parentLane = i === 0 ? lane : allocate();
66
+ active[parentLane] = parentHash;
67
+ return { lane: parentLane, parentHash };
68
+ });
69
+ return { lane, hasChild, outputLanes, passLanes };
70
+ });
71
+ return { rows, laneCount: Math.max(active.length, 1) };
72
+ }
73
+
74
+ /** What each full ref name in a decoration list is, by how it starts. */
75
+ const REF_KINDS = [
76
+ ['HEAD -> refs/heads/', 'head'],
77
+ ['tag: refs/tags/', 'tag'],
78
+ ['refs/heads/', 'branch'],
79
+ ['refs/remotes/', 'remote'],
80
+ ];
81
+
82
+ /**
83
+ * Read `git log --decorate=full`'s `%D` list into pills. Full names are what
84
+ * tell a remote's `refs/remotes/origin/main` from a local branch that happens
85
+ * to be called `origin/main`. Other refs, such as `refs/stash`, are left out.
86
+ * @param {string} refs - `HEAD -> refs/heads/main, tag: refs/tags/v1, refs/remotes/origin/main`
87
+ * @returns {Array<{name: string, type: 'head'|'tag'|'branch'|'remote'}>}
88
+ */
89
+ export function parseGitRefs(refs) {
90
+ if (!refs) return [];
91
+ return String(refs).split(',').map((s) => s.trim()).flatMap((part) => {
92
+ if (part === 'HEAD') return [{ name: 'HEAD', type: 'head' }];
93
+ const kind = REF_KINDS.find(([prefix]) => part.startsWith(prefix));
94
+ return kind ? [{ name: part.slice(kind[0].length), type: kind[1] }] : [];
95
+ });
96
+ }
97
+
98
+ const laneX = (lane) => lane * LANE_WIDTH + LANE_WIDTH / 2;
99
+
100
+ function edge(d, lane) {
101
+ const path = document.createElementNS(SVG_NS, 'path');
102
+ path.setAttribute('class', `twm-git-graph__edge twm-git-lane--${lane % LANE_COLOURS}`);
103
+ path.setAttribute('d', d);
104
+ return path;
105
+ }
106
+
107
+ function sizeGutter(svg, laneCount) {
108
+ const width = Math.max(laneCount * LANE_WIDTH, LANE_WIDTH * 2);
109
+ svg.setAttribute('width', String(width));
110
+ svg.setAttribute('viewBox', `0 0 ${width} ${ROW_HEIGHT}`);
111
+ }
112
+
113
+ function createGutter(row, laneCount) {
114
+ const svg = document.createElementNS(SVG_NS, 'svg');
115
+ svg.setAttribute('class', 'twm-git-graph__svg');
116
+ svg.setAttribute('height', String(ROW_HEIGHT));
117
+ svg.setAttribute('aria-hidden', 'true');
118
+ sizeGutter(svg, laneCount);
119
+
120
+ const h = ROW_HEIGHT;
121
+ const cx = laneX(row.lane);
122
+ const cy = h / 2;
123
+ for (const lane of row.passLanes) {
124
+ const x = laneX(lane);
125
+ svg.appendChild(edge(`M${x},0 L${x},${h}`, lane));
126
+ }
127
+ if (row.hasChild) svg.appendChild(edge(`M${cx},0 L${cx},${cy}`, row.lane));
128
+ for (const { lane } of row.outputLanes) {
129
+ const x = laneX(lane);
130
+ svg.appendChild(edge(x === cx
131
+ ? `M${cx},${cy} L${cx},${h}`
132
+ : `M${cx},${cy} C${cx},${h * 0.8} ${x},${h * 0.6} ${x},${h}`, lane));
133
+ }
134
+ const node = document.createElementNS(SVG_NS, 'circle');
135
+ node.setAttribute('class', `twm-git-graph__node twm-git-lane--${row.lane % LANE_COLOURS}`);
136
+ node.setAttribute('cx', String(cx));
137
+ node.setAttribute('cy', String(cy));
138
+ node.setAttribute('r', String(NODE_RADIUS));
139
+ svg.appendChild(node);
140
+ return svg;
141
+ }
142
+
143
+ function commitTitle(commit) {
144
+ const date = new Date(commit.timestamp * 1000).toLocaleDateString();
145
+ return `${commit.shortHash} — ${commit.author}\n${date}\n\n${commit.subject}`;
146
+ }
147
+
148
+ /**
149
+ * @param {HTMLElement} host
150
+ * @param {Object} [options]
151
+ * @param {Object|null} [options.adapter] - A git adapter with `log`; the rest is used when present.
152
+ * @param {(file: {path: string, status: string, ref: string}) => void} [options.onOpenDiff] - Opens a file of a
153
+ * commit. Without it, a diff window when the adapter has `diffFile`.
154
+ * @param {() => void} [options.onChanged] - Told after a menu operation changed the repository.
155
+ * @param {number} [options.pageSize=50]
156
+ * @param {boolean} [options.showRemoteRefs=false] - Draw remote branches' pills as well; a narrow panel leaves them out.
157
+ * @returns {{ el: HTMLElement, refresh(): Promise<void>, setAdapter(adapter: Object|null): void, destroy(): void }}
158
+ */
159
+ export function createGitGraph(host, options = {}) {
160
+ const { onChanged = null, pageSize = DEFAULT_PAGE_SIZE, showRemoteRefs = false } = options;
161
+ let adapter = options.adapter ?? null;
162
+ const openDiff = openDiffHandler(() => adapter, options.onOpenDiff);
163
+
164
+ const root = document.createElement('div');
165
+ root.className = 'twm-git-graph';
166
+ host.appendChild(root);
167
+
168
+ let commits = [];
169
+ let drawnJson = '';
170
+ let allLoaded = false;
171
+ let generation = 0; // a newer read makes an older answer stale
172
+ let observer = null;
173
+ let sentinel = null;
174
+ let destroyed = false;
175
+
176
+ const say = (text) => {
177
+ const m = document.createElement('div');
178
+ m.className = 'twm-git-graph__message';
179
+ m.textContent = text;
180
+ root.replaceChildren(m);
181
+ };
182
+
183
+ const stopObserving = () => {
184
+ observer?.disconnect();
185
+ observer = null;
186
+ sentinel?.remove();
187
+ sentinel = null;
188
+ };
189
+
190
+ /** `git` is the adapter the menu was opened on; a file opens only while that repository still shows. */
191
+ const openFiles = async (git, commit, x, y) => {
192
+ const result = await callGit('Git changes', () => git.commitFiles({ hash: commit.hash }));
193
+ if (!result || git !== adapter) return;
194
+ const files = result.files ?? [];
195
+ if (files.length === 0) {
196
+ toastInfo('No files changed', commit.shortHash);
197
+ } else if (files.length === 1) {
198
+ openDiff({ ...files[0], ref: commit.hash });
199
+ } else {
200
+ const items = files.map((f, i) => ({ label: f.path, icon: 'difference', action: String(i) }));
201
+ showContextMenu(x, y, items, (action) => {
202
+ if (git === adapter) openDiff({ ...files[Number(action)], ref: commit.hash });
203
+ });
204
+ }
205
+ };
206
+
207
+ const menuItems = (git) => {
208
+ const groups = [
209
+ [
210
+ git.checkout && { label: 'Checkout (detached)', icon: 'logout', action: 'checkout' },
211
+ git.createBranch && { label: 'Create Branch…', icon: 'commit', action: 'create-branch' },
212
+ { label: 'Copy Commit Hash', icon: 'content_copy', action: 'copy-hash' },
213
+ ],
214
+ [
215
+ git.cherryPick && { label: 'Cherry-pick', icon: 'content_copy', action: 'cherry-pick' },
216
+ git.createTag && { label: 'Create Tag…', icon: 'sell', action: 'create-tag' },
217
+ git.commitFiles && { label: 'View Changes', icon: 'difference', action: 'view-changes' },
218
+ ],
219
+ ].map((group) => group.filter(Boolean)).filter((group) => group.length > 0);
220
+ return groups.flatMap((group, i) => (i === 0 ? group : [{ separator: true }, ...group]));
221
+ };
222
+
223
+ const onMenuAction = async (git, action, commit, x, y) => {
224
+ const { hash } = commit;
225
+ /** Run an operation; whatever came of it, the graph and its owner read the repository again. */
226
+ const change = async (opName, fn) => {
227
+ await callGit(opName, fn);
228
+ await refresh();
229
+ onChanged?.();
230
+ };
231
+ switch (action) {
232
+ case 'checkout':
233
+ await change('Git checkout', () => git.checkout({ ref: hash }));
234
+ break;
235
+ case 'create-branch': {
236
+ const values = await askFor('Create Branch', 'Create', [['name', 'Branch name']]);
237
+ if (values) await change('Git create branch', () => git.createBranch({ name: values.name, from: hash }));
238
+ break;
239
+ }
240
+ case 'copy-hash':
241
+ try {
242
+ await navigator.clipboard.writeText(hash);
243
+ toastInfo('Commit hash copied', commit.shortHash);
244
+ } catch (e) {
245
+ reportBridgeError('Copy commit hash failed', e);
246
+ }
247
+ break;
248
+ case 'cherry-pick':
249
+ await change('Git cherry-pick', () => git.cherryPick({ hash }));
250
+ break;
251
+ case 'create-tag': {
252
+ const values = await askFor('Create Tag', 'Create', [['name', 'Tag name']]);
253
+ if (values) await change('Git create tag', () => git.createTag({ name: values.name, hash }));
254
+ break;
255
+ }
256
+ case 'view-changes':
257
+ await openFiles(git, commit, x, y);
258
+ break;
259
+ }
260
+ };
261
+
262
+ const createRow = (commit, laneRow, laneCount) => {
263
+ const row = document.createElement('div');
264
+ row.className = 'twm-git-graph__row';
265
+ row.title = commitTitle(commit);
266
+ row.tabIndex = 0;
267
+ row.appendChild(createGutter(laneRow, laneCount));
268
+
269
+ const info = document.createElement('div');
270
+ info.className = 'twm-git-graph__info';
271
+ for (const pill of parseGitRefs(commit.refs)) {
272
+ if (pill.type === 'remote' && !showRemoteRefs) continue;
273
+ const span = document.createElement('span');
274
+ span.className = `twm-git-ref twm-git-ref--${pill.type}`;
275
+ span.textContent = pill.name;
276
+ span.title = pill.name;
277
+ info.appendChild(span);
278
+ }
279
+ const subject = document.createElement('span');
280
+ subject.className = 'twm-git-graph__subject';
281
+ subject.textContent = commit.subject;
282
+ info.appendChild(subject);
283
+ row.appendChild(info);
284
+
285
+ const openMenu = (x, y) => {
286
+ const git = adapter;
287
+ showContextMenu(x, y, menuItems(git), (action) => onMenuAction(git, action, commit, x, y));
288
+ };
289
+ row.addEventListener('contextmenu', (e) => {
290
+ e.preventDefault();
291
+ openMenu(e.clientX, e.clientY);
292
+ });
293
+ row.addEventListener('keydown', (e) => {
294
+ if (e.key !== 'ContextMenu' && !(e.key === 'F10' && e.shiftKey)) return;
295
+ e.preventDefault();
296
+ const r = row.getBoundingClientRect();
297
+ openMenu(r.left, r.bottom);
298
+ });
299
+ return row;
300
+ };
301
+
302
+ const observeSentinel = () => {
303
+ stopObserving();
304
+ if (allLoaded || commits.length === 0) return;
305
+ sentinel = document.createElement('div');
306
+ sentinel.className = 'twm-git-graph__sentinel';
307
+ root.appendChild(sentinel);
308
+ observer = new IntersectionObserver((entries) => {
309
+ if (entries[0]?.isIntersecting) loadMore();
310
+ }, { root });
311
+ observer.observe(sentinel);
312
+ };
313
+
314
+ /** Draw the rows from `from` on, and widen the gutters above them when the lanes outgrew them. */
315
+ const drawRows = (from) => {
316
+ stopObserving();
317
+ const { rows, laneCount } = computeGraphLanes(commits);
318
+ for (const svg of root.querySelectorAll('.twm-git-graph__svg')) sizeGutter(svg, laneCount);
319
+ const fragment = document.createDocumentFragment();
320
+ for (let i = from; i < commits.length; i++) fragment.appendChild(createRow(commits[i], rows[i], laneCount));
321
+ root.appendChild(fragment);
322
+ observeSentinel();
323
+ };
324
+
325
+ const clear = () => {
326
+ stopObserving();
327
+ commits = [];
328
+ drawnJson = '';
329
+ allLoaded = false;
330
+ root.replaceChildren();
331
+ };
332
+
333
+ /** Read from the top as many commits as are loaded, replacing what is drawn only if they differ. */
334
+ const refresh = async () => {
335
+ const gen = ++generation;
336
+ if (!adapter?.log) {
337
+ clear();
338
+ return;
339
+ }
340
+ const count = Math.max(commits.length, pageSize);
341
+ let result;
342
+ try {
343
+ result = await adapter.log({ count, skip: 0 });
344
+ } catch (e) {
345
+ result = { ok: false, error: e?.message || String(e) };
346
+ }
347
+ if (destroyed || gen !== generation) return;
348
+ if (!result?.ok) {
349
+ clear();
350
+ say(result?.error || 'The history could not be read.');
351
+ return;
352
+ }
353
+ const next = result.commits ?? [];
354
+ const json = JSON.stringify(next);
355
+ if (json === drawnJson) {
356
+ if (!observer) observeSentinel(); // a read that overtook loadMore must not end the paging
357
+ return;
358
+ }
359
+ const top = root.scrollTop;
360
+ clear();
361
+ drawnJson = json;
362
+ commits = next;
363
+ allLoaded = next.length < count;
364
+ if (commits.length === 0) {
365
+ say('No commits yet');
366
+ return;
367
+ }
368
+ drawRows(0);
369
+ root.scrollTop = top;
370
+ };
371
+
372
+ const loadMore = async () => {
373
+ const gen = ++generation;
374
+ stopObserving();
375
+ const skip = commits.length;
376
+ const result = await callGit('Git log', () => adapter.log({ count: pageSize, skip }));
377
+ if (destroyed || gen !== generation || !result) return;
378
+ const page = result.commits ?? [];
379
+ allLoaded = page.length < pageSize;
380
+ commits = commits.concat(page);
381
+ drawnJson = JSON.stringify(commits);
382
+ drawRows(skip);
383
+ };
384
+
385
+ refresh();
386
+
387
+ return {
388
+ el: root,
389
+ refresh,
390
+ setAdapter(next) {
391
+ adapter = next ?? null;
392
+ clear();
393
+ refresh();
394
+ },
395
+ destroy() {
396
+ destroyed = true;
397
+ stopObserving();
398
+ root.remove();
399
+ },
400
+ };
401
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * git_ops.js — what the panel and the graph share about calling an adapter.
3
+ * Internal to @flexdesk/git: not exported from its barrel.
4
+ */
5
+
6
+ import { openForm } from '../ui/components/modal.js';
7
+ import { reportBridgeError } from '../ui/components/toast.js';
8
+ import { openGitDiffWindow } from './diff_window.js';
9
+
10
+ /**
11
+ * Call an adapter method. A failure, an `{ ok: false, error }` answer or a
12
+ * throw alike, is an error toast carrying the backend's words; a stop on
13
+ * conflicts is said to be that.
14
+ * @param {string} opName - As the toast names it: 'Git push'.
15
+ * @param {() => Promise<Object>} fn
16
+ * @returns {Promise<Object|null>} The answer, or null when it failed.
17
+ */
18
+ export async function callGit(opName, fn) {
19
+ let result;
20
+ try {
21
+ result = await fn();
22
+ } catch (e) {
23
+ reportBridgeError(`${opName} failed`, e instanceof Error ? e : new Error(String(e)));
24
+ return null;
25
+ }
26
+ const title = result?.conflicts ? `${opName} stopped on conflicts` : `${opName} failed`;
27
+ return reportBridgeError(title, result) ? (result ?? { ok: true }) : null;
28
+ }
29
+
30
+ /**
31
+ * Ask for text values in a FlexDesk form.
32
+ * @param {string} title
33
+ * @param {string} submitLabel
34
+ * @param {Array<[string, string]>} fields - `[name, label]` pairs, all required.
35
+ * @returns {Promise<Object|null>} The trimmed values, or null when cancelled or left blank.
36
+ */
37
+ export async function askFor(title, submitLabel, fields) {
38
+ const values = await openForm({
39
+ title,
40
+ submitLabel,
41
+ fields: fields.map(([name, label]) => ({ name, label, type: 'text', required: true })),
42
+ });
43
+ if (!values) return null;
44
+ const trimmed = Object.fromEntries(fields.map(([name]) => [name, String(values[name] ?? '').trim()]));
45
+ return Object.values(trimmed).every(Boolean) ? trimmed : null;
46
+ }
47
+
48
+ /**
49
+ * What opening a file's diff does: the consumer's callback, else a diff window
50
+ * when the adapter has `diffFile`.
51
+ * @param {() => Object|null} getAdapter
52
+ * @param {Function|null|undefined} onOpenDiff
53
+ * @returns {(file: {path: string, status: string, renamedFrom?: string, ref?: string}) => void}
54
+ */
55
+ export function openDiffHandler(getAdapter, onOpenDiff) {
56
+ if (onOpenDiff) return onOpenDiff;
57
+ return ({ path, status, renamedFrom, ref }) => {
58
+ const adapter = getAdapter();
59
+ if (adapter?.diffFile) openGitDiffWindow({ adapter, path, status, renamedFrom, ref });
60
+ };
61
+ }