pi-weave 0.1.7 → 0.1.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/README.md +123 -37
- package/package.json +17 -5
- package/skills/weave-notepad/SKILL.md +13 -4
- package/src/core/cache/workspace.ts +466 -0
- package/src/core/concurrency.ts +36 -0
- package/src/core/frontmatter.ts +270 -23
- package/src/core/git.ts +19 -0
- package/src/core/graph/build.ts +97 -5
- package/src/core/graph/current.ts +41 -28
- package/src/core/graph/mentions.ts +170 -0
- package/src/core/graph/model.ts +39 -0
- package/src/core/graph/wikilinks.ts +5 -1
- package/src/core/index.ts +14 -0
- package/src/core/openInEditor.ts +69 -0
- package/src/core/paths.ts +7 -0
- package/src/core/sessions.ts +929 -0
- package/src/core/summaries.ts +1 -19
- package/src/core/types.ts +40 -0
- package/src/core/vault.ts +739 -57
- package/src/core/view/cluster.ts +262 -0
- package/src/core/view/detail.ts +118 -0
- package/src/core/view/focus.ts +109 -0
- package/src/core/view/health.ts +156 -0
- package/src/core/view/index.ts +15 -0
- package/src/core/view/links.ts +105 -0
- package/src/core/view/time.ts +47 -0
- package/src/core/view/tree.ts +269 -0
- package/src/core/view/types.ts +39 -0
- package/src/core/workspace.ts +3 -3
- package/src/pi/index.ts +248 -48
- package/src/pi/sessionScan.ts +104 -0
- package/src/pi/summarize.ts +24 -4
- package/src/pi/tools/noteTool.ts +13 -4
- package/src/pi/viewer/tui/branding.ts +8 -7
- package/src/pi/viewer/tui/explorer.ts +5 -3
- package/src/pi/viewer/tui/model.ts +46 -667
- package/src/pi/viewer/tui/openNote.ts +7 -56
- package/src/pi/viewer/tui/surface/explore.ts +4 -2
- package/src/pi/viewer/web/run.ts +331 -0
- package/src/web/client/api.dom.ts +40 -0
- package/src/web/client/api.ts +472 -0
- package/src/web/client/bootstrap.ts +58 -0
- package/src/web/client/context/context.model.ts +313 -0
- package/src/web/client/dist/app.js +764 -0
- package/src/web/client/graph/Graph.tsx +209 -0
- package/src/web/client/graph/column.model.ts +372 -0
- package/src/web/client/graph/dynamics.ts +176 -0
- package/src/web/client/graph/graph.model.ts +546 -0
- package/src/web/client/graph/positions.ts +380 -0
- package/src/web/client/graph/project.ts +153 -0
- package/src/web/client/graph/renderer.dom.ts +52 -0
- package/src/web/client/graph/renderer.ts +339 -0
- package/src/web/client/graph/scheme.ts +44 -0
- package/src/web/client/live.model.ts +275 -0
- package/src/web/client/live.ts +151 -0
- package/src/web/client/main.tsx +27 -0
- package/src/web/client/note/Editor.tsx +102 -0
- package/src/web/client/note/Note.tsx +113 -0
- package/src/web/client/note/editor.controller.ts +151 -0
- package/src/web/client/note/editor.model.ts +636 -0
- package/src/web/client/note/note.model.ts +738 -0
- package/src/web/client/search/SearchPalette.tsx +105 -0
- package/src/web/client/search/search.model.ts +588 -0
- package/src/web/client/search/search.ts +107 -0
- package/src/web/client/selection.storage.ts +69 -0
- package/src/web/client/shell/Columns.tsx +161 -0
- package/src/web/client/shell/ContextRail.tsx +87 -0
- package/src/web/client/shell/Divider.tsx +44 -0
- package/src/web/client/shell/FocusTrap.tsx +56 -0
- package/src/web/client/shell/Header.tsx +64 -0
- package/src/web/client/shell/HelpOverlay.tsx +70 -0
- package/src/web/client/shell/Shell.tsx +210 -0
- package/src/web/client/shell/StatusBar.tsx +28 -0
- package/src/web/client/shell/cssvars.ts +70 -0
- package/src/web/client/shell/drag.model.ts +170 -0
- package/src/web/client/shell/focus.model.ts +100 -0
- package/src/web/client/shell/keys.model.ts +453 -0
- package/src/web/client/shell/keys.ts +59 -0
- package/src/web/client/shell/layout.model.ts +526 -0
- package/src/web/client/shell/shell.model.ts +333 -0
- package/src/web/client/shell/theme.ts +490 -0
- package/src/web/client/shell/viewport.ts +29 -0
- package/src/web/client/state.ts +89 -0
- package/src/web/client/tree/Tree.tsx +141 -0
- package/src/web/client/tree/tree.model.ts +674 -0
- package/src/web/client/workspace.ts +278 -0
- package/src/web/server/page.ts +258 -0
- package/src/web/server/routes.ts +987 -0
- package/src/web/server/security.ts +361 -0
- package/src/web/server/server.ts +275 -0
- package/src/web/server/sse.ts +321 -0
- package/src/web/server/watcher.ts +507 -0
- package/src/web/shared/graph.ts +213 -0
- package/src/web/shared/layout.ts +314 -0
- package/src/web/shared/logo.ts +11 -0
- package/src/web/shared/metrics.ts +136 -0
- package/src/web/shared/view.ts +200 -0
- package/src/web/shared/wire.ts +358 -0
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Everything the shell *decides*, as pure functions (weave-workspace §1.2).
|
|
3
|
+
*
|
|
4
|
+
* The header's status summary, the connection indicator's wording, the
|
|
5
|
+
* per-column empty-state copy and the status bar's text all live here rather
|
|
6
|
+
* than inside the components that render them. That is not a stylistic
|
|
7
|
+
* preference: §10 forbids adding a DOM test environment, so a conditional
|
|
8
|
+
* inside a `.tsx` is a conditional that can never be covered, and §14 lists
|
|
9
|
+
* "coverage gate blocks the UI work" as a live risk whose stated mitigation
|
|
10
|
+
* is exactly this split. Each `.tsx` under `shell/` is therefore props-in,
|
|
11
|
+
* JSX-out, with every branch it might have wanted resolved here first.
|
|
12
|
+
*
|
|
13
|
+
* Compiled by the root `tsconfig.json` when a test imports it, so: no DOM
|
|
14
|
+
* types, no `node:*`, no `src/core`.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { GraphPayload, WireNodeKind, WireStalenessState } from "../../shared/wire";
|
|
18
|
+
import type { ConnectionState } from "../state";
|
|
19
|
+
import type { ColumnId, DividerId, ResolvedColumn } from "./layout.model";
|
|
20
|
+
import { DIVIDERS } from "./layout.model";
|
|
21
|
+
|
|
22
|
+
// --- the header summary --------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The `vault:34 · repo:fresh · 127 nodes` readout from the §1.2 sketch.
|
|
26
|
+
*
|
|
27
|
+
* Kept as three fields rather than one pre-joined string so the component can
|
|
28
|
+
* put a separator between them without this module owning a `·`, and so a
|
|
29
|
+
* test asserts the numbers rather than a formatting decision.
|
|
30
|
+
*/
|
|
31
|
+
export interface HeaderSummary {
|
|
32
|
+
/** Note nodes in the vault. */
|
|
33
|
+
readonly notes: number;
|
|
34
|
+
/** Repository index freshness, or `null` when the repo is unindexed. */
|
|
35
|
+
readonly repo: WireStalenessState | null;
|
|
36
|
+
/** Every node, of every kind. */
|
|
37
|
+
readonly nodes: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The summary before the first graph arrives. */
|
|
41
|
+
export const EMPTY_SUMMARY: HeaderSummary = { notes: 0, repo: null, nodes: 0 };
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Which node kinds count as "vault" for the header.
|
|
45
|
+
*
|
|
46
|
+
* Only `note`. The `vault` node itself is the container, and counting it
|
|
47
|
+
* would make an empty vault read `vault:1` — a number that is technically
|
|
48
|
+
* defensible and would still be read as "there is one note in there".
|
|
49
|
+
*/
|
|
50
|
+
const VAULT_KINDS: readonly WireNodeKind[] = ["note"];
|
|
51
|
+
|
|
52
|
+
/** Derive the header counts from a payload. */
|
|
53
|
+
export function summarize(payload: GraphPayload | null): HeaderSummary {
|
|
54
|
+
if (payload === null) return EMPTY_SUMMARY;
|
|
55
|
+
const nodes = payload.model.nodes;
|
|
56
|
+
return {
|
|
57
|
+
notes: nodes.filter((node) => VAULT_KINDS.includes(node.kind)).length,
|
|
58
|
+
repo: payload.model.staleness?.state ?? null,
|
|
59
|
+
nodes: nodes.length,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The repo segment's text.
|
|
65
|
+
*
|
|
66
|
+
* `"unindexed"` rather than `"missing"` for a null or `missing` staleness:
|
|
67
|
+
* "missing" reads as an error, and a repository that has simply never been
|
|
68
|
+
* scanned is the ordinary first-run state, not a fault. The word the user
|
|
69
|
+
* needs is the one that implies an action they can take.
|
|
70
|
+
*/
|
|
71
|
+
export function repoLabel(repo: WireStalenessState | null): string {
|
|
72
|
+
return repo === null || repo === "missing" ? "unindexed" : repo;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** `vault:34 · repo:fresh · 127 nodes`, as the pieces to join. */
|
|
76
|
+
export function summaryParts(summary: HeaderSummary): readonly string[] {
|
|
77
|
+
return [`vault:${summary.notes}`, `repo:${repoLabel(summary.repo)}`, `${summary.nodes} nodes`];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// --- the connection indicator ----------------------------------------------------
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* How a connection state is presented.
|
|
84
|
+
*
|
|
85
|
+
* `tone` is a class suffix rather than a colour, so the palette stays in the
|
|
86
|
+
* stylesheet where §5.2's nonce'd CSS can own it and this module stays free
|
|
87
|
+
* of presentation constants it cannot test the appearance of.
|
|
88
|
+
*/
|
|
89
|
+
export interface ConnectionView {
|
|
90
|
+
readonly label: string;
|
|
91
|
+
readonly tone: "ok" | "warn" | "bad";
|
|
92
|
+
/** The `title=` tooltip. Says what the user should expect to happen next. */
|
|
93
|
+
readonly hint: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const CONNECTION_VIEWS: Readonly<Record<ConnectionState, ConnectionView>> = {
|
|
97
|
+
live: { label: "live", tone: "ok", hint: "watching the vault and the repository for changes" },
|
|
98
|
+
reconnecting: {
|
|
99
|
+
label: "reconnecting",
|
|
100
|
+
tone: "warn",
|
|
101
|
+
// Naming the recovery matters: §6 has the client refetch everything on
|
|
102
|
+
// reopen, so the user's screen will catch up on its own and they should
|
|
103
|
+
// not go looking for a reload button.
|
|
104
|
+
hint: "the event stream dropped — retrying, and everything refetches when it returns",
|
|
105
|
+
},
|
|
106
|
+
offline: { label: "offline", tone: "bad", hint: "the workspace server is gone — use ⟳ to retry" },
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/** Present a connection state. Total over the three states of §1.3. */
|
|
110
|
+
export function connectionView(state: ConnectionState): ConnectionView {
|
|
111
|
+
return CONNECTION_VIEWS[state];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// --- empty states ------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* A column's title, and the placeholder shown while its content is someone
|
|
118
|
+
* else's phase.
|
|
119
|
+
*
|
|
120
|
+
* P1's deliverable for the three columns was an *honest* empty state naming
|
|
121
|
+
* the phase that would fill it — more useful than a spinner implying a load,
|
|
122
|
+
* and far more useful than a blank pane that reads as a bug.
|
|
123
|
+
*
|
|
124
|
+
* **As of P3 no column uses one.** All three are built, so `Columns.tsx`
|
|
125
|
+
* renders their real surfaces and each has its *own* empty states —
|
|
126
|
+
* `treeEmptyMessage`, `noteEmptyMessage` and `graphEmptyMessage`, which
|
|
127
|
+
* distinguish "loading" from "filtered to nothing" from "genuinely empty" in a
|
|
128
|
+
* way one static sentence cannot.
|
|
129
|
+
*
|
|
130
|
+
* The table stays because {@link EmptyStateCopy.title} is still every column's
|
|
131
|
+
* heading and its `aria-label`. {@link EmptyStateCopy.body} and `phase` are
|
|
132
|
+
* now dead for all three, and are kept accurate rather than deleted for one
|
|
133
|
+
* reason: `tests/web/client-shell.test.ts` asserts the `phase` values against
|
|
134
|
+
* §11, so a column whose phase silently disagreed with the doc would be the
|
|
135
|
+
* first sign that this table had stopped tracking reality. `EmptyState.tsx`
|
|
136
|
+
* itself now has no caller, and is deleted rather than kept warm — an unused
|
|
137
|
+
* component is a thing the next reader has to work out is unused.
|
|
138
|
+
*/
|
|
139
|
+
export interface EmptyStateCopy {
|
|
140
|
+
readonly title: string;
|
|
141
|
+
readonly body: string;
|
|
142
|
+
/** The phase that fills this column in, e.g. `"P2"`. */
|
|
143
|
+
readonly phase: string;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const EMPTY_STATES: Readonly<Record<ColumnId, EmptyStateCopy>> = {
|
|
147
|
+
tree: {
|
|
148
|
+
title: "Tree",
|
|
149
|
+
// Built in P2; body and phase are no longer rendered. See the header.
|
|
150
|
+
body: "The vault and repository outline, expandable, filterable, with provenance markers.",
|
|
151
|
+
phase: "P2",
|
|
152
|
+
},
|
|
153
|
+
note: {
|
|
154
|
+
title: "Note",
|
|
155
|
+
body: "Select anything to read it here — rendered Markdown, front matter, tags and wikilinks.",
|
|
156
|
+
phase: "P2",
|
|
157
|
+
},
|
|
158
|
+
graph: {
|
|
159
|
+
title: "Graph",
|
|
160
|
+
// Built in P3; body and phase are no longer rendered. See the header.
|
|
161
|
+
body: "The knowledge graph, laid out and navigable, with the selection's neighbourhood highlighted.",
|
|
162
|
+
phase: "P3",
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
/** The empty-state copy for a column. */
|
|
167
|
+
export function emptyStateFor(column: ColumnId): EmptyStateCopy {
|
|
168
|
+
return EMPTY_STATES[column];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The context rail's title.
|
|
173
|
+
*
|
|
174
|
+
* Built in P2.5, so `body`/`phase` are no longer rendered either — the rail's
|
|
175
|
+
* real empty states are `RAIL_EMPTY` in `context/context.model.ts`, which
|
|
176
|
+
* separates "loading" from "nothing selected" from "this node is isolated".
|
|
177
|
+
* `title` is still the rail's heading and `aria-label`.
|
|
178
|
+
*/
|
|
179
|
+
export const CONTEXT_EMPTY: EmptyStateCopy = {
|
|
180
|
+
title: "Context",
|
|
181
|
+
body: "Links, backlinks and mentions for whatever is selected.",
|
|
182
|
+
phase: "P2",
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
// --- the status bar -------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The status bar's segments, left to right.
|
|
189
|
+
*
|
|
190
|
+
* The selection is included because it is the one piece of state that is
|
|
191
|
+
* otherwise invisible at P1: with all three columns showing empty states,
|
|
192
|
+
* clicking something would have no observable effect at all, and a shell
|
|
193
|
+
* whose context bus cannot be seen working is a shell nobody can tell is
|
|
194
|
+
* wired up.
|
|
195
|
+
*/
|
|
196
|
+
export interface StatusBarModel {
|
|
197
|
+
readonly cwd: string;
|
|
198
|
+
readonly selection: string;
|
|
199
|
+
readonly connection: ConnectionView;
|
|
200
|
+
/** `null` before the first successful graph fetch. */
|
|
201
|
+
readonly stamp: string | null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** The `—` shown where a value is genuinely absent, not zero. */
|
|
205
|
+
export const NO_VALUE = "—";
|
|
206
|
+
|
|
207
|
+
/** Build the status bar's model. */
|
|
208
|
+
export function statusBarModel(
|
|
209
|
+
cwd: string,
|
|
210
|
+
selectedId: string | null,
|
|
211
|
+
connection: ConnectionState,
|
|
212
|
+
stamp: string | null,
|
|
213
|
+
): StatusBarModel {
|
|
214
|
+
return {
|
|
215
|
+
cwd: cwd === "" ? NO_VALUE : cwd,
|
|
216
|
+
selection: selectedId ?? "nothing selected",
|
|
217
|
+
connection: connectionView(connection),
|
|
218
|
+
stamp,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* A `generatedAt` stamp, shortened for the status bar.
|
|
224
|
+
*
|
|
225
|
+
* Just the `HH:MM:SS`, because the date is almost always today and the whole
|
|
226
|
+
* ISO string is 24 characters of mostly-constant noise in a bar that has
|
|
227
|
+
* three other things to say. A stamp that does not look like an ISO timestamp
|
|
228
|
+
* is passed through untouched rather than sliced blindly — the server derives
|
|
229
|
+
* it from input timestamps and is free to change that derivation, and a
|
|
230
|
+
* mangled substring would be a worse lie than the full value.
|
|
231
|
+
*/
|
|
232
|
+
export function shortStamp(stamp: string | null): string {
|
|
233
|
+
if (stamp === null) return NO_VALUE;
|
|
234
|
+
const match = /T(\d{2}:\d{2}:\d{2})/.exec(stamp);
|
|
235
|
+
return match?.[1] ?? stamp;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// --- pairing columns with dividers ---------------------------------------------------
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* One rendered column, plus the divider that follows it (if any).
|
|
242
|
+
*
|
|
243
|
+
* `divider` is `null` for the last column: there are three columns and two
|
|
244
|
+
* dividers, and the trailing edge of the grid is the window, not a handle.
|
|
245
|
+
*/
|
|
246
|
+
export interface ColumnSlot {
|
|
247
|
+
readonly column: ResolvedColumn;
|
|
248
|
+
readonly divider: DividerId | null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Interleave resolved columns with the dividers between them.
|
|
253
|
+
*
|
|
254
|
+
* This exists as a model function rather than an `index + 1 < length` check
|
|
255
|
+
* inside the JSX for two reasons. The first is coverage: that check is a
|
|
256
|
+
* branch, and a branch in a `.tsx` is a branch no test can reach (§10). The
|
|
257
|
+
* second is that the naive version is *wrong at a breakpoint* — at `"medium"`
|
|
258
|
+
* only tree and note render, so the divider after `note` must not appear even
|
|
259
|
+
* though `DIVIDERS` contains one. Deriving the pairing from the columns
|
|
260
|
+
* actually being rendered, rather than from the static divider list, makes
|
|
261
|
+
* that impossible to get wrong.
|
|
262
|
+
*/
|
|
263
|
+
export function columnSlots(resolved: readonly ResolvedColumn[]): readonly ColumnSlot[] {
|
|
264
|
+
return resolved.map((column, index) => ({
|
|
265
|
+
column,
|
|
266
|
+
// A divider is *named by the column to its left* (`layout.model.ts`'s
|
|
267
|
+
// `dividerPair`), so the id is the column's own — no index arithmetic into
|
|
268
|
+
// `DIVIDERS`, which would need an unreachable `?? null` to satisfy
|
|
269
|
+
// `noUncheckedIndexedAccess` and would leave a branch no test can cover.
|
|
270
|
+
// It exists only where a next column follows to resize against.
|
|
271
|
+
divider: index < resolved.length - 1 && isDivider(column.id) ? column.id : null,
|
|
272
|
+
}));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Whether a column has a divider named after it — i.e. is not the last one. */
|
|
276
|
+
function isDivider(column: ColumnId): column is DividerId {
|
|
277
|
+
return (DIVIDERS as readonly ColumnId[]).includes(column);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// --- the search affordance ---------------------------------------------------------
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* The `⌘K` control.
|
|
284
|
+
*
|
|
285
|
+
* Rendered **disabled** through P1–P3, with a title saying search arrived in
|
|
286
|
+
* P4 — a deliberate choice over omitting it: the keyboard hint taught the
|
|
287
|
+
* shortcut that would exist, and a `disabled` control explaining itself is
|
|
288
|
+
* honest in a way that a working-looking box that does nothing is not.
|
|
289
|
+
*
|
|
290
|
+
* P4 made it live, and it is now a *button* rather than an `<input>`. The
|
|
291
|
+
* palette owns the only text field, so a second one in the header would be
|
|
292
|
+
* two places to type a query into and one of them would be a lie — clicking
|
|
293
|
+
* it opens the overlay and whatever was typed into the header box would be
|
|
294
|
+
* discarded. A button that looks like a search field and opens the real one
|
|
295
|
+
* is the affordance the §1.2 sketch actually describes.
|
|
296
|
+
*/
|
|
297
|
+
export const SEARCH_PLACEHOLDER = "Search…";
|
|
298
|
+
|
|
299
|
+
/** The shortcut hint. `⌘K` on Apple platforms, `Ctrl K` elsewhere. */
|
|
300
|
+
export function searchShortcut(isApple: boolean): string {
|
|
301
|
+
return isApple ? "⌘K" : "Ctrl K";
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** The `title=` on the live search button. Names the key that also opens it. */
|
|
305
|
+
export function searchHint(shortcut: string): string {
|
|
306
|
+
return `Search notes and repository (${shortcut})`;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// --- overlays ------------------------------------------------------------------
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Which modal surface is open, if any.
|
|
313
|
+
*
|
|
314
|
+
* A single nullable id rather than one boolean per overlay, because the two
|
|
315
|
+
* are **mutually exclusive** and two booleans can represent a state that is
|
|
316
|
+
* not — help and search both open, stacked, each trapping focus against the
|
|
317
|
+
* other. Making that unrepresentable costs nothing here and removes a whole
|
|
318
|
+
* class of bug from the keyboard layer, which is the thing that opens them.
|
|
319
|
+
*/
|
|
320
|
+
export type OverlayId = "search" | "help" | null;
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Whether a platform string looks like an Apple one.
|
|
324
|
+
*
|
|
325
|
+
* Takes the string rather than reading `navigator`, so it is testable and so
|
|
326
|
+
* the single DOM read happens at the one call site in the shell. Substring
|
|
327
|
+
* matching on `Mac`, `iPhone` and `iPad` covers what `navigator.platform` and
|
|
328
|
+
* `userAgent` produce; anything unrecognised gets the `Ctrl` spelling, which
|
|
329
|
+
* is the right default because non-Apple is the larger population.
|
|
330
|
+
*/
|
|
331
|
+
export function looksApple(platform: string): boolean {
|
|
332
|
+
return /mac|iphone|ipad/i.test(platform);
|
|
333
|
+
}
|