@k2b/cloud 0.44.0 → 0.45.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -2
- package/src/access/GroupCoverage.tsx +20 -5
- package/src/access/PermissionEditor.tsx +11 -2
- package/src/access/PrincipalAvatar.tsx +30 -21
- package/src/access/messages.ts +4 -4
- package/src/account/EntitySearch.tsx +12 -0
- package/src/ai/dictation-runtime.ts +6 -2
- package/src/ai/executor.ts +9 -3
- package/src/ai/index.ts +1 -0
- package/src/ai/project-tool.ts +4 -4
- package/src/ai/projects-authorization.ts +53 -0
- package/src/ai/projects-routes.ts +56 -46
- package/src/ai/projects.ts +100 -79
- package/src/ai/recent-resources.ts +3 -3
- package/src/ai/routes.ts +30 -30
- package/src/ai/skill-tool.ts +4 -4
- package/src/ai/skills-routes.ts +27 -26
- package/src/ai/skills.ts +120 -81
- package/src/api/accounts-entities.ts +6 -3
- package/src/api/admin-ai-skills.ts +4 -1
- package/src/api/auth.ts +9 -2
- package/src/browser/session.ts +39 -0
- package/src/browser/shared-text-history-validation.ts +151 -0
- package/src/browser/shared-text-history.ts +464 -0
- package/src/browser/shared-text-offline-authorized.ts +29 -0
- package/src/browser/shared-text-offline.ts +351 -0
- package/src/browser/shared-text-provider.ts +714 -0
- package/src/browser/shared-text-table-contract.ts +150 -0
- package/src/browser/shared-text.ts +19 -0
- package/src/contracts/shared.ts +2 -1
- package/src/server/api/respond.ts +10 -2
- package/src/server/index.ts +1 -0
- package/src/server/services/access.ts +23 -10
- package/src/server/services/index.ts +1 -0
- package/src/services/accounts/app.ts +30 -13
- package/src/services/accounts/entities.ts +29 -16
- package/src/services/accounts/group-sql.ts +23 -0
- package/src/services/accounts/groups.ts +16 -13
- package/src/services/accounts/identities.ts +8 -1
- package/src/services/accounts/identity-reconciliation.ts +3 -1
- package/src/services/accounts/local-groups.ts +69 -4
- package/src/services/accounts/users.ts +38 -8
- package/src/services/mandates/index.ts +2 -2
- package/src/services/mandates/policy.ts +1 -0
- package/src/services/session/index.ts +12 -3
- package/src/shared/email-html.ts +1 -0
- package/src/shared/markdown/extensions/katex.ts +8 -1
- package/src/shared/markdown/extensions/tables.ts +14 -1
- package/src/shared/markdown/index.ts +22 -11
- package/src/shared-text/anchors.ts +29 -0
- package/src/shared-text/awareness.ts +87 -0
- package/src/shared-text/changes.ts +27 -0
- package/src/shared-text/documents.ts +428 -77
- package/src/shared-text/events.ts +12 -2
- package/src/shared-text/index.ts +16 -1
- package/src/shared-text/log.ts +24 -5
- package/src/shared-text/snapshots.ts +69 -23
- package/src/shared-text/structural-history.ts +377 -0
- package/src/shared-text/table-actions.ts +432 -0
- package/src/shared-text/types.ts +24 -5
- package/src/ssr/AppLaunchpadPanel.tsx +1 -1
- package/src/ssr/CloudShell.island.tsx +19 -3
- package/src/ssr/Layout.tsx +1 -1
- package/src/ssr/MobileProfileActions.tsx +2 -2
- package/src/ssr/ProfilePreferences.tsx +2 -1
- package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +3 -3
- package/src/ssr/admin-navigation.ts +1 -11
- package/src/ssr/app-launchpad.tsx +6 -1
- package/src/ssr/platform-messages.ts +2 -0
- package/src/ssr/profile-actions.ts +3 -6
- package/src/ssr/shell-data.tsx +1 -1
- package/src/workflows/language/expressions.ts +3 -2
- package/src/workflows/runtime/executor.ts +2 -1
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
/** Fixed-character table intents shared by browser capture and server planning. */
|
|
2
|
+
import type { MenuAction, TableTarget } from "@k2b/ui/text-editor";
|
|
3
|
+
import * as T from "@k2b/ui/text-editor/model";
|
|
4
|
+
import type * as Y from "yjs";
|
|
5
|
+
import { captureTextAnchor, resolveTextAnchor } from "./anchors";
|
|
6
|
+
import type { SharedTextChange } from "./types";
|
|
7
|
+
|
|
8
|
+
type RowAction = Extract<MenuAction, "rowAbove" | "rowBelow" | "deleteRow" | "moveRowUp" | "moveRowDown">;
|
|
9
|
+
type RowMove = Extract<RowAction, "moveRowUp" | "moveRowDown">;
|
|
10
|
+
type ColumnMove = Extract<MenuAction, "moveColLeft" | "moveColRight">;
|
|
11
|
+
type TableAction = Extract<MenuAction, "completeColumns" | "toggleHeader" | "deleteTable">;
|
|
12
|
+
type ColumnAction = Exclude<MenuAction, RowAction | TableAction>;
|
|
13
|
+
type TableWitness = { headerBreak: Uint8Array; separatorHyphen: Uint8Array };
|
|
14
|
+
type RowWitness = { kind: "header" } | { kind: "body"; beforeBreak: Uint8Array; inside: Uint8Array; insideKind: "pipe" | "break" };
|
|
15
|
+
type ColumnWitness = { hyphen: Uint8Array; left: Uint8Array; right: Uint8Array };
|
|
16
|
+
|
|
17
|
+
/** Transport encodes the byte arrays; no old numeric row/column survives capture. */
|
|
18
|
+
export type TableActionIntent =
|
|
19
|
+
| { action: TableAction; table: TableWitness }
|
|
20
|
+
| { action: "replaceSource"; table: TableWitness; expected: string; source: string }
|
|
21
|
+
| { action: Exclude<RowAction, RowMove>; table: TableWitness; row: RowWitness }
|
|
22
|
+
| { action: RowMove; table: TableWitness; row: RowWitness; neighbor: RowWitness }
|
|
23
|
+
| { action: Exclude<ColumnAction, ColumnMove>; table: TableWitness; column: ColumnWitness }
|
|
24
|
+
| { action: ColumnMove; table: TableWitness; column: ColumnWitness; neighbor: ColumnWitness };
|
|
25
|
+
|
|
26
|
+
/** Stable conflict for malformed, unsupported, deleted, replaced, or moved targets. */
|
|
27
|
+
export class TableActionRejectedError extends Error {
|
|
28
|
+
readonly code = "table_target_rejected";
|
|
29
|
+
|
|
30
|
+
constructor() {
|
|
31
|
+
super("The table target is unavailable. Inspect the table and retry.");
|
|
32
|
+
this.name = "TableActionRejectedError";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class TableActionLimitError extends RangeError {
|
|
37
|
+
readonly code = "table_action_too_large";
|
|
38
|
+
constructor() {
|
|
39
|
+
super("The table action exceeds the document text limit");
|
|
40
|
+
this.name = "TableActionLimitError";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const reject = (): never => {
|
|
45
|
+
throw new TableActionRejectedError();
|
|
46
|
+
};
|
|
47
|
+
const isIndex = (value: number) => Number.isSafeInteger(value) && value >= 0;
|
|
48
|
+
const isBreak = (source: string, at: number) => source[at] === "\n" || source[at] === "\r";
|
|
49
|
+
const hyphenAt = (cell: T.Cell) => cell.from + cell.text.indexOf("-");
|
|
50
|
+
|
|
51
|
+
// Both edges must be actual characters. Optional outer pipes use the physical
|
|
52
|
+
// newline instead; an omitted right pipe at EOF has no safe witness.
|
|
53
|
+
const columnEdges = (table: T.Table, column: number): [number, number] => {
|
|
54
|
+
const separator = table.lines[1]!;
|
|
55
|
+
return [
|
|
56
|
+
separator.leadingPipe ? separator.pipes[column]! : column === 0 ? table.lines[0]!.to : separator.pipes[column - 1]!,
|
|
57
|
+
separator.leadingPipe ? (separator.pipes[column + 1] ?? separator.to) : (separator.pipes[column] ?? separator.to),
|
|
58
|
+
];
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Call synchronously in structure.perform, before awaiting a flush or request.
|
|
63
|
+
* The host must ensure its edits reached the server. Capture grants no access;
|
|
64
|
+
* applications authorize the document and fence restore revisions externally.
|
|
65
|
+
*/
|
|
66
|
+
export const captureTableAction = (text: Y.Text, action: MenuAction, target: TableTarget): TableActionIntent => {
|
|
67
|
+
if (!text.doc) return reject();
|
|
68
|
+
const source = text.toString();
|
|
69
|
+
const table = T.findTables(source).find((candidate) => candidate.from === target.tableFrom);
|
|
70
|
+
const row = table && T.gridLine(table, target.row);
|
|
71
|
+
// UI columnFrom clamps extra body cells to the last header cell. Validate that
|
|
72
|
+
// offset, but never mistake it for an extra column's identity.
|
|
73
|
+
const headerCell = table?.lines[0]?.cells[Math.min(target.column, table.columns - 1)];
|
|
74
|
+
const width = table?.lines.reduce((max, line) => Math.max(max, line.cells.length), 0) ?? 0;
|
|
75
|
+
if (
|
|
76
|
+
!table ||
|
|
77
|
+
!row ||
|
|
78
|
+
!headerCell ||
|
|
79
|
+
!isIndex(target.row) ||
|
|
80
|
+
!isIndex(target.column) ||
|
|
81
|
+
target.column >= width ||
|
|
82
|
+
row.from !== target.rowFrom ||
|
|
83
|
+
headerCell.from !== target.columnFrom
|
|
84
|
+
)
|
|
85
|
+
return reject();
|
|
86
|
+
const anchor = (at: number) => captureTextAnchor(text, at);
|
|
87
|
+
const witness: TableWitness = {
|
|
88
|
+
headerBreak: anchor(table.lines[0]!.to),
|
|
89
|
+
separatorHyphen: anchor(hyphenAt(table.lines[1]!.cells[0]!)),
|
|
90
|
+
};
|
|
91
|
+
const rowWitness = (index: number): RowWitness => {
|
|
92
|
+
if (index === 0) return { kind: "header" };
|
|
93
|
+
const line = T.gridLine(table, index);
|
|
94
|
+
if (!line || index < 1) return reject();
|
|
95
|
+
const inside = line.pipes[0] ?? line.to;
|
|
96
|
+
if (!line.pipes.length && !isBreak(source, inside)) return reject();
|
|
97
|
+
return {
|
|
98
|
+
kind: "body",
|
|
99
|
+
beforeBreak: anchor(table.lines[index]!.to),
|
|
100
|
+
inside: anchor(inside),
|
|
101
|
+
insideKind: line.pipes.length ? "pipe" : "break",
|
|
102
|
+
};
|
|
103
|
+
};
|
|
104
|
+
const columnWitness = (index: number): ColumnWitness => {
|
|
105
|
+
const cell = table.lines[1]!.cells[index];
|
|
106
|
+
if (!cell) return reject();
|
|
107
|
+
const [left, right] = columnEdges(table, index);
|
|
108
|
+
if (right === table.lines[1]!.to && !isBreak(source, right)) return reject();
|
|
109
|
+
return { hyphen: anchor(hyphenAt(cell)), left: anchor(left), right: anchor(right) };
|
|
110
|
+
};
|
|
111
|
+
switch (action) {
|
|
112
|
+
case "completeColumns":
|
|
113
|
+
case "toggleHeader":
|
|
114
|
+
case "deleteTable":
|
|
115
|
+
return { action, table: witness };
|
|
116
|
+
case "rowAbove":
|
|
117
|
+
case "rowBelow":
|
|
118
|
+
case "deleteRow":
|
|
119
|
+
case "moveRowUp":
|
|
120
|
+
case "moveRowDown": {
|
|
121
|
+
const row = rowWitness(target.row);
|
|
122
|
+
if (action === "moveRowUp" || action === "moveRowDown") {
|
|
123
|
+
const to = target.row + (action === "moveRowUp" ? -1 : 1);
|
|
124
|
+
if (target.row === 0 || to < 1) return reject();
|
|
125
|
+
return { action, table: witness, row, neighbor: rowWitness(to) };
|
|
126
|
+
}
|
|
127
|
+
return { action, table: witness, row };
|
|
128
|
+
}
|
|
129
|
+
case "moveColLeft":
|
|
130
|
+
case "moveColRight":
|
|
131
|
+
case "colLeft":
|
|
132
|
+
case "colRight":
|
|
133
|
+
case "deleteCol":
|
|
134
|
+
case "alignLeft":
|
|
135
|
+
case "alignCenter":
|
|
136
|
+
case "alignRight": {
|
|
137
|
+
const column = columnWitness(target.column);
|
|
138
|
+
if (action === "moveColLeft" || action === "moveColRight")
|
|
139
|
+
return { action, table: witness, column, neighbor: columnWitness(target.column + (action === "moveColLeft" ? -1 : 1)) };
|
|
140
|
+
return { action, table: witness, column };
|
|
141
|
+
}
|
|
142
|
+
default:
|
|
143
|
+
return reject();
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/** An explicit source draft uses the same fixed target and exact source CAS. */
|
|
148
|
+
export const captureTableSource = (text: Y.Text, target: TableTarget, expected: string, source: string): TableActionIntent => {
|
|
149
|
+
const captured = captureTableAction(text, "completeColumns", target);
|
|
150
|
+
return { action: "replaceSource", table: captured.table, expected, source };
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/** A move changes column identity. Reissue both raw cell spans, including their
|
|
154
|
+
* padding/separator identities, so stale fixed witnesses cannot target the
|
|
155
|
+
* neighbor whose contents the pure text model moved into that physical slot. */
|
|
156
|
+
const moveColumns = (source: string, table: T.Table, column: number, to: number): SharedTextChange[] => {
|
|
157
|
+
const changes = T.moveColumn(table, column, to);
|
|
158
|
+
if (!changes.length) return [];
|
|
159
|
+
const first = Math.min(column, to),
|
|
160
|
+
last = Math.max(column, to);
|
|
161
|
+
return table.lines.map((line) => {
|
|
162
|
+
const from = line.leadingPipe ? line.pipes[first]! + 1 : first === 0 ? line.from : line.pipes[first - 1]! + 1;
|
|
163
|
+
const end = (line.leadingPipe ? line.pipes[last + 1] : line.pipes[last]) ?? line.to;
|
|
164
|
+
const edits = changes
|
|
165
|
+
.filter((change) => change.from >= from && change.to <= end)
|
|
166
|
+
.map((change) => ({ ...change, from: change.from - from, to: change.to - from }));
|
|
167
|
+
return { from, to: end, insert: T.applyChanges(source.slice(from, end), edits), recreate: true as const };
|
|
168
|
+
});
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Reparse a reconstructed Y.Text and plan disjoint model edits without mutation.
|
|
173
|
+
* Apply in descending source order in one Yjs transaction, under the writer's
|
|
174
|
+
* current-head CAS. Only an explicit source draft replaces a whole table.
|
|
175
|
+
*/
|
|
176
|
+
export const planTableAction = (text: Y.Text, intent: TableActionIntent, maxLength: number): SharedTextChange[] => {
|
|
177
|
+
try {
|
|
178
|
+
const source = text.toString();
|
|
179
|
+
if (!Number.isSafeInteger(maxLength) || maxLength < 0) return reject();
|
|
180
|
+
const resolve = (bytes: Uint8Array): number => {
|
|
181
|
+
if (!(bytes instanceof Uint8Array)) return reject();
|
|
182
|
+
return resolveTextAnchor(text, bytes) ?? reject();
|
|
183
|
+
};
|
|
184
|
+
const headerBreak = resolve(intent.table.headerBreak);
|
|
185
|
+
const separatorHyphen = resolve(intent.table.separatorHyphen);
|
|
186
|
+
const table = T.findTables(source).find(
|
|
187
|
+
(candidate) =>
|
|
188
|
+
candidate.lines[0]!.to === headerBreak &&
|
|
189
|
+
candidate.lines[1]!.cells.some((cell) => separatorHyphen >= cell.from && separatorHyphen < cell.to),
|
|
190
|
+
);
|
|
191
|
+
if (!table || !isBreak(source, headerBreak) || source[separatorHyphen] !== "-") return reject();
|
|
192
|
+
const resolveRow = (witness: RowWitness): number => {
|
|
193
|
+
if (witness.kind === "header") return 0;
|
|
194
|
+
if (witness.kind !== "body") return reject();
|
|
195
|
+
const before = resolve(witness.beforeBreak),
|
|
196
|
+
inside = resolve(witness.inside);
|
|
197
|
+
if (!isBreak(source, before)) return reject();
|
|
198
|
+
// The preceding newline is deleted by deleteRow. A row's own ending
|
|
199
|
+
// newline alone is unsafe: deletion can leave it attached to another row.
|
|
200
|
+
for (let row = 1; row < T.rowCount(table); row++) {
|
|
201
|
+
const line = T.gridLine(table, row)!;
|
|
202
|
+
if (table.lines[row]!.to !== before) continue;
|
|
203
|
+
if (witness.insideKind === "pipe" && line.pipes.includes(inside)) return row;
|
|
204
|
+
if (witness.insideKind === "break" && line.to === inside && isBreak(source, inside)) return row;
|
|
205
|
+
}
|
|
206
|
+
return reject();
|
|
207
|
+
};
|
|
208
|
+
const resolveColumn = (witness: ColumnWitness): number => {
|
|
209
|
+
const hyphen = resolve(witness.hyphen),
|
|
210
|
+
left = resolve(witness.left),
|
|
211
|
+
right = resolve(witness.right);
|
|
212
|
+
if (source[hyphen] !== "-") return reject();
|
|
213
|
+
const column = table.lines[1]!.cells.findIndex((cell) => hyphen >= cell.from && hyphen < cell.to);
|
|
214
|
+
if (column < 0) return reject();
|
|
215
|
+
const [currentLeft, currentRight] = columnEdges(table, column);
|
|
216
|
+
if (left !== currentLeft || right !== currentRight) return reject();
|
|
217
|
+
if (right === table.lines[1]!.to && !isBreak(source, right)) return reject();
|
|
218
|
+
return column;
|
|
219
|
+
};
|
|
220
|
+
const checkExpansion = (length: number) => {
|
|
221
|
+
if (source.length + length > maxLength) throw new TableActionLimitError();
|
|
222
|
+
};
|
|
223
|
+
const columnInsertion = (column: number) => {
|
|
224
|
+
let length = 0;
|
|
225
|
+
for (const [row, line] of table.lines.entries()) {
|
|
226
|
+
length +=
|
|
227
|
+
column >= line.cells.length
|
|
228
|
+
? (line.trailingPipe ? 0 : 2) + (column - line.cells.length + 1) * (row === 1 ? 6 : 4)
|
|
229
|
+
: (column === 0 && !line.leadingPipe ? 1 : 0) + (row === 1 ? 6 : 4);
|
|
230
|
+
checkExpansion(length);
|
|
231
|
+
}
|
|
232
|
+
return T.insertColumn(table, column);
|
|
233
|
+
};
|
|
234
|
+
const rowInsertion = (after: number) => {
|
|
235
|
+
checkExpansion((table.lines[0]?.lineBreak || "\n").length + 1 + 4 * table.columns);
|
|
236
|
+
return T.insertRow(table, after);
|
|
237
|
+
};
|
|
238
|
+
switch (intent.action) {
|
|
239
|
+
case "completeColumns": {
|
|
240
|
+
const columns = table.lines.reduce((width, line) => Math.max(width, line.cells.length), table.columns);
|
|
241
|
+
let length = 0;
|
|
242
|
+
for (const [row, line] of table.lines.entries()) {
|
|
243
|
+
const count = columns - line.cells.length;
|
|
244
|
+
if (count) length += (line.trailingPipe ? 0 : 2) + count * (row === 1 ? 6 : 4);
|
|
245
|
+
checkExpansion(length);
|
|
246
|
+
}
|
|
247
|
+
return T.completeColumns(table);
|
|
248
|
+
}
|
|
249
|
+
case "rowAbove":
|
|
250
|
+
return rowInsertion(Math.max(0, resolveRow(intent.row) - 1));
|
|
251
|
+
case "rowBelow":
|
|
252
|
+
return rowInsertion(resolveRow(intent.row));
|
|
253
|
+
case "deleteRow":
|
|
254
|
+
return T.deleteRow(table, resolveRow(intent.row));
|
|
255
|
+
case "moveRowUp": {
|
|
256
|
+
const row = resolveRow(intent.row);
|
|
257
|
+
const to = resolveRow(intent.neighbor);
|
|
258
|
+
if (to !== row - 1) return reject();
|
|
259
|
+
return T.moveRow(source, table, row, to);
|
|
260
|
+
}
|
|
261
|
+
case "moveRowDown": {
|
|
262
|
+
const row = resolveRow(intent.row);
|
|
263
|
+
const to = resolveRow(intent.neighbor);
|
|
264
|
+
if (to !== row + 1) return reject();
|
|
265
|
+
return T.moveRow(source, table, row, to);
|
|
266
|
+
}
|
|
267
|
+
case "moveColLeft": {
|
|
268
|
+
const column = resolveColumn(intent.column);
|
|
269
|
+
const to = resolveColumn(intent.neighbor);
|
|
270
|
+
if (to !== column - 1) return reject();
|
|
271
|
+
return moveColumns(source, table, column, to);
|
|
272
|
+
}
|
|
273
|
+
case "moveColRight": {
|
|
274
|
+
const column = resolveColumn(intent.column);
|
|
275
|
+
const to = resolveColumn(intent.neighbor);
|
|
276
|
+
if (to !== column + 1) return reject();
|
|
277
|
+
return moveColumns(source, table, column, to);
|
|
278
|
+
}
|
|
279
|
+
case "toggleHeader":
|
|
280
|
+
return T.toggleHeader(source, table);
|
|
281
|
+
case "deleteTable":
|
|
282
|
+
return T.deleteTable(table);
|
|
283
|
+
case "replaceSource": {
|
|
284
|
+
if (source.slice(table.from, table.to) !== intent.expected) return reject();
|
|
285
|
+
const replacement = T.findTables(intent.source);
|
|
286
|
+
if (
|
|
287
|
+
replacement.length !== 1 ||
|
|
288
|
+
replacement[0]!.from !== 0 ||
|
|
289
|
+
intent.source.slice(replacement[0]!.to).trim() !== "" ||
|
|
290
|
+
replacement[0]!.lines.some((line) => line.cells.length > replacement[0]!.columns)
|
|
291
|
+
)
|
|
292
|
+
return reject();
|
|
293
|
+
checkExpansion(intent.source.length - intent.expected.length);
|
|
294
|
+
return intent.source === intent.expected ? [] : [{ from: table.from, to: table.to, insert: intent.source }];
|
|
295
|
+
}
|
|
296
|
+
case "colLeft":
|
|
297
|
+
return columnInsertion(resolveColumn(intent.column));
|
|
298
|
+
case "colRight":
|
|
299
|
+
return columnInsertion(resolveColumn(intent.column) + 1);
|
|
300
|
+
case "deleteCol":
|
|
301
|
+
return T.deleteColumn(table, resolveColumn(intent.column));
|
|
302
|
+
case "alignLeft":
|
|
303
|
+
return T.setAlign(table, resolveColumn(intent.column), "left");
|
|
304
|
+
case "alignCenter":
|
|
305
|
+
return T.setAlign(table, resolveColumn(intent.column), "center");
|
|
306
|
+
case "alignRight":
|
|
307
|
+
return T.setAlign(table, resolveColumn(intent.column), "right");
|
|
308
|
+
default:
|
|
309
|
+
return reject();
|
|
310
|
+
}
|
|
311
|
+
} catch (error) {
|
|
312
|
+
if (error instanceof TableActionLimitError) throw error;
|
|
313
|
+
// Transport may supply invalid shapes/bytes despite the static type. Every
|
|
314
|
+
// rejected intent has the same public error, and planning never edits text.
|
|
315
|
+
return reject();
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
/** A focus target is resolved against current identities immediately before use. */
|
|
320
|
+
export type TableFocusWitness = { table: TableWitness; row: RowWitness; column: ColumnWitness };
|
|
321
|
+
export const captureTableFocus = (text: Y.Text, target: TableTarget): TableFocusWitness => {
|
|
322
|
+
const row = captureTableAction(text, "rowBelow", target);
|
|
323
|
+
const column = captureTableAction(text, "alignLeft", target);
|
|
324
|
+
if (!("row" in row) || !("column" in column)) return reject();
|
|
325
|
+
return { table: row.table, row: row.row, column: column.column };
|
|
326
|
+
};
|
|
327
|
+
export const resolveTableFocus = (text: Y.Text, witness: TableFocusWitness): TableTarget | null => {
|
|
328
|
+
try {
|
|
329
|
+
const source = text.toString();
|
|
330
|
+
const at = (bytes: Uint8Array) => resolveTextAnchor(text, bytes);
|
|
331
|
+
const table = T.findTables(source).find(
|
|
332
|
+
(table) =>
|
|
333
|
+
table.lines[0]!.to === at(witness.table.headerBreak) &&
|
|
334
|
+
table.lines[1]!.cells.some((cell) => {
|
|
335
|
+
const hyphen = at(witness.table.separatorHyphen);
|
|
336
|
+
return hyphen !== null && hyphen >= cell.from && hyphen < cell.to;
|
|
337
|
+
}),
|
|
338
|
+
);
|
|
339
|
+
if (!table) return null;
|
|
340
|
+
const hyphen = at(witness.column.hyphen);
|
|
341
|
+
const column = table.lines[1]!.cells.findIndex((cell) => hyphen !== null && hyphen >= cell.from && hyphen < cell.to);
|
|
342
|
+
if (column < 0 || source[hyphen!] !== "-") return null;
|
|
343
|
+
const edges = columnEdges(table, column);
|
|
344
|
+
if (at(witness.column.left) !== edges[0] || at(witness.column.right) !== edges[1]) return null;
|
|
345
|
+
const rowWitness = witness.row;
|
|
346
|
+
const row =
|
|
347
|
+
rowWitness.kind === "header"
|
|
348
|
+
? 0
|
|
349
|
+
: Array.from({ length: T.rowCount(table) - 1 }, (_, index) => index + 1).find((index) => {
|
|
350
|
+
const line = T.gridLine(table, index)!;
|
|
351
|
+
return (
|
|
352
|
+
table.lines[index]!.to === at(rowWitness.beforeBreak) &&
|
|
353
|
+
(rowWitness.insideKind === "pipe" ? line.pipes.includes(at(rowWitness.inside) ?? -1) : line.to === at(rowWitness.inside))
|
|
354
|
+
);
|
|
355
|
+
});
|
|
356
|
+
if (row === undefined) return null;
|
|
357
|
+
return { tableFrom: table.from, rowFrom: T.gridLine(table, row)!.from, columnFrom: table.lines[0]!.cells[column]!.from, row, column };
|
|
358
|
+
} catch {
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
/** Resolve the selection at the planned head, then capture the ACTUAL receipt IDs. */
|
|
364
|
+
export const prepareTableActionFocus = (
|
|
365
|
+
before: Y.Text,
|
|
366
|
+
intent: TableActionIntent,
|
|
367
|
+
focus: TableFocusWitness,
|
|
368
|
+
caret?: { row: number; column: number },
|
|
369
|
+
): ((after: Y.Text) => TableFocusWitness | null) => {
|
|
370
|
+
const target = resolveTableFocus(before, focus);
|
|
371
|
+
const table = target && T.findTables(before.toString()).find((table) => table.from === target.tableFrom);
|
|
372
|
+
if (!target || !table || resolveTextAnchor(before, intent.table.headerBreak) !== table.lines[0]!.to) return () => null;
|
|
373
|
+
let { row, column } = target;
|
|
374
|
+
// Row/column intents must identify the same selected identity. The request's
|
|
375
|
+
// optional focus is presentation, never authority to change another target.
|
|
376
|
+
if ("row" in intent) {
|
|
377
|
+
const selected = resolveTableFocus(before, { ...focus, row: intent.row });
|
|
378
|
+
if (!selected || selected.row !== row) return () => null;
|
|
379
|
+
}
|
|
380
|
+
if ("column" in intent) {
|
|
381
|
+
const selected = resolveTableFocus(before, { ...focus, column: intent.column });
|
|
382
|
+
if (!selected || selected.column !== column) return () => null;
|
|
383
|
+
}
|
|
384
|
+
switch (intent.action) {
|
|
385
|
+
case "deleteTable":
|
|
386
|
+
return () => null;
|
|
387
|
+
case "rowAbove":
|
|
388
|
+
row = Math.max(1, row);
|
|
389
|
+
break;
|
|
390
|
+
case "rowBelow":
|
|
391
|
+
row++;
|
|
392
|
+
break;
|
|
393
|
+
case "moveRowUp":
|
|
394
|
+
row--;
|
|
395
|
+
break;
|
|
396
|
+
case "moveRowDown":
|
|
397
|
+
row++;
|
|
398
|
+
break;
|
|
399
|
+
case "colRight":
|
|
400
|
+
case "moveColRight":
|
|
401
|
+
column++;
|
|
402
|
+
break;
|
|
403
|
+
case "moveColLeft":
|
|
404
|
+
column--;
|
|
405
|
+
break;
|
|
406
|
+
case "toggleHeader":
|
|
407
|
+
row = table.lines[0]!.cells.some((cell) => cell.text) ? row + 1 : Math.max(0, row - 1);
|
|
408
|
+
break;
|
|
409
|
+
case "replaceSource":
|
|
410
|
+
row = caret?.row ?? row;
|
|
411
|
+
column = caret?.column ?? column;
|
|
412
|
+
break;
|
|
413
|
+
}
|
|
414
|
+
const from = table.from;
|
|
415
|
+
return (after) => {
|
|
416
|
+
const table = T.findTables(after.toString()).find((table) => table.from === from);
|
|
417
|
+
if (!table) return null;
|
|
418
|
+
const selectedRow = Math.max(0, Math.min(row, T.rowCount(table) - 1));
|
|
419
|
+
const selectedColumn = Math.max(0, Math.min(column, table.columns - 1));
|
|
420
|
+
try {
|
|
421
|
+
return captureTableFocus(after, {
|
|
422
|
+
tableFrom: table.from,
|
|
423
|
+
rowFrom: T.gridLine(table, selectedRow)!.from,
|
|
424
|
+
columnFrom: table.lines[0]!.cells[selectedColumn]!.from,
|
|
425
|
+
row: selectedRow,
|
|
426
|
+
column: selectedColumn,
|
|
427
|
+
});
|
|
428
|
+
} catch {
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
};
|
package/src/shared-text/types.ts
CHANGED
|
@@ -1,14 +1,22 @@
|
|
|
1
|
+
import type { SharedTextStructuralHistory } from "./structural-history";
|
|
1
2
|
export type SharedTextActor = { kind: "user" | "service_account"; id: string };
|
|
2
3
|
|
|
3
4
|
/** Actor is optional so events retained from older deployments remain readable. */
|
|
4
5
|
export type SharedTextEvent = {
|
|
5
|
-
kind: "sync" | "awareness";
|
|
6
|
+
kind: "sync" | "awareness" | "freeze";
|
|
6
7
|
payload: string;
|
|
7
8
|
originNodeId: string;
|
|
8
9
|
originPeerId: string | null;
|
|
9
10
|
actor?: SharedTextActor;
|
|
11
|
+
/** Echoed only by authorized server actions, for the initiating browser's undo history. */
|
|
12
|
+
actionId?: string;
|
|
13
|
+
/** Authored only by the server structural writer/admission boundary; retained with the event. */
|
|
14
|
+
history?: SharedTextStructuralHistory;
|
|
15
|
+
/** Updates apply only to the store lineage from which they originated. */
|
|
16
|
+
restoreRevision?: string;
|
|
10
17
|
};
|
|
11
|
-
|
|
18
|
+
/** Ordered document events; a freeze marker carries no text update. */
|
|
19
|
+
export type SharedTextSyncEvent = SharedTextEvent & { kind: "sync" | "freeze" };
|
|
12
20
|
export type SharedTextContributor = SharedTextActor & { lastContributedAt: Date };
|
|
13
21
|
|
|
14
22
|
/** The store anchors legacy cursors to this log before returning a snapshot. */
|
|
@@ -27,13 +35,24 @@ export type SharedTextSaveInput = {
|
|
|
27
35
|
actor: SharedTextActor | null;
|
|
28
36
|
contributors: SharedTextContributor[];
|
|
29
37
|
};
|
|
30
|
-
export type SharedTextSnapshot =
|
|
38
|
+
export type SharedTextSnapshot = SharedTextStoredState;
|
|
39
|
+
/** A disjoint edit in the original document's UTF-16 coordinates. */
|
|
40
|
+
export type SharedTextChange = {
|
|
41
|
+
from: number;
|
|
42
|
+
to: number;
|
|
43
|
+
insert: string;
|
|
44
|
+
/** Authorized identity fence: recreate a nonempty range even when its text is equal. */
|
|
45
|
+
recreate?: true;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type SharedTextActionReceipt = { actionId: string; update: Uint8Array };
|
|
49
|
+
|
|
31
50
|
export type SharedTextEditResult<Saved> =
|
|
32
51
|
| { status: "notFound" }
|
|
33
52
|
| { status: "unchanged"; text: string }
|
|
34
53
|
| { status: "syncFailed"; error: unknown }
|
|
35
|
-
| { status: "persistFailed"; cursor: string; error: unknown }
|
|
36
|
-
| { status: "edited"; text: string; cursor: string; saved: Saved };
|
|
54
|
+
| { status: "persistFailed"; cursor: string; error: unknown; receipt?: SharedTextActionReceipt }
|
|
55
|
+
| { status: "edited"; text: string; cursor: string; saved: Saved; receipt?: SharedTextActionReceipt };
|
|
37
56
|
|
|
38
57
|
export type SharedTextStore<Saved, Context> = {
|
|
39
58
|
load(docId: string, options: { signal?: AbortSignal }): Promise<SharedTextStoredState | null>;
|
|
@@ -29,7 +29,7 @@ export type AppLaunchpadLegalLink = {
|
|
|
29
29
|
};
|
|
30
30
|
|
|
31
31
|
export type AppLaunchpadContext = {
|
|
32
|
-
profile?: { name: string; theme: CloudThemePreference; appHref?: string };
|
|
32
|
+
profile?: { userId?: string; name: string; theme: CloudThemePreference; appHref?: string };
|
|
33
33
|
apps: AppLaunchpadApp[];
|
|
34
34
|
legalLinks: AppLaunchpadLegalLink[];
|
|
35
35
|
};
|
|
@@ -111,7 +111,12 @@ function RailNavigation(props: { currentPath: string; panelOpen?: boolean }) {
|
|
|
111
111
|
const locale = useLocale();
|
|
112
112
|
const t = () => platformMessages.resolve([locale()]).t;
|
|
113
113
|
if (!data?.rail || !data.profile) return null;
|
|
114
|
-
const profile = {
|
|
114
|
+
const profile = {
|
|
115
|
+
userId: data.profile.userId,
|
|
116
|
+
name: data.profile.name,
|
|
117
|
+
theme: data.theme,
|
|
118
|
+
...(data.profile.appHref ? { appHref: data.profile.appHref } : {}),
|
|
119
|
+
};
|
|
115
120
|
createEffect(() => setAppLaunchpadContext(data.apps, data.legalLinks, profile));
|
|
116
121
|
return (
|
|
117
122
|
<nav class="layout-rail-navigation flex min-h-0 flex-1 flex-col items-center gap-1" aria-label={t().apps}>
|
|
@@ -128,6 +133,7 @@ function RailNavigation(props: { currentPath: string; panelOpen?: boolean }) {
|
|
|
128
133
|
helpOpen={!!props.panelOpen && data.panel?.provider === "core"}
|
|
129
134
|
/>
|
|
130
135
|
<ProfilePreferences
|
|
136
|
+
userId={data.profile.userId}
|
|
131
137
|
appHref={data.profile.appHref}
|
|
132
138
|
avatarSrc={data.profile.avatarSrc}
|
|
133
139
|
initialTheme={data.theme}
|
|
@@ -164,7 +170,12 @@ function HeaderControls(props: {
|
|
|
164
170
|
<AppLaunchpadButton
|
|
165
171
|
profile={
|
|
166
172
|
data.profile
|
|
167
|
-
? {
|
|
173
|
+
? {
|
|
174
|
+
userId: data.profile.userId,
|
|
175
|
+
name: data.profile.name,
|
|
176
|
+
theme: data.theme,
|
|
177
|
+
...(data.profile.appHref ? { appHref: data.profile.appHref } : {}),
|
|
178
|
+
}
|
|
168
179
|
: undefined
|
|
169
180
|
}
|
|
170
181
|
apps={data.apps}
|
|
@@ -188,7 +199,12 @@ function HeaderControls(props: {
|
|
|
188
199
|
<AppLaunchpadButton
|
|
189
200
|
profile={
|
|
190
201
|
data.profile
|
|
191
|
-
? {
|
|
202
|
+
? {
|
|
203
|
+
userId: data.profile.userId,
|
|
204
|
+
name: data.profile.name,
|
|
205
|
+
theme: data.theme,
|
|
206
|
+
...(data.profile.appHref ? { appHref: data.profile.appHref } : {}),
|
|
207
|
+
}
|
|
192
208
|
: undefined
|
|
193
209
|
}
|
|
194
210
|
apps={data.apps}
|
package/src/ssr/Layout.tsx
CHANGED
|
@@ -271,7 +271,7 @@ export default function Layout(props: LayoutProps) {
|
|
|
271
271
|
? {
|
|
272
272
|
rail: c.get("railPreferences") ?? defaultRailPreferences(),
|
|
273
273
|
home: resolveHomePath(settings?.app?.home_path),
|
|
274
|
-
profile: { name: profileName, avatarSrc: profileAvatarSrc, appHref },
|
|
274
|
+
profile: { userId: user.id, name: profileName, avatarSrc: profileAvatarSrc, appHref },
|
|
275
275
|
}
|
|
276
276
|
: {}),
|
|
277
277
|
legalLinks,
|
|
@@ -5,10 +5,10 @@ import { createProfileActions } from "./profile-actions";
|
|
|
5
5
|
import { ThemeCycleButton } from "./ThemeCycleButton";
|
|
6
6
|
|
|
7
7
|
export function MobileProfileActions(props: {
|
|
8
|
-
profile: { name: string; theme: CloudThemePreference; appHref?: string };
|
|
8
|
+
profile: { userId?: string; name: string; theme: CloudThemePreference; appHref?: string };
|
|
9
9
|
beforeSelect: () => Promise<boolean>;
|
|
10
10
|
}) {
|
|
11
|
-
const { preferences, signingOut, signOut } = createProfileActions(props.profile.theme, props.profile.appHref);
|
|
11
|
+
const { preferences, signingOut, signOut } = createProfileActions(props.profile.theme, props.profile.appHref, props.profile.userId);
|
|
12
12
|
const navigate = (href: string) => (event: MouseEvent) => {
|
|
13
13
|
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
14
14
|
event.preventDefault();
|
|
@@ -3,6 +3,7 @@ import type { CloudThemePreference } from "../shared/theme";
|
|
|
3
3
|
import { createProfileActions } from "./profile-actions";
|
|
4
4
|
|
|
5
5
|
type ProfilePreferencesProps = {
|
|
6
|
+
userId?: string;
|
|
6
7
|
/** Set while the installation offers the mobile app. */
|
|
7
8
|
appHref?: string;
|
|
8
9
|
avatarSrc?: string;
|
|
@@ -16,7 +17,7 @@ type ProfilePreferencesProps = {
|
|
|
16
17
|
* bottom edge.
|
|
17
18
|
*/
|
|
18
19
|
export default function ProfilePreferences(props: ProfilePreferencesProps) {
|
|
19
|
-
const { preferences, items, signOut, signingOut } = createProfileActions(props.initialTheme, props.appHref);
|
|
20
|
+
const { preferences, items, signOut, signingOut } = createProfileActions(props.initialTheme, props.appHref, props.userId);
|
|
20
21
|
|
|
21
22
|
const avatar = () => <Avatar name={props.name} src={props.avatarSrc} size="sm" />;
|
|
22
23
|
|