@3sln/trove 0.0.7 → 0.0.9
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 +1 -1
- package/packages/core/src/collections/index.js +146 -1
- package/packages/core/src/encryption/envelope.js +483 -0
- package/packages/core/src/encryption/exposure.js +101 -0
- package/packages/core/src/encryption/keys.js +88 -0
- package/packages/core/src/encryption/policy.js +112 -0
- package/packages/core/src/encryption/rotation.js +432 -0
- package/packages/core/src/index.js +17 -1
- package/packages/core/src/links.js +85 -0
- package/packages/core/src/metadata/memory.js +3 -1
- package/packages/core/src/metadata/sqlite.js +27 -7
- package/packages/core/src/scan.js +35 -1
- package/packages/core/src/storage/cost.js +228 -0
- package/packages/core/src/uploads.js +302 -19
- package/packages/core/src/vfs.js +161 -10
- package/packages/server/src/adapters/staticAssets.js +14 -5
- package/packages/server/src/engine/index.js +1 -1
- package/packages/server/src/engine/providers/core.js +21 -2
- package/packages/server/src/index.js +45 -2
- package/packages/server/src/routes.js +62 -1
- package/packages/web/dist/assets/main-jg5vmp8f.js +356 -0
- package/packages/web/dist/assets/main-jg5vmp8f.js.map +118 -0
- package/packages/web/dist/assets/styles-e5gk19rn.css +1 -0
- package/packages/web/dist/index.html +9 -3
- package/packages/web/dist/sw.js +1 -1
- package/packages/web/src/bl/actions.js +1471 -138
- package/packages/web/src/bl/commands.js +103 -284
- package/packages/web/src/bl/context.js +69 -0
- package/packages/web/src/bl/index.js +136 -40
- package/packages/web/src/bl/intern.js +129 -0
- package/packages/web/src/bl/launcher.js +251 -0
- package/packages/web/src/bl/links.js +1 -1
- package/packages/web/src/bl/match.js +71 -0
- package/packages/web/src/bl/mentions.js +39 -0
- package/packages/web/src/bl/offline.js +0 -4
- package/packages/web/src/bl/openers.js +65 -33
- package/packages/web/src/bl/pluginInstall.js +42 -21
- package/packages/web/src/bl/queries.js +669 -0
- package/packages/web/src/bl/services.js +141 -145
- package/packages/web/src/bl/social.js +3 -1
- package/packages/web/src/bl/state.js +144 -0
- package/packages/web/src/bl/status.js +96 -0
- package/packages/web/src/bl/trust.js +109 -0
- package/packages/web/src/bl/viewState.js +60 -0
- package/packages/web/src/bl/views.js +64 -0
- package/packages/web/src/bl/watchQuery.js +73 -0
- package/packages/web/src/platform/api.js +63 -11
- package/packages/web/src/platform/commands.js +49 -9
- package/packages/web/src/platform/context.js +146 -51
- package/packages/web/src/platform/index.js +27 -20
- package/packages/web/src/platform/navigation.js +34 -4
- package/packages/web/src/platform/pickers.js +46 -0
- package/packages/web/src/platform/pluginHost.js +43 -10
- package/packages/web/src/platform/pluginRpc.js +6 -2
- package/packages/web/src/platform/spatialNav.js +0 -2
- package/packages/web/src/platform/viewport.js +1 -6
- package/packages/web/src/platform/voiceSearch.js +1 -3
- package/packages/web/src/platform/whenclause.js +22 -3
- package/packages/web/src/styles.css +77 -4
- package/packages/web/src/ui/activate.js +22 -0
- package/packages/web/src/ui/components/activityBar.js +7 -3
- package/packages/web/src/ui/components/activityPanel.js +10 -11
- package/packages/web/src/ui/components/adminView.js +191 -0
- package/packages/web/src/ui/components/collectionGate.js +4 -3
- package/packages/web/src/ui/components/commandPalette.js +24 -55
- package/packages/web/src/ui/components/editorArea.js +16 -13
- package/packages/web/src/ui/components/launcher.js +39 -225
- package/packages/web/src/ui/components/openers/index.js +7 -4
- package/packages/web/src/ui/components/openers/markdown.js +5 -3
- package/packages/web/src/ui/components/overlays.js +169 -43
- package/packages/web/src/ui/components/phoneChrome.js +35 -33
- package/packages/web/src/ui/components/pluginReview.js +62 -23
- package/packages/web/src/ui/components/pluginsView.js +30 -28
- package/packages/web/src/ui/components/settingsView.js +151 -58
- package/packages/web/src/ui/components/social.js +42 -62
- package/packages/web/src/ui/components/statusBar.js +30 -74
- package/packages/web/src/ui/components/views/grid.js +3 -2
- package/packages/web/src/ui/components/views/index.js +7 -62
- package/packages/web/src/ui/components/views/list.js +3 -2
- package/packages/web/src/ui/components/views/parts.js +15 -4
- package/packages/web/src/ui/compositions/workbench.js +166 -58
- package/packages/web/src/ui/region.js +53 -0
- package/packages/web/src/workbench.js +17 -15
- package/packages/web/dist/assets/main-f0f2tfhp.js +0 -356
- package/packages/web/dist/assets/main-f0f2tfhp.js.map +0 -104
- package/packages/web/dist/assets/styles-d3cyysgp.css +0 -1
- package/packages/web/src/platform/overlay.js +0 -81
- package/packages/web/src/platform/workbench.js +0 -156
|
@@ -5,8 +5,11 @@
|
|
|
5
5
|
import { dd } from '../../runtime.js';
|
|
6
6
|
import { icon } from '../icon.js';
|
|
7
7
|
import { relativeDate } from '../format.js';
|
|
8
|
-
import { OpenFileAction } from '../../bl/actions.js';
|
|
8
|
+
import { AddTagAction, CopyTextAction, DeleteCommentAction, EnablePushAction, ExecCommandAction, LoadSidecarAction, OpenFileAction, OpenNotificationTargetAction, PostCommentAction, ReactToCommentAction, RemoveTagAction, SetReplyToAction, ToggleInboxAction, ToggleInfoPanelAction } from '../../bl/actions.js';
|
|
9
9
|
import { troveUri } from '@3sln/trove/core/links.js';
|
|
10
|
+
import { parseMentions } from '../../bl/mentions.js';
|
|
11
|
+
import { sidecarFor } from '../../bl/queries.js';
|
|
12
|
+
import { watchQuery } from '../../bl/watchQuery.js';
|
|
10
13
|
|
|
11
14
|
const { div, span, button, textarea, input, p } = dd;
|
|
12
15
|
|
|
@@ -28,26 +31,24 @@ export function principalChip(state) {
|
|
|
28
31
|
|
|
29
32
|
export function notificationBell(state, ui) {
|
|
30
33
|
const n = state.so.notifications;
|
|
31
|
-
const social = ui.app.social;
|
|
32
34
|
return div({ className: 'bell-wrap' },
|
|
33
35
|
button({ className: 'iconbtn bell', title: 'Notifications' },
|
|
34
36
|
icon('bell', { size: 19 }),
|
|
35
37
|
n.unread ? span({ className: 'bell-badge' }, String(n.unread > 9 ? '9+' : n.unread)) : null,
|
|
36
|
-
).on({ click: () =>
|
|
38
|
+
).on({ click: () => ui.engine.dispatch(new ToggleInboxAction()) }),
|
|
37
39
|
state.so.inboxOpen ? inboxDropdown(state, ui) : null,
|
|
38
40
|
);
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
function inboxDropdown(state, ui) {
|
|
42
44
|
const items = state.so.notifications.items;
|
|
43
|
-
const social = ui.app.social;
|
|
44
45
|
return div({},
|
|
45
|
-
div({ className: 'scrim', $styling: { background: 'transparent', 'z-index': '54' } }).on({ click: () =>
|
|
46
|
+
div({ className: 'scrim', $styling: { background: 'transparent', 'z-index': '54' } }).on({ click: () => ui.engine.dispatch(new ToggleInboxAction(false)) }),
|
|
46
47
|
div({ className: 'inbox' },
|
|
47
48
|
div({ className: 'inbox-head' },
|
|
48
49
|
span('Notifications'),
|
|
49
50
|
state.so.pushSupported && !state.so.pushEnabled
|
|
50
|
-
? button({ className: 'link' }, 'Enable push').on({ click: () =>
|
|
51
|
+
? button({ className: 'link' }, 'Enable push').on({ click: () => ui.engine.dispatch(new EnablePushAction()) })
|
|
51
52
|
: state.so.pushEnabled ? span({ className: 'muted' }, icon('check', { size: 13 })) : null,
|
|
52
53
|
),
|
|
53
54
|
items.length
|
|
@@ -65,21 +66,10 @@ function inboxItem(note, ui) {
|
|
|
65
66
|
div({ className: 'ii-time' }, relativeDate(note.createdAt)),
|
|
66
67
|
).on({
|
|
67
68
|
click: () => {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}).catch((err) => {
|
|
73
|
-
// The item a notification points at can be deleted, or live somewhere the
|
|
74
|
-
// reader lost access to. Either way the click must not just do nothing.
|
|
75
|
-
ui.platform.notifications.warn(
|
|
76
|
-
err?.status === 403 || err?.code === 'forbidden'
|
|
77
|
-
? 'You no longer have access to that item.'
|
|
78
|
-
: 'That item no longer exists.',
|
|
79
|
-
);
|
|
80
|
-
});
|
|
81
|
-
}
|
|
82
|
-
ui.app.social.toggleInbox(false);
|
|
69
|
+
// One intent, one action — including what to say when the item a notification
|
|
70
|
+
// points at is gone. See OpenNotificationTargetAction.
|
|
71
|
+
if (first?.nodeId) ui.engine.dispatch(new OpenNotificationTargetAction(first.nodeId));
|
|
72
|
+
ui.engine.dispatch(new ToggleInboxAction(false));
|
|
83
73
|
},
|
|
84
74
|
});
|
|
85
75
|
}
|
|
@@ -89,26 +79,31 @@ function inboxItem(note, ui) {
|
|
|
89
79
|
export function infoPanel(state, ui) {
|
|
90
80
|
const nav = state.nav;
|
|
91
81
|
const active = nav.activeFile ? { id: nav.activeTabId, node: nav.activeFile } : null;
|
|
92
|
-
const sc = state.so.sidecar;
|
|
93
82
|
return div({ className: 'infopanel' },
|
|
94
83
|
div({ className: 'ip-head' },
|
|
95
84
|
icon('info', { size: 15 }),
|
|
96
85
|
span('Details'),
|
|
97
|
-
button({ className: 'iconbtn', title: 'Close' }, icon('close', { size: 14 })).on({ click: () => ui.
|
|
86
|
+
button({ className: 'iconbtn', title: 'Close' }, icon('close', { size: 14 })).on({ click: () => ui.engine.dispatch(new ToggleInfoPanelAction(false)) }),
|
|
98
87
|
),
|
|
99
88
|
!active
|
|
100
89
|
? div({ className: 'ip-empty' }, span('Open a file to see its tags and conversation.'))
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
),
|
|
90
|
+
// Watching `sidecarFor(id)` is what LOADS it: the query's bootAction fetches when the
|
|
91
|
+
// first observer arrives and its killAction clears when the last one leaves. Nothing
|
|
92
|
+
// watches the nav stack to notice a file changed — a different file is a different
|
|
93
|
+
// query. See bl/queries.js.
|
|
94
|
+
: ui.watch(watchQuery(ui.engine, sidecarFor(active.node.id)), (sc) => div({ className: 'ip-body' },
|
|
95
|
+
fileHeader(active.node, state, ui),
|
|
96
|
+
linkSection(active.node, state, ui),
|
|
97
|
+
tagSection(sc, ui),
|
|
98
|
+
conversationSection(state, sc, ui),
|
|
99
|
+
)),
|
|
107
100
|
);
|
|
108
101
|
}
|
|
109
102
|
|
|
110
103
|
function fileHeader(node, state, ui) {
|
|
111
|
-
|
|
104
|
+
// From the offline view, which resolves the pin set — the component asks whether this
|
|
105
|
+
// file is pinned without knowing that pins are a list to be scanned.
|
|
106
|
+
const pinned = state.off?.pinnedIds?.has(node.id) ?? false;
|
|
112
107
|
return div({ className: 'ip-file' },
|
|
113
108
|
div({ className: 'ip-name' }, node.name),
|
|
114
109
|
div({ className: 'ip-path' }, node.contentType || ''),
|
|
@@ -118,7 +113,7 @@ function fileHeader(node, state, ui) {
|
|
|
118
113
|
button({ className: `btn small ${pinned ? 'on' : ''}`, $styling: { 'margin-top': '10px' } },
|
|
119
114
|
icon(pinned ? 'check' : 'download', { size: 14 }),
|
|
120
115
|
pinned ? 'Available offline' : 'Make available offline',
|
|
121
|
-
).on({ click: () => (pinned ? ui.
|
|
116
|
+
).on({ click: () => (pinned ? ui.engine.dispatch(new ExecCommandAction('offline.unpin', node)) : ui.engine.dispatch(new ExecCommandAction('offline.pin', node))) }),
|
|
122
117
|
);
|
|
123
118
|
}
|
|
124
119
|
|
|
@@ -147,18 +142,13 @@ function linkSection(node, state, ui) {
|
|
|
147
142
|
: mine?.items?.length
|
|
148
143
|
? div({ className: 'ip-backlinks' }, ...mine.items.map((n) =>
|
|
149
144
|
button({ className: 'ip-backlink', title: n.name }, icon('file-text', { size: 12 }), span(n.name))
|
|
150
|
-
.on({ click: () => ui.
|
|
145
|
+
.on({ click: () => ui.engine.dispatch(new OpenFileAction(n)) })))
|
|
151
146
|
: div({ className: 'ip-muted' }, 'Nothing links here yet.'),
|
|
152
147
|
);
|
|
153
148
|
}
|
|
154
149
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
await navigator.clipboard.writeText(uri);
|
|
158
|
-
ui.platform.notifications.success(`Copied ${uri}`);
|
|
159
|
-
} catch {
|
|
160
|
-
ui.platform.notifications.info(uri, { sticky: true });
|
|
161
|
-
}
|
|
150
|
+
function copyLink(ui, uri) {
|
|
151
|
+
ui.engine.dispatch(new CopyTextAction(uri, `Copied ${uri}`));
|
|
162
152
|
}
|
|
163
153
|
|
|
164
154
|
function tagSection(sc, ui) {
|
|
@@ -169,7 +159,7 @@ function tagSection(sc, ui) {
|
|
|
169
159
|
...tags.map((t) =>
|
|
170
160
|
span({ className: 'tag' },
|
|
171
161
|
t.value ? `${t.name}: ${t.value}` : t.name,
|
|
172
|
-
button({ className: 'tag-x' }, icon('close', { size: 11 })).on({ click: () => ui.
|
|
162
|
+
button({ className: 'tag-x' }, icon('close', { size: 11 })).on({ click: () => ui.engine.dispatch(new RemoveTagAction(t.name)) }),
|
|
173
163
|
),
|
|
174
164
|
),
|
|
175
165
|
input({ className: 'tag-input', placeholder: '+ tag', value: '' }).on({
|
|
@@ -177,7 +167,7 @@ function tagSection(sc, ui) {
|
|
|
177
167
|
if (e.key === 'Enter' && e.target.value.trim()) {
|
|
178
168
|
const raw = e.target.value.trim();
|
|
179
169
|
const [name, ...rest] = raw.split(':');
|
|
180
|
-
ui.
|
|
170
|
+
ui.engine.dispatch(new AddTagAction(name.trim(), rest.join(':').trim() || undefined));
|
|
181
171
|
e.target.value = '';
|
|
182
172
|
}
|
|
183
173
|
},
|
|
@@ -194,7 +184,7 @@ function conversationSection(state, sc, ui) {
|
|
|
194
184
|
const body = sc?.error
|
|
195
185
|
? div({ className: 'conv-empty' },
|
|
196
186
|
span(`Couldn't load the conversation: ${sc.error}`),
|
|
197
|
-
button({ className: 'btn', $styling: { 'margin-top': '8px' } }, 'Retry').on({ click: () => ui.
|
|
187
|
+
button({ className: 'btn', $styling: { 'margin-top': '8px' } }, 'Retry').on({ click: () => ui.engine.dispatch(new LoadSidecarAction(sc.nodeId)) }))
|
|
198
188
|
: comments.length
|
|
199
189
|
? div({ className: 'thread' }, ...comments.map((c) => commentNode(c, state, ui, 0)))
|
|
200
190
|
: div({ className: 'conv-empty' }, 'No comments yet. Start the discussion — use @ to mention someone.');
|
|
@@ -207,7 +197,6 @@ function conversationSection(state, sc, ui) {
|
|
|
207
197
|
|
|
208
198
|
function commentNode(c, state, ui, depth) {
|
|
209
199
|
const me = state.so.me;
|
|
210
|
-
const social = ui.app.social;
|
|
211
200
|
const mine = me && c.author?.id === me.id;
|
|
212
201
|
return div({ className: 'comment', $styling: depth ? { marginLeft: '18px' } : {} },
|
|
213
202
|
div({ className: 'c-head' },
|
|
@@ -222,17 +211,16 @@ function commentNode(c, state, ui, depth) {
|
|
|
222
211
|
...REACTIONS.map((emoji) => {
|
|
223
212
|
const users = c.reactions?.[emoji] || [];
|
|
224
213
|
return button({ className: `react ${users.includes(me?.id) ? 'on' : ''}` }, emoji, users.length ? span({ className: 'rc' }, String(users.length)) : null)
|
|
225
|
-
.on({ click: () =>
|
|
214
|
+
.on({ click: () => ui.engine.dispatch(new ReactToCommentAction(c.id, emoji)) });
|
|
226
215
|
}),
|
|
227
|
-
button({ className: 'c-link' }, 'Reply').on({ click: () =>
|
|
228
|
-
mine ? button({ className: 'c-link danger' }, 'Delete').on({ click: () =>
|
|
216
|
+
button({ className: 'c-link' }, 'Reply').on({ click: () => ui.engine.dispatch(new SetReplyToAction({ id: c.id, author: c.author })) }),
|
|
217
|
+
mine ? button({ className: 'c-link danger' }, 'Delete').on({ click: () => ui.engine.dispatch(new DeleteCommentAction(c.id)) }) : null,
|
|
229
218
|
) : null,
|
|
230
219
|
(c.replies || []).length ? div({ className: 'replies' }, ...c.replies.map((r) => commentNode(r, state, ui, depth + 1))) : null,
|
|
231
220
|
).key(c.id);
|
|
232
221
|
}
|
|
233
222
|
|
|
234
223
|
function composer(state, ui) {
|
|
235
|
-
const social = ui.app.social;
|
|
236
224
|
const me = state.so.me;
|
|
237
225
|
const replyTo = state.so.replyTo;
|
|
238
226
|
// `me` is null only when /api/me didn't answer — offline, or refused. It is NOT the
|
|
@@ -252,11 +240,11 @@ function composer(state, ui) {
|
|
|
252
240
|
}
|
|
253
241
|
return div({ className: 'composer' },
|
|
254
242
|
replyTo ? div({ className: 'replying' }, `Replying to ${replyTo.author?.name || 'comment'}`,
|
|
255
|
-
button({ className: 'c-link' }, 'cancel').on({ click: () =>
|
|
243
|
+
button({ className: 'c-link' }, 'cancel').on({ click: () => ui.engine.dispatch(new SetReplyToAction(null)) })) : null,
|
|
256
244
|
textarea({ className: 'composer-input', placeholder: 'Write a comment… @mention someone', rows: 2, value: '' }).on({
|
|
257
245
|
keydown: (e) => {
|
|
258
246
|
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
|
259
|
-
|
|
247
|
+
ui.engine.dispatch(new PostCommentAction(e.target.value));
|
|
260
248
|
e.target.value = '';
|
|
261
249
|
}
|
|
262
250
|
},
|
|
@@ -266,7 +254,7 @@ function composer(state, ui) {
|
|
|
266
254
|
button({ className: 'btn primary', disabled: state.so.posting }, 'Comment').on({
|
|
267
255
|
click: (e) => {
|
|
268
256
|
const box = e.target.closest('.composer').querySelector('.composer-input');
|
|
269
|
-
|
|
257
|
+
ui.engine.dispatch(new PostCommentAction(box.value));
|
|
270
258
|
box.value = '';
|
|
271
259
|
},
|
|
272
260
|
}),
|
|
@@ -274,17 +262,9 @@ function composer(state, ui) {
|
|
|
274
262
|
);
|
|
275
263
|
}
|
|
276
264
|
|
|
277
|
-
//
|
|
265
|
+
// WHERE the mentions are is parsed in bl/mentions.js, beside the `#tag` parser it is a
|
|
266
|
+
// sibling of. All that is left here is the decision to draw one as a chip.
|
|
278
267
|
function renderBody(text) {
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
let last = 0;
|
|
282
|
-
let m;
|
|
283
|
-
while ((m = re.exec(text))) {
|
|
284
|
-
if (m.index > last) out.push(text.slice(last, m.index + (m[3] ? (m[0].startsWith(' ') ? 1 : 0) : 0)));
|
|
285
|
-
out.push(span({ className: 'mention' }, '@' + (m[1] || m[3])));
|
|
286
|
-
last = re.lastIndex;
|
|
287
|
-
}
|
|
288
|
-
if (last < text.length) out.push(text.slice(last));
|
|
289
|
-
return out;
|
|
268
|
+
return parseMentions(text).map((part) =>
|
|
269
|
+
(part.type === 'mention' ? span({ className: 'mention' }, `@${part.name}`) : part.value));
|
|
290
270
|
}
|
|
@@ -2,6 +2,7 @@ import { dd } from '../../runtime.js';
|
|
|
2
2
|
import { icon } from '../icon.js';
|
|
3
3
|
import { bytes } from '../format.js';
|
|
4
4
|
import { sanitizedVNodes, htmlToText } from '../sanitize.js';
|
|
5
|
+
import { CancelTransferAction, ExecCommandAction, ShowContextMenuAction, ToggleActivityPanelAction } from '../../bl/actions.js';
|
|
5
6
|
|
|
6
7
|
const { div, button, span } = dd;
|
|
7
8
|
|
|
@@ -12,15 +13,17 @@ const { div, button, span } = dd;
|
|
|
12
13
|
* A slot with nothing in it, or one whose `when` doesn't hold, renders nothing at all
|
|
13
14
|
* rather than an empty chip.
|
|
14
15
|
*/
|
|
16
|
+
// The item arrives already decided — see the statusItems view query. Whether it should
|
|
17
|
+
// show at all (`visible`, its `when` clause, whether the plugin behind it is responding) is
|
|
18
|
+
// resolved on the query side now, which is what let this stop reaching into `platform`
|
|
19
|
+
// mid-render. `html` is still untrusted plugin markup: a query emitting plain data says
|
|
20
|
+
// nothing about that data being safe, so it is still sanitised here.
|
|
15
21
|
function statusSlot(item, ui) {
|
|
16
|
-
if (item.visible === false || !item.html) return null;
|
|
17
|
-
if (item.when && !ui.platform.context.evaluate(item.when)) return null;
|
|
18
|
-
if (!ui.platform.plugins.isAvailable(item)) return null;
|
|
19
22
|
const content = sanitizedVNodes(item.html, dd);
|
|
20
23
|
if (!content.length) return null;
|
|
21
24
|
const title = item.tooltip ? htmlToText(item.tooltip) : '';
|
|
22
25
|
return item.command
|
|
23
|
-
? button({ className: 'seg', title }, ...content).on({ click: () => ui.
|
|
26
|
+
? button({ className: 'seg', title }, ...content).on({ click: () => ui.engine.dispatch(new ExecCommandAction(item.command)) })
|
|
24
27
|
: span({ className: 'seg', title }, ...content);
|
|
25
28
|
}
|
|
26
29
|
|
|
@@ -35,7 +38,7 @@ function statusSlot(item, ui) {
|
|
|
35
38
|
export function activityChips(state, ui) {
|
|
36
39
|
const act = state.act || { tasks: [], issues: [] };
|
|
37
40
|
const running = act.tasks.filter((t) => t.status === 'running');
|
|
38
|
-
const open = () => ui.
|
|
41
|
+
const open = () => ui.engine.dispatch(new ToggleActivityPanelAction(true));
|
|
39
42
|
const chips = [];
|
|
40
43
|
|
|
41
44
|
if (running.length) {
|
|
@@ -71,12 +74,9 @@ export function activityChips(state, ui) {
|
|
|
71
74
|
* meter that means nothing. The bar turns amber under 10% free and red under 5%, which
|
|
72
75
|
* is early enough to act on.
|
|
73
76
|
*/
|
|
74
|
-
export function usageChip(
|
|
75
|
-
const u = ex.usage;
|
|
77
|
+
export function usageChip({ usage: u, usageLevel: level = '' }) {
|
|
76
78
|
if (!u?.total) return null;
|
|
77
|
-
const freeRatio = u.available / u.total;
|
|
78
79
|
const pct = Math.min(100, Math.round((u.used / u.total) * 100));
|
|
79
|
-
const level = freeRatio < 0.05 ? 'critical' : freeRatio < 0.1 ? 'low' : '';
|
|
80
80
|
return span({
|
|
81
81
|
className: `seg sb-usage ${level}`,
|
|
82
82
|
title: `${bytes(u.used)} used of ${bytes(u.total)} — ${bytes(u.available)} free on this volume`,
|
|
@@ -85,63 +85,19 @@ export function usageChip(ex) {
|
|
|
85
85
|
span(`${bytes(u.available)} free`));
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
/**
|
|
89
|
-
* Everything the status bar reports, derived once.
|
|
90
|
-
*
|
|
91
|
-
* Two shells render these: the desktop as a row of segments along the bottom, the phone
|
|
92
|
-
* as rows in a sheet behind a single icon. Deriving them in one place is what stops the
|
|
93
|
-
* two from quietly telling the user different things about the same drive.
|
|
94
|
-
*/
|
|
95
|
-
export function statusFacts(state, ui) {
|
|
96
|
-
const ex = state.ex;
|
|
97
|
-
const items = ex.items || [];
|
|
98
|
-
const act = state.act || { tasks: [], issues: [] };
|
|
99
|
-
return {
|
|
100
|
-
// `null` with nothing open — `collectionLabel` renders that as "no collection". The
|
|
101
|
-
// old fallback made the bar name a collection that may not exist, on a drive where
|
|
102
|
-
// the user had not yet chosen one.
|
|
103
|
-
collectionId: ex.collectionId ?? null,
|
|
104
|
-
// What to CALL it, so the phone shell and the desktop bar cannot end up saying
|
|
105
|
-
// different things — which is the entire reason these facts are derived once. The
|
|
106
|
-
// phone rendered `collectionId` raw, which showed an opaque `col_…` id where the
|
|
107
|
-
// desktop showed the name.
|
|
108
|
-
collectionLabel: collectionLabel(ex),
|
|
109
|
-
// The COLLECTION's totals when the server could give them, not the page's. Summing
|
|
110
|
-
// what happens to be loaded reports a 3,000-file drive as 500 files — a wrong number,
|
|
111
|
-
// not a rounded one. Falls back to the page only when the server didn't say.
|
|
112
|
-
totalItems: ex.stats?.items ?? items.length,
|
|
113
|
-
// Whether that total is the COLLECTION's or just the page we happen to hold. With
|
|
114
|
-
// more pages waiting, the page length is a floor, not a total, and must read as one.
|
|
115
|
-
totalKnown: ex.stats?.items != null,
|
|
116
|
-
totalBytes: ex.stats?.bytes ?? items.reduce((n, i) => n + (i.size || 0), 0),
|
|
117
|
-
shown: items.length,
|
|
118
|
-
partial: !!ex.nextCursor,
|
|
119
|
-
usage: ex.usage,
|
|
120
|
-
uploading: state.tr.items.filter((t) => t.status === 'active'),
|
|
121
|
-
running: act.tasks.filter((t) => t.status === 'running'),
|
|
122
|
-
issues: act.issues,
|
|
123
|
-
off: state.off || { online: true, pins: [], queued: 0, syncing: false },
|
|
124
|
-
caps: ui.platform.capabilities,
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/** What to call the current collection: its name, its id, or an honest nothing. */
|
|
129
|
-
function collectionLabel(ex) {
|
|
130
|
-
if (!ex?.collectionId) return 'no collection';
|
|
131
|
-
const match = (ex.collections || []).find((c) => c.id === ex.collectionId);
|
|
132
|
-
return match?.name || ex.collectionId;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
88
|
export default function statusBar(state, ui) {
|
|
136
89
|
const ex = state.ex;
|
|
137
90
|
const items = ex.items || [];
|
|
138
|
-
|
|
139
|
-
|
|
91
|
+
// Derived by the `statusFacts` query — see bl/status.js. These used to be worked out
|
|
92
|
+
// here and imported INTO the phone chrome from this module, which is a business-layer
|
|
93
|
+
// derivation living in a component and being shared out of it.
|
|
94
|
+
const f = state.facts;
|
|
95
|
+
const { totalItems, totalBytes } = f;
|
|
96
|
+
const caps = state.caps;
|
|
140
97
|
const active = f.uploading;
|
|
141
98
|
|
|
142
|
-
// Plugin-contributed status slots
|
|
143
|
-
const slots =
|
|
144
|
-
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || a.name.localeCompare(b.name));
|
|
99
|
+
// Plugin-contributed status slots. Already filtered and ordered by the query.
|
|
100
|
+
const slots = state.statusItems || [];
|
|
145
101
|
const left = slots.filter((s) => s.slot === 'left').map((s) => statusSlot(s, ui));
|
|
146
102
|
const right = slots.filter((s) => s.slot !== 'left').map((s) => statusSlot(s, ui));
|
|
147
103
|
|
|
@@ -161,34 +117,34 @@ export default function statusBar(state, ui) {
|
|
|
161
117
|
icon('download', { size: 12 }), span(`${off.pins.length} offline`), icon('chevron-down', { size: 11 }))
|
|
162
118
|
.on({ click: (e) => {
|
|
163
119
|
const items = off.pins.slice(0, 12).flatMap((p) => [
|
|
164
|
-
{ label: p.name, icon: 'file-text',
|
|
165
|
-
{ label: `Remove “${p.name}” from offline`, icon: 'close',
|
|
120
|
+
{ label: p.name, icon: 'file-text', actions: [new ExecCommandAction('explorer.open', p)] },
|
|
121
|
+
{ label: `Remove “${p.name}” from offline`, icon: 'close', actions: [new ExecCommandAction('offline.unpin', p)] },
|
|
166
122
|
{ sep: true },
|
|
167
123
|
]);
|
|
168
|
-
if (off.pins.length > 12) items.push({ label: `…and ${off.pins.length - 12} more`,
|
|
124
|
+
if (off.pins.length > 12) items.push({ label: `…and ${off.pins.length - 12} more`, actions: [] });
|
|
169
125
|
const r = e.currentTarget.getBoundingClientRect();
|
|
170
|
-
ui.
|
|
126
|
+
ui.engine.dispatch(new ShowContextMenuAction(r.left, r.top - 8 - items.length * 34, items));
|
|
171
127
|
} })
|
|
172
128
|
: null,
|
|
173
129
|
button({ className: 'seg', title: 'Switch collection' },
|
|
174
130
|
// The NAME, and nothing invented. `|| 'default'` used to sit here, which meant the
|
|
175
131
|
// status bar cheerfully named a collection on a drive that had none.
|
|
176
|
-
icon('files', { size: 13 }), span(collectionLabel
|
|
132
|
+
icon('files', { size: 13 }), span(f.collectionLabel),
|
|
177
133
|
(ex.collections || []).length > 1 || ex.canCreateCollection ? icon('chevron-down', { size: 11 }) : null)
|
|
178
134
|
.on({ click: (e) => {
|
|
179
|
-
const items =
|
|
135
|
+
const items = ex.collectionMenu || [];
|
|
180
136
|
// With nowhere else to go, the segment is a label, not a dead menu.
|
|
181
137
|
if (items.length > 1 || ex.canCreateCollection) {
|
|
182
138
|
const r = e.currentTarget.getBoundingClientRect();
|
|
183
|
-
ui.
|
|
184
|
-
} else ui.
|
|
139
|
+
ui.engine.dispatch(new ShowContextMenuAction(r.left, r.top - 8 - items.length * 34, items));
|
|
140
|
+
} else ui.engine.dispatch(new ExecCommandAction('workbench.view.home'));
|
|
185
141
|
} }),
|
|
186
142
|
active.length
|
|
187
143
|
? button({ className: 'seg', title: 'Active uploads — click to cancel' }, div({ className: 'spinner', $styling: { width: '11px', height: '11px' } }), span(`${active.length} uploading`))
|
|
188
|
-
.on({ click: (e) => ui.
|
|
144
|
+
.on({ click: (e) => ui.engine.dispatch(new ShowContextMenuAction(e.clientX, e.clientY, active.map((t) => ({
|
|
189
145
|
label: `Cancel ${t.name} (${Math.round((t.ratio || 0) * 100)}%)`, icon: 'close', danger: true,
|
|
190
|
-
|
|
191
|
-
}))) })
|
|
146
|
+
actions: [new CancelTransferAction(t.id)],
|
|
147
|
+
})))) })
|
|
192
148
|
: span({ className: 'seg', title: ex.nextCursor ? `Showing ${items.length} of ${f.totalKnown ? totalItems : 'more'}` : '' },
|
|
193
149
|
`${totalItems.toLocaleString()}${f.totalKnown || !f.partial ? '' : '+'} item${totalItems === 1 ? '' : 's'}`),
|
|
194
150
|
...left,
|
|
@@ -204,12 +160,12 @@ export default function statusBar(state, ui) {
|
|
|
204
160
|
? 'Total size of this collection'
|
|
205
161
|
: `Size of the ${f.shown.toLocaleString()} items loaded so far — this server doesn’t report a collection total`,
|
|
206
162
|
}, `${bytes(totalBytes)}${f.totalKnown || !f.partial ? '' : '+'}`),
|
|
207
|
-
usageChip(
|
|
163
|
+
usageChip(f),
|
|
208
164
|
caps ? span({ className: 'seg', title: 'Storage backend capabilities' },
|
|
209
165
|
icon(caps.storage?.presignDownload ? 'download' : 'files', { size: 12 }),
|
|
210
166
|
span(caps.storage?.presignDownload ? 'S3 direct' : 'proxied'),
|
|
211
167
|
) : null,
|
|
212
168
|
caps?.features?.semanticSearch ? button({ className: 'seg', title: 'Semantic search enabled' }, icon('star', { size: 12 }), span('semantic'))
|
|
213
|
-
.on({ click: () => ui.
|
|
169
|
+
.on({ click: () => ui.engine.dispatch(new ExecCommandAction('workbench.view.home')) }) : null,
|
|
214
170
|
);
|
|
215
171
|
}
|
|
@@ -17,6 +17,7 @@ import { dd } from '../../../runtime.js';
|
|
|
17
17
|
import { icon, iconForNode } from '../../icon.js';
|
|
18
18
|
import { groupHeader, menuButton, openRowMenu } from './parts.js';
|
|
19
19
|
import { attachMedia } from '../../media.js';
|
|
20
|
+
import { activate } from '../../activate.js';
|
|
20
21
|
|
|
21
22
|
const { div, span, img } = dd;
|
|
22
23
|
|
|
@@ -24,7 +25,7 @@ export function gridView({ groups, index, handlers, ui }) {
|
|
|
24
25
|
let gi = -1;
|
|
25
26
|
return div({ className: 'launch-view view-grid' },
|
|
26
27
|
...groups.map((group) => div({ className: 'launch-group' },
|
|
27
|
-
groupHeader(group),
|
|
28
|
+
groupHeader(group, ui),
|
|
28
29
|
group.items.length
|
|
29
30
|
? div({ className: 'grid-list' }, ...group.items.map((it) => {
|
|
30
31
|
const at = ++gi;
|
|
@@ -108,7 +109,7 @@ function tile(it, active, { hover, select }, ui) {
|
|
|
108
109
|
),
|
|
109
110
|
div({ className: 'gt-name' }, it.title),
|
|
110
111
|
).on({
|
|
111
|
-
click: it
|
|
112
|
+
click: () => activate(ui, it),
|
|
112
113
|
mouseenter: hover,
|
|
113
114
|
...(it.menu ? { contextmenu: (e) => { e.preventDefault(); openRowMenu(e.currentTarget, it, ui, e, select); } } : {}),
|
|
114
115
|
});
|
|
@@ -20,6 +20,8 @@
|
|
|
20
20
|
|
|
21
21
|
import { dd } from '../../../runtime.js';
|
|
22
22
|
import { icon } from '../../icon.js';
|
|
23
|
+
import { SetSettingAction } from '../../../bl/actions.js';
|
|
24
|
+
import { SETTING } from '../../../bl/views.js';
|
|
23
25
|
import { listView } from './list.js';
|
|
24
26
|
import { gridView, gridMove } from './grid.js';
|
|
25
27
|
|
|
@@ -44,76 +46,19 @@ export function registerBuiltinViews(platform) {
|
|
|
44
46
|
}
|
|
45
47
|
}
|
|
46
48
|
|
|
47
|
-
const SETTING = 'explorer.view';
|
|
48
49
|
|
|
49
|
-
/**
|
|
50
|
-
* Every view that could draw these results, best first.
|
|
51
|
-
*
|
|
52
|
-
* No availability check, unlike openers: a view is host code, so there is no "its
|
|
53
|
-
* provider isn't answering" state to be in. A when-clause is the only gate, and it is
|
|
54
|
-
* the same one every other contribution answers to.
|
|
55
|
-
*/
|
|
56
|
-
export function availableViews(platform) {
|
|
57
|
-
return platform.contributions
|
|
58
|
-
.ofType('view')
|
|
59
|
-
.filter((v) => !v.when || platform.context.evaluate(v.when))
|
|
60
|
-
.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* The view to draw with, in order of how much someone meant it.
|
|
65
|
-
*
|
|
66
|
-
* 1. A saved choice — they pressed a button. Nothing infers its way past that.
|
|
67
|
-
* 2. The search transformer's hint, when there is a search on screen. It read the
|
|
68
|
-
* sentence: "photos from the trip last summer" is a request for a gallery as much as
|
|
69
|
-
* it is a query, and nothing downstream can recover that from a list of content types.
|
|
70
|
-
* 3. A view whose `match` suits what is actually there — a collection of photographs
|
|
71
|
-
* opens as a grid without anyone asking.
|
|
72
|
-
* 4. The highest priority, which is the list.
|
|
73
|
-
*
|
|
74
|
-
* A hint naming a view this build doesn't have is ignored, not an error: the transformer
|
|
75
|
-
* is deployment configuration and may outlive the build it was written against.
|
|
76
|
-
*/
|
|
77
|
-
export function activeView(platform, items = [], hint = null) {
|
|
78
|
-
const views = availableViews(platform);
|
|
79
|
-
if (!views.length) return null;
|
|
80
|
-
const saved = platform.settings.get(SETTING);
|
|
81
|
-
const chosen = saved && views.find((v) => v.id === saved);
|
|
82
|
-
if (chosen) return chosen;
|
|
83
|
-
const suggested = hint && views.find((v) => v.id === hint);
|
|
84
|
-
if (suggested) return suggested;
|
|
85
|
-
const nodes = items.map((i) => i.node).filter(Boolean);
|
|
86
|
-
if (nodes.length >= 3) {
|
|
87
|
-
const suits = (v) => v.match && Object.keys(v.match).length
|
|
88
|
-
&& nodes.filter((n) => matchesView(v, n)).length / nodes.length > 0.6;
|
|
89
|
-
const fitted = views.find(suits);
|
|
90
|
-
if (fitted) return fitted;
|
|
91
|
-
}
|
|
92
|
-
return views[0];
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function matchesView(view, node) {
|
|
96
|
-
const ct = node.contentType || '';
|
|
97
|
-
const name = (node.name || '').toLowerCase();
|
|
98
|
-
const ext = name.includes('.') ? name.slice(name.lastIndexOf('.')) : '';
|
|
99
|
-
if ((view.match.ext || []).includes(ext)) return true;
|
|
100
|
-
return (view.match.mime || []).some((m) => (m.endsWith('/*') ? ct.startsWith(m.slice(0, -1)) : ct === m));
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
export function chooseView(platform, viewId) {
|
|
104
|
-
platform.settings.set(SETTING, viewId || undefined);
|
|
105
|
-
}
|
|
106
50
|
|
|
107
51
|
/** The switcher, shown only when there is more than one way to look at this. */
|
|
108
|
-
export function viewSwitcher(
|
|
109
|
-
const views =
|
|
52
|
+
export function viewSwitcher(slice, current, ui) {
|
|
53
|
+
const views = slice?.views || [];
|
|
110
54
|
if (views.length < 2) return null;
|
|
111
55
|
return div({ className: 'view-switch' }, ...views.map((v) =>
|
|
112
56
|
button({
|
|
113
57
|
className: `vs-btn ${v.id === current?.id ? 'on' : ''}`,
|
|
114
|
-
title: `${v.title
|
|
58
|
+
title: `${v.title} view`,
|
|
115
59
|
'aria-pressed': v.id === current?.id ? 'true' : 'false',
|
|
116
|
-
}, icon(v.icon || 'list', { size: 14 }))
|
|
60
|
+
}, icon(v.icon || 'list', { size: 14 }))
|
|
61
|
+
.on({ click: () => ui.engine.dispatch(new SetSettingAction(SETTING, v.id)) })));
|
|
117
62
|
}
|
|
118
63
|
|
|
119
64
|
/**
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { dd } from '../../../runtime.js';
|
|
9
9
|
import { icon } from '../../icon.js';
|
|
10
10
|
import { groupHeader, menuButton, openRowMenu } from './parts.js';
|
|
11
|
+
import { activate } from '../../activate.js';
|
|
11
12
|
|
|
12
13
|
const { div, span } = dd;
|
|
13
14
|
|
|
@@ -15,7 +16,7 @@ export function listView({ groups, index, handlers, ui }) {
|
|
|
15
16
|
let gi = -1;
|
|
16
17
|
return div({ className: 'launch-view view-list' },
|
|
17
18
|
...groups.map((group) => div({ className: 'launch-group' },
|
|
18
|
-
groupHeader(group),
|
|
19
|
+
groupHeader(group, ui),
|
|
19
20
|
group.items.length
|
|
20
21
|
? div({ className: 'launch-list' }, ...group.items.map((it) => {
|
|
21
22
|
const at = ++gi;
|
|
@@ -43,7 +44,7 @@ function itemRow(it, active, { hover, select }, ui) {
|
|
|
43
44
|
// which is how adding `contextmenu` silently removed `click` and stopped every file
|
|
44
45
|
// in the drive from opening.
|
|
45
46
|
).on({
|
|
46
|
-
click: it
|
|
47
|
+
click: () => activate(ui, it),
|
|
47
48
|
mouseenter: hover,
|
|
48
49
|
...(it.menu ? { contextmenu: (e) => { e.preventDefault(); openRowMenu(e.currentTarget, it, ui, e, select); } } : {}),
|
|
49
50
|
});
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
|
|
9
9
|
import { dd } from '../../../runtime.js';
|
|
10
10
|
import { icon } from '../../icon.js';
|
|
11
|
+
import { ShowContextMenuAction } from '../../../bl/actions.js';
|
|
12
|
+
import { activate } from '../../activate.js';
|
|
11
13
|
|
|
12
14
|
const { div, span, button } = dd;
|
|
13
15
|
|
|
@@ -18,14 +20,23 @@ const { div, span, button } = dd;
|
|
|
18
20
|
* the user typed — it turns their `#draft` into `#DRAFT`, a tag that isn't what they
|
|
19
21
|
* wrote. So a group can carry a verbatim half.
|
|
20
22
|
*/
|
|
21
|
-
export function groupHeader(group) {
|
|
23
|
+
export function groupHeader(group, ui) {
|
|
24
|
+
const controls = group.controls || [];
|
|
22
25
|
return div({ className: 'launch-h' },
|
|
23
26
|
// Label and value together on the left; `.launch-h` is space-between, so an
|
|
24
27
|
// ungrouped value gets flung to the far edge away from the label it belongs to.
|
|
25
28
|
div({ className: 'lh-title' },
|
|
26
29
|
span(group.title),
|
|
27
30
|
group.verbatim ? span({ className: 'lh-verbatim' }, group.verbatim) : null),
|
|
28
|
-
|
|
31
|
+
// Descriptions, not a pre-rendered button. A group used to carry a vnode in `action`,
|
|
32
|
+
// which meant the thing deciding WHAT a header offers had to be able to draw one — the
|
|
33
|
+
// same reason menu items stopped carrying `run` closures. See ui/activate.js.
|
|
34
|
+
controls.length
|
|
35
|
+
? div({ className: 'lh-actions' }, ...controls.map((c) =>
|
|
36
|
+
button({ className: 'launch-up', title: c.title || c.label },
|
|
37
|
+
c.icon ? icon(c.icon, { size: 13 }) : null, c.label)
|
|
38
|
+
.on({ click: () => activate(ui, c) })))
|
|
39
|
+
: null,
|
|
29
40
|
);
|
|
30
41
|
}
|
|
31
42
|
|
|
@@ -52,7 +63,7 @@ export function openRowMenu(anchor, it, ui, event, select) {
|
|
|
52
63
|
const y = event?.clientY ?? (r ? r.bottom : 0);
|
|
53
64
|
// Read the coordinates BEFORE selecting: selecting re-renders, and `anchor` is then
|
|
54
65
|
// a detached node whose rect is all zeroes.
|
|
55
|
-
const items = it.menu(
|
|
66
|
+
const items = it.menu();
|
|
56
67
|
select?.();
|
|
57
|
-
ui.
|
|
68
|
+
ui.engine.dispatch(new ShowContextMenuAction(x, y, items));
|
|
58
69
|
}
|