@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,151 @@
|
|
|
1
|
+
import * as T from "@k2b/ui/text-editor/model";
|
|
2
|
+
import * as Y from "yjs";
|
|
3
|
+
|
|
4
|
+
type NativeFrame = Y.UndoManager["undoStack"][number];
|
|
5
|
+
type Span = { from: number; to: number };
|
|
6
|
+
type Piece = Span & { original: number | null };
|
|
7
|
+
|
|
8
|
+
export class SharedTextNativeHistoryError extends Error {
|
|
9
|
+
readonly code = "shared_text_history_target";
|
|
10
|
+
constructor(readonly reason: "dependency" | "unavailable") {
|
|
11
|
+
super("The input history target is unavailable");
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const intersect = (set: NativeFrame["insertions"], item: Y.Item): Span[] =>
|
|
16
|
+
(set.clients.get(item.id.client) ?? []).flatMap((run) => {
|
|
17
|
+
const from = Math.max(run.clock, item.id.clock) - item.id.clock;
|
|
18
|
+
const to = Math.min(run.clock + run.len, item.id.clock + item.length) - item.id.clock;
|
|
19
|
+
return from < to ? [{ from, to }] : [];
|
|
20
|
+
});
|
|
21
|
+
const subtract = (wanted: Span[], excluded: Span[]): Span[] =>
|
|
22
|
+
wanted.flatMap((span) => {
|
|
23
|
+
const result: Span[] = [];
|
|
24
|
+
let from = span.from;
|
|
25
|
+
for (const cut of excluded) {
|
|
26
|
+
if (cut.to <= from || cut.from >= span.to) continue;
|
|
27
|
+
if (cut.from > from) result.push({ from, to: cut.from });
|
|
28
|
+
from = Math.max(from, cut.to);
|
|
29
|
+
}
|
|
30
|
+
if (from < span.to) result.push({ from, to: span.to });
|
|
31
|
+
return result;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
type Region = Span & { key: string | undefined };
|
|
35
|
+
const regions = (source: string, original: (position: number) => number | null): Region[] =>
|
|
36
|
+
T.findTables(source).flatMap((table) =>
|
|
37
|
+
table.lines.flatMap((line, row) =>
|
|
38
|
+
line.cells.map((_cell, column) => {
|
|
39
|
+
const left = line.leadingPipe ? line.pipes[column]! + 1 : column === 0 ? line.from : line.pipes[column - 1]! + 1;
|
|
40
|
+
const right = (line.leadingPipe ? line.pipes[column + 1] : line.pipes[column]) ?? line.to;
|
|
41
|
+
const separator = table.lines[1]!.cells[column];
|
|
42
|
+
const hyphen = separator ? original(separator.from + separator.text.indexOf("-")) : null;
|
|
43
|
+
const header = original(table.lines[0]!.to);
|
|
44
|
+
const rowIdentity = row < 2 ? row : original(line.from - 1);
|
|
45
|
+
// Extra physical cells have no separator hyphen. Their surviving left
|
|
46
|
+
// delimiter distinguishes them even when columns are inserted/removed.
|
|
47
|
+
// An unknown identity is never evidence that two targets are the same.
|
|
48
|
+
const columnIdentity = separator ? hyphen : original(left - 1);
|
|
49
|
+
return {
|
|
50
|
+
from: left,
|
|
51
|
+
to: right,
|
|
52
|
+
key:
|
|
53
|
+
header === null || rowIdentity === null || columnIdentity === null
|
|
54
|
+
? undefined
|
|
55
|
+
: `${header}:${rowIdentity}:${separator ? "column" : "extra"}:${columnIdentity}`,
|
|
56
|
+
};
|
|
57
|
+
}),
|
|
58
|
+
),
|
|
59
|
+
);
|
|
60
|
+
const locate = (regions: Region[], position: number, end: number) => {
|
|
61
|
+
let low = 0,
|
|
62
|
+
high = regions.length;
|
|
63
|
+
while (low < high) {
|
|
64
|
+
const middle = (low + high) >>> 1;
|
|
65
|
+
if (regions[middle]!.to <= position) low = middle + 1;
|
|
66
|
+
else high = middle;
|
|
67
|
+
}
|
|
68
|
+
const region = regions[low];
|
|
69
|
+
return region && region.from <= position ? { key: region.key, to: region.to } : { key: null, to: region?.from ?? end };
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** Project root plain-text native effects without a second manager or live mutation.
|
|
73
|
+
* Identity sets and linked Item/content fields are exported Yjs contracts. Native
|
|
74
|
+
* restoration occupies the original deleted item's gap; redone items already
|
|
75
|
+
* have their effect and are not copied again. Unknown content refuses safely.
|
|
76
|
+
*/
|
|
77
|
+
export const validateNativeHistory = (
|
|
78
|
+
text: Y.Text,
|
|
79
|
+
frame: NativeFrame,
|
|
80
|
+
liveInsertions: NativeFrame["insertions"],
|
|
81
|
+
baseline: Uint8Array | null,
|
|
82
|
+
) => {
|
|
83
|
+
const known = baseline ? Y.decodeStateVector(baseline) : new Map<number, number>();
|
|
84
|
+
const chunks: string[] = [];
|
|
85
|
+
const pieces: Piece[] = [];
|
|
86
|
+
const protectedSpans: { before: number; after: number; length: number }[] = [];
|
|
87
|
+
let before = 0,
|
|
88
|
+
after = 0;
|
|
89
|
+
const append = (value: string, original: number | null) => {
|
|
90
|
+
if (!value.length) return;
|
|
91
|
+
chunks.push(value);
|
|
92
|
+
pieces.push({ from: after, to: after + value.length, original });
|
|
93
|
+
after += value.length;
|
|
94
|
+
};
|
|
95
|
+
for (const item of Y.getTypeChildren(text)) {
|
|
96
|
+
// Adopted frames lack correspondence captured by this controller. Native
|
|
97
|
+
// Undo follows pre-existing redone chains for insertions; projecting only
|
|
98
|
+
// the original IDs would omit their live effects. Leave such frames intact.
|
|
99
|
+
if (baseline === null && item.redone !== null && intersect(frame.insertions, item).length)
|
|
100
|
+
throw new SharedTextNativeHistoryError("unavailable");
|
|
101
|
+
if (item.deleted) {
|
|
102
|
+
const restore = subtract(intersect(frame.deletions, item), intersect(frame.insertions, item));
|
|
103
|
+
if (!restore.length || item.redone !== null) continue;
|
|
104
|
+
const content = item.content;
|
|
105
|
+
if (!(content instanceof Y.ContentString)) throw new SharedTextNativeHistoryError("unavailable");
|
|
106
|
+
for (const span of restore) append(content.str.slice(span.from, span.to), null);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (!item.countable) continue;
|
|
110
|
+
const content = item.content;
|
|
111
|
+
if (!(content instanceof Y.ContentString)) throw new SharedTextNativeHistoryError("unavailable");
|
|
112
|
+
const keep = subtract([{ from: 0, to: item.length }], intersect(liveInsertions, item));
|
|
113
|
+
for (const span of keep) {
|
|
114
|
+
const newer = Math.max(span.from, (known.get(item.id.client) ?? 0) - item.id.clock);
|
|
115
|
+
if (newer < span.to) protectedSpans.push({ before: before + newer, after: after + newer - span.from, length: span.to - newer });
|
|
116
|
+
append(content.str.slice(span.from, span.to), before + span.from);
|
|
117
|
+
}
|
|
118
|
+
before += item.length;
|
|
119
|
+
}
|
|
120
|
+
const source = text.toString();
|
|
121
|
+
const candidate = chunks.join("");
|
|
122
|
+
if (before !== source.length) throw new SharedTextNativeHistoryError("unavailable");
|
|
123
|
+
const original = (position: number) => {
|
|
124
|
+
let low = 0,
|
|
125
|
+
high = pieces.length;
|
|
126
|
+
while (low < high) {
|
|
127
|
+
const middle = (low + high) >>> 1;
|
|
128
|
+
if (pieces[middle]!.to <= position) low = middle + 1;
|
|
129
|
+
else high = middle;
|
|
130
|
+
}
|
|
131
|
+
const piece = pieces[low];
|
|
132
|
+
return piece?.original != null ? piece.original + position - piece.from : null;
|
|
133
|
+
};
|
|
134
|
+
const beforeRegions = regions(source, (position) => position);
|
|
135
|
+
const afterRegions = regions(candidate, original);
|
|
136
|
+
for (const span of protectedSpans) {
|
|
137
|
+
let offset = 0;
|
|
138
|
+
while (offset < span.length) {
|
|
139
|
+
const oldAt = span.before + offset,
|
|
140
|
+
newAt = span.after + offset;
|
|
141
|
+
const oldTarget = locate(beforeRegions, oldAt, source.length);
|
|
142
|
+
const newTarget = locate(afterRegions, newAt, candidate.length);
|
|
143
|
+
if (oldTarget.key === undefined || newTarget.key === undefined || oldTarget.key !== newTarget.key)
|
|
144
|
+
throw new SharedTextNativeHistoryError("dependency");
|
|
145
|
+
const length = Math.min(span.length - offset, oldTarget.to - oldAt, newTarget.to - newAt);
|
|
146
|
+
if (length <= 0) throw new SharedTextNativeHistoryError("unavailable");
|
|
147
|
+
offset += length;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return candidate;
|
|
151
|
+
};
|
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
import * as T from "@k2b/ui/text-editor/model";
|
|
2
|
+
import * as Y from "yjs";
|
|
3
|
+
import { captureTextAnchor, resolveTextAnchor } from "../shared-text/anchors";
|
|
4
|
+
import { captureTableFocus, resolveTableFocus, type TableFocusWitness } from "../shared-text/table-actions";
|
|
5
|
+
|
|
6
|
+
export type SharedTextInverseRequest = {
|
|
7
|
+
action: "undo" | "redo";
|
|
8
|
+
receiptCursor: string;
|
|
9
|
+
actionId: string;
|
|
10
|
+
applied: Promise<void>;
|
|
11
|
+
cancel: (reason: unknown) => void;
|
|
12
|
+
};
|
|
13
|
+
type NativeFrame = Y.UndoManager["undoStack"][number];
|
|
14
|
+
type CellBoundary = { focus: TableFocusWitness; left: Uint8Array | null; right: Uint8Array };
|
|
15
|
+
type Boundary = { left: Uint8Array | null; right: Uint8Array | null; cell?: CellBoundary };
|
|
16
|
+
type TextFrame = {
|
|
17
|
+
kind: "text";
|
|
18
|
+
item: NativeFrame;
|
|
19
|
+
boundaries: Boundary[];
|
|
20
|
+
liveInsertions: NativeFrame["insertions"];
|
|
21
|
+
baseline: Uint8Array | null;
|
|
22
|
+
};
|
|
23
|
+
type Frame = TextFrame | { kind: "structure"; cursor: string };
|
|
24
|
+
|
|
25
|
+
// UndoManager deliberately skips effectless native frames. A remote structural
|
|
26
|
+
// deletion can make an earlier typing frame effectless; reject it before a
|
|
27
|
+
// native call can silently consume the next independent step as well.
|
|
28
|
+
const hasUncovered = (wanted: NativeFrame["insertions"], excluded: NativeFrame["insertions"]): boolean => {
|
|
29
|
+
for (const [client, ranges] of wanted.clients) {
|
|
30
|
+
const removed = excluded.clients.get(client) ?? [];
|
|
31
|
+
for (const range of ranges) {
|
|
32
|
+
let clock = range.clock;
|
|
33
|
+
for (const deletion of removed) {
|
|
34
|
+
if (deletion.clock + deletion.len <= clock) continue;
|
|
35
|
+
if (deletion.clock > clock) break;
|
|
36
|
+
clock = Math.max(clock, deletion.clock + deletion.len);
|
|
37
|
+
if (clock >= range.clock + range.len) break;
|
|
38
|
+
}
|
|
39
|
+
if (clock < range.clock + range.len) return true;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return false;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The supplied manager owns ordinary input only. Structural receipts are remote
|
|
47
|
+
* and their inverses are authorized by the server. Hosts route every history
|
|
48
|
+
* entry point through this controller, including native editor key bindings.
|
|
49
|
+
*/
|
|
50
|
+
export const createSharedTextActionHistory = (doc: Y.Doc, history: Y.UndoManager, onChange?: () => void, onRetainedColumn?: () => void) => {
|
|
51
|
+
const undo: Frame[] = [],
|
|
52
|
+
redo: Frame[] = [];
|
|
53
|
+
const remote = {};
|
|
54
|
+
const text = doc.getText("codemirror");
|
|
55
|
+
let deletedBoundaries: Boundary[] = [];
|
|
56
|
+
// One public text observer captures surviving identities, before the native
|
|
57
|
+
// manager emits stack-item-added/updated in afterTransaction. Metadata lives
|
|
58
|
+
// only on reachable frames, and disappears with clear/dispose.
|
|
59
|
+
const observe = (event: Y.YTextEvent) => {
|
|
60
|
+
deletedBoundaries = [];
|
|
61
|
+
let at = 0;
|
|
62
|
+
// Content neighbors may be removed/restored by another native manager.
|
|
63
|
+
// Relax only inside the SAME still-live physical cell scaffold, never for
|
|
64
|
+
// an operation deleting table syntax (including delimiter escapes) or a
|
|
65
|
+
// vanished row/column. Reviving a backslash can escape a still-live pipe.
|
|
66
|
+
// Y.Text's changes.deleted is always empty. Its public transaction delete
|
|
67
|
+
// set is the actual deleted content, before transaction cleanup/GC.
|
|
68
|
+
let contentOnly = true;
|
|
69
|
+
Y.iterateDeletedStructs(event.transaction, event.transaction.deleteSet, (item) => {
|
|
70
|
+
if (!(item instanceof Y.Item)) {
|
|
71
|
+
contentOnly = false;
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (item.parent !== text) return;
|
|
75
|
+
const content = item.content;
|
|
76
|
+
if (!(content instanceof Y.ContentString) || ["|", "\\", "\n", "\r"].some((value) => content.str.includes(value)))
|
|
77
|
+
contentOnly = false;
|
|
78
|
+
});
|
|
79
|
+
const tables = contentOnly && event.delta.some((delta) => delta.delete) ? T.findTables(text.toString()) : [];
|
|
80
|
+
const captureCell = (at: number): CellBoundary | undefined => {
|
|
81
|
+
for (const table of tables) {
|
|
82
|
+
const index = table.lines.findIndex((line) => at >= line.from && at <= line.to);
|
|
83
|
+
if (index < 0 || index === 1) continue;
|
|
84
|
+
const line = table.lines[index]!;
|
|
85
|
+
for (let column = 0; column < table.columns; column++) {
|
|
86
|
+
const left = line.leadingPipe ? line.pipes[column] : column === 0 ? line.from - 1 : line.pipes[column - 1];
|
|
87
|
+
const right = (line.leadingPipe ? line.pipes[column + 1] : line.pipes[column]) ?? line.to;
|
|
88
|
+
if (left === undefined || at <= left || at > right || right >= text.length) continue;
|
|
89
|
+
try {
|
|
90
|
+
const row = index === 0 ? 0 : index - 1;
|
|
91
|
+
return {
|
|
92
|
+
focus: captureTableFocus(text, {
|
|
93
|
+
tableFrom: table.from,
|
|
94
|
+
rowFrom: line.from,
|
|
95
|
+
columnFrom: table.lines[0]!.cells[column]!.from,
|
|
96
|
+
row,
|
|
97
|
+
column,
|
|
98
|
+
}),
|
|
99
|
+
left: left < 0 ? null : captureTextAnchor(text, left),
|
|
100
|
+
right: captureTextAnchor(text, right),
|
|
101
|
+
};
|
|
102
|
+
} catch {
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return undefined;
|
|
108
|
+
};
|
|
109
|
+
for (const delta of event.delta) {
|
|
110
|
+
if (delta.retain) {
|
|
111
|
+
at += delta.retain;
|
|
112
|
+
}
|
|
113
|
+
if (delta.insert) at += typeof delta.insert === "string" ? delta.insert.length : 1;
|
|
114
|
+
if (delta.delete) {
|
|
115
|
+
deletedBoundaries.push({
|
|
116
|
+
left: at > 0 ? captureTextAnchor(text, at - 1) : null,
|
|
117
|
+
right: at < text.length ? captureTextAnchor(text, at) : null,
|
|
118
|
+
cell: captureCell(at),
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
text.observe(observe);
|
|
124
|
+
const available = (boundary: Boundary, frame: TextFrame) => {
|
|
125
|
+
const left = boundary.left ? resolveTextAnchor(text, boundary.left) : -1;
|
|
126
|
+
const right = boundary.right ? resolveTextAnchor(text, boundary.right) : text.length;
|
|
127
|
+
const removedHere = (bytes: Uint8Array | null) => {
|
|
128
|
+
const id = bytes && Y.decodeRelativePosition(bytes).item;
|
|
129
|
+
return !!id && Y.isDeleted(frame.item.deletions, id);
|
|
130
|
+
};
|
|
131
|
+
// Later edits merged into this SAME native frame may delete its earlier
|
|
132
|
+
// boundary. Their newer outer boundary still protects the whole reversal.
|
|
133
|
+
if ((left === null && !removedHere(boundary.left)) || (right === null && !removedHere(boundary.right))) {
|
|
134
|
+
const cell = boundary.cell;
|
|
135
|
+
const tables = cell ? T.findTables(text.toString()) : [];
|
|
136
|
+
let target = cell && resolveTableFocus(text, cell.focus);
|
|
137
|
+
if (cell && !target) {
|
|
138
|
+
// Inserting a column on the left adds a delimiter BEFORE the original
|
|
139
|
+
// hyphen; it does not replace that column. Native content history can
|
|
140
|
+
// follow this insertion only while the original hyphen/right edge and
|
|
141
|
+
// strict table/row witnesses survive. Server action witnesses stay fixed.
|
|
142
|
+
const hyphen = resolveTextAnchor(text, cell.focus.column.hyphen);
|
|
143
|
+
const oldLeft = resolveTextAnchor(text, cell.focus.column.left);
|
|
144
|
+
for (const table of tables) {
|
|
145
|
+
const separator = table.lines[1]!;
|
|
146
|
+
const column = separator.cells.findIndex((cell) => hyphen !== null && hyphen >= cell.from && hyphen < cell.to);
|
|
147
|
+
if (column < 0) continue;
|
|
148
|
+
const left = separator.leadingPipe ? separator.pipes[column] : column === 0 ? table.lines[0]!.to : separator.pipes[column - 1];
|
|
149
|
+
if (
|
|
150
|
+
left === undefined ||
|
|
151
|
+
oldLeft === null ||
|
|
152
|
+
oldLeft > left ||
|
|
153
|
+
(oldLeft !== table.lines[0]!.to && !separator.pipes.includes(oldLeft))
|
|
154
|
+
)
|
|
155
|
+
continue;
|
|
156
|
+
target = resolveTableFocus(text, {
|
|
157
|
+
...cell.focus,
|
|
158
|
+
column: { ...cell.focus.column, left: captureTextAnchor(text, left) },
|
|
159
|
+
});
|
|
160
|
+
if (target) break;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const tableFrom = target?.tableFrom;
|
|
164
|
+
const table = target && tables.find((table) => table.from === tableFrom);
|
|
165
|
+
const line = table && target && T.gridLine(table, target.row);
|
|
166
|
+
if (!cell || !target || !line) return false;
|
|
167
|
+
const edgeLeft = line.leadingPipe ? line.pipes[target.column] : target.column === 0 ? line.from - 1 : line.pipes[target.column - 1];
|
|
168
|
+
const edgeRight = (line.leadingPipe ? line.pipes[target.column + 1] : line.pipes[target.column]) ?? line.to;
|
|
169
|
+
const oldLeft = cell.left ? resolveTextAnchor(text, cell.left) : -1;
|
|
170
|
+
if (
|
|
171
|
+
edgeLeft === undefined ||
|
|
172
|
+
oldLeft === null ||
|
|
173
|
+
oldLeft < line.from - 1 ||
|
|
174
|
+
oldLeft > edgeLeft ||
|
|
175
|
+
(oldLeft !== line.from - 1 && !line.pipes.includes(oldLeft)) ||
|
|
176
|
+
resolveTextAnchor(text, cell.right) !== edgeRight
|
|
177
|
+
)
|
|
178
|
+
return false;
|
|
179
|
+
// A deleted neighbor's ordinary fallback must still lie INSIDE that
|
|
180
|
+
// original column and row. Merely finding a same-looking cell is unsafe.
|
|
181
|
+
return [boundary.left, boundary.right].every((bytes) => {
|
|
182
|
+
if (!bytes) return false;
|
|
183
|
+
const position = Y.createAbsolutePositionFromRelativePosition(Y.decodeRelativePosition(bytes), doc, false);
|
|
184
|
+
return position?.type === text && position.index > edgeLeft && position.index <= edgeRight;
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
if (left !== null && right !== null && right <= left) return false;
|
|
188
|
+
if (boundary.left !== null || boundary.right !== null || !text.length) return true;
|
|
189
|
+
// Whole-text replacements have no external boundaries. They are safe only
|
|
190
|
+
// while all live replacement identities still belong to this frame.
|
|
191
|
+
const first = Y.createRelativePositionFromTypeIndex(text, 0).item;
|
|
192
|
+
if (!first) return false;
|
|
193
|
+
let item: Y.Item | null = Y.getItem(doc.store, first);
|
|
194
|
+
while (item) {
|
|
195
|
+
if (
|
|
196
|
+
!item.deleted &&
|
|
197
|
+
item.countable &&
|
|
198
|
+
!frame.liveInsertions.clients
|
|
199
|
+
.get(item.id.client)
|
|
200
|
+
?.some((run) => run.clock <= item!.id.clock && run.clock + run.len >= item!.id.clock + item!.length)
|
|
201
|
+
)
|
|
202
|
+
return false;
|
|
203
|
+
item = item.right;
|
|
204
|
+
}
|
|
205
|
+
return true;
|
|
206
|
+
};
|
|
207
|
+
const includesRun = (set: NativeFrame["insertions"], id: Y.ID, length = 1) =>
|
|
208
|
+
set.clients.get(id.client)?.some((run) => run.clock <= id.clock && run.clock + run.len >= id.clock + length) ?? false;
|
|
209
|
+
const restoredAt = (id: Y.ID, created: NativeFrame["insertions"]) => {
|
|
210
|
+
const at = (assoc: number) => Y.createAbsolutePositionFromRelativePosition(new Y.RelativePosition(null, null, id, assoc), doc, true);
|
|
211
|
+
const start = at(0),
|
|
212
|
+
end = at(-1);
|
|
213
|
+
// A deleted item's ordinary fallback resolves both associations to the same
|
|
214
|
+
// gap. A real live native restoration resolves on either SIDE of its char.
|
|
215
|
+
if (start?.type !== text || end?.type !== text || end.index !== start.index + 1) return null;
|
|
216
|
+
const live = Y.createRelativePositionFromTypeIndex(text, start.index).item;
|
|
217
|
+
return live && includesRun(created, live) ? { id: live, index: start.index } : null;
|
|
218
|
+
};
|
|
219
|
+
const refreshRestorations = (created: NativeFrame["insertions"]) => {
|
|
220
|
+
if (!created.clients.size) return;
|
|
221
|
+
for (const frame of [...undo, ...redo]) {
|
|
222
|
+
if (frame.kind !== "text") continue;
|
|
223
|
+
for (const boundary of frame.boundaries)
|
|
224
|
+
for (const side of ["left", "right"] as const) {
|
|
225
|
+
const bytes = boundary[side];
|
|
226
|
+
if (!bytes || resolveTextAnchor(text, bytes) !== null) continue;
|
|
227
|
+
const id = Y.decodeRelativePosition(bytes).item;
|
|
228
|
+
const restored = id && restoredAt(id, created);
|
|
229
|
+
if (restored) boundary[side] = captureTextAnchor(text, restored.index);
|
|
230
|
+
}
|
|
231
|
+
const next = Y.createDeleteSet();
|
|
232
|
+
for (const [client, ranges] of frame.liveInsertions.clients) {
|
|
233
|
+
for (const range of ranges) {
|
|
234
|
+
let clock = range.clock;
|
|
235
|
+
const until = clock + range.len;
|
|
236
|
+
while (clock < until) {
|
|
237
|
+
const item = Y.getItem(doc.store, Y.createID(client, clock));
|
|
238
|
+
const length = Math.min(until - clock, item.id.clock + item.length - clock);
|
|
239
|
+
const first = restoredAt(Y.createID(client, clock), created);
|
|
240
|
+
const last = restoredAt(Y.createID(client, clock + length - 1), created);
|
|
241
|
+
const mapped =
|
|
242
|
+
first &&
|
|
243
|
+
last &&
|
|
244
|
+
first.id.client === last.id.client &&
|
|
245
|
+
last.id.clock === first.id.clock + length - 1 &&
|
|
246
|
+
includesRun(created, first.id, length);
|
|
247
|
+
const id = mapped ? first.id : Y.createID(client, clock);
|
|
248
|
+
const target = next.clients.get(id.client) ?? [];
|
|
249
|
+
target.push({ clock: id.clock, len: length });
|
|
250
|
+
next.clients.set(id.client, target);
|
|
251
|
+
clock += length;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
frame.liveInsertions = Y.mergeDeleteSets([next]);
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
let disposed = false;
|
|
259
|
+
let notifying = false;
|
|
260
|
+
// Native events can run inside yCollab's EditorView.update. Host callbacks
|
|
261
|
+
// may reconfigure that view through reactive UI state; never call them there.
|
|
262
|
+
const notify = () => {
|
|
263
|
+
if (notifying || disposed) return;
|
|
264
|
+
notifying = true;
|
|
265
|
+
queueMicrotask(() => {
|
|
266
|
+
notifying = false;
|
|
267
|
+
if (!disposed) onChange?.();
|
|
268
|
+
});
|
|
269
|
+
};
|
|
270
|
+
let moving = false;
|
|
271
|
+
let inverse: ((request: SharedTextInverseRequest) => Promise<void>) | undefined;
|
|
272
|
+
let pending: { id: string; resolve(): void; reject(reason: unknown): void; direction?: "undo" | "redo"; frame?: Frame } | null = null;
|
|
273
|
+
const added = ({ stackItem }: { stackItem: NativeFrame }) => {
|
|
274
|
+
if (moving) return;
|
|
275
|
+
undo.push({
|
|
276
|
+
kind: "text",
|
|
277
|
+
item: stackItem,
|
|
278
|
+
boundaries: [...deletedBoundaries],
|
|
279
|
+
liveInsertions: stackItem.insertions,
|
|
280
|
+
baseline: Y.encodeStateVector(doc),
|
|
281
|
+
});
|
|
282
|
+
redo.length = 0;
|
|
283
|
+
notify();
|
|
284
|
+
};
|
|
285
|
+
const updated = ({ stackItem }: { stackItem: NativeFrame }) => {
|
|
286
|
+
if (moving) return;
|
|
287
|
+
const frame = undo.findLast((frame) => frame.kind === "text" && frame.item === stackItem);
|
|
288
|
+
if (frame?.kind === "text") {
|
|
289
|
+
frame.boundaries.push(...deletedBoundaries);
|
|
290
|
+
frame.liveInsertions = stackItem.insertions;
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
const cleared = ({ undoStackCleared, redoStackCleared }: { undoStackCleared: boolean; redoStackCleared: boolean }) => {
|
|
294
|
+
if (moving) return;
|
|
295
|
+
if (undoStackCleared) undo.length = 0;
|
|
296
|
+
if (redoStackCleared) redo.length = 0;
|
|
297
|
+
notify();
|
|
298
|
+
};
|
|
299
|
+
history.on("stack-item-added", added);
|
|
300
|
+
history.on("stack-item-updated", updated);
|
|
301
|
+
history.on("stack-cleared", cleared);
|
|
302
|
+
// Existing input frames have no captured deletion context. Such restoration
|
|
303
|
+
// is unavailable. Insertion-only Undo requires no pre-registration redone
|
|
304
|
+
// chains; validation refuses unknown live correspondence before mutation.
|
|
305
|
+
undo.push(
|
|
306
|
+
...history.undoStack.map((item) => ({ kind: "text" as const, item, boundaries: [], liveInsertions: item.insertions, baseline: null })),
|
|
307
|
+
);
|
|
308
|
+
const cancel = (id: string, reason: unknown) => {
|
|
309
|
+
if (pending?.id !== id) return;
|
|
310
|
+
const action = pending;
|
|
311
|
+
pending = null;
|
|
312
|
+
action.reject(reason);
|
|
313
|
+
notify();
|
|
314
|
+
};
|
|
315
|
+
const begin = (id: string, direction?: "undo" | "redo", frame?: Frame) => {
|
|
316
|
+
if (disposed || pending || !id) throw new Error("A table action is unavailable or still awaiting its receipt");
|
|
317
|
+
const applied = new Promise<void>((resolve, reject) => {
|
|
318
|
+
pending = { id, resolve, reject, direction, frame };
|
|
319
|
+
});
|
|
320
|
+
void applied.catch(() => {});
|
|
321
|
+
notify();
|
|
322
|
+
return { applied, cancel: (reason: unknown) => cancel(id, reason) };
|
|
323
|
+
};
|
|
324
|
+
const move = async (direction: "undo" | "redo") => {
|
|
325
|
+
if (disposed || pending) return null;
|
|
326
|
+
const from = direction === "undo" ? undo : redo,
|
|
327
|
+
to = direction === "undo" ? redo : undo;
|
|
328
|
+
const frame = from.at(-1);
|
|
329
|
+
if (!frame) return null;
|
|
330
|
+
history.stopCapturing();
|
|
331
|
+
if (frame.kind === "structure") {
|
|
332
|
+
if (!inverse) throw new Error("Structural history requires an authorized inverse handler");
|
|
333
|
+
const actionId = crypto.randomUUID();
|
|
334
|
+
const action = begin(actionId, direction, frame);
|
|
335
|
+
try {
|
|
336
|
+
await inverse({ action: direction, receiptCursor: frame.cursor, actionId, ...action });
|
|
337
|
+
await action.applied;
|
|
338
|
+
} catch (error) {
|
|
339
|
+
action.cancel(error);
|
|
340
|
+
throw error;
|
|
341
|
+
}
|
|
342
|
+
return frame;
|
|
343
|
+
}
|
|
344
|
+
const { validateNativeHistory } = await import("./shared-text-history-validation");
|
|
345
|
+
if (disposed || pending || from.at(-1) !== frame) throw new Error("The input history frame is unavailable");
|
|
346
|
+
// Public UndoManager events preserve CodeMirror selection metadata. Never
|
|
347
|
+
// pop another input frame if the referenced native step became unavailable.
|
|
348
|
+
const stack = direction === "undo" ? history.undoStack : history.redoStack;
|
|
349
|
+
if (stack.at(-1) !== frame.item) throw new Error("The input history frame is unavailable");
|
|
350
|
+
if (
|
|
351
|
+
hasUncovered(frame.item.deletions, frame.item.insertions) &&
|
|
352
|
+
(!frame.boundaries.length || frame.boundaries.some((boundary) => !available(boundary, frame)))
|
|
353
|
+
)
|
|
354
|
+
throw new Error("The input history target is unavailable");
|
|
355
|
+
if (
|
|
356
|
+
!hasUncovered(frame.liveInsertions, Y.createDeleteSetFromStructStore(doc.store)) &&
|
|
357
|
+
!hasUncovered(frame.item.deletions, frame.item.insertions)
|
|
358
|
+
)
|
|
359
|
+
throw new Error("The input history frame is unavailable");
|
|
360
|
+
validateNativeHistory(text, frame.item, frame.liveInsertions, frame.baseline);
|
|
361
|
+
moving = true;
|
|
362
|
+
try {
|
|
363
|
+
const result = direction === "undo" ? history.undo() : history.redo();
|
|
364
|
+
if (!result) throw new Error("The input history frame could not be reversed");
|
|
365
|
+
from.pop();
|
|
366
|
+
const replacement = (direction === "undo" ? history.redoStack : history.undoStack).at(-1);
|
|
367
|
+
if (replacement) {
|
|
368
|
+
to.push({
|
|
369
|
+
kind: "text",
|
|
370
|
+
item: replacement,
|
|
371
|
+
boundaries: [...deletedBoundaries],
|
|
372
|
+
liveInsertions: replacement.insertions,
|
|
373
|
+
baseline: frame.baseline,
|
|
374
|
+
});
|
|
375
|
+
refreshRestorations(replacement.insertions);
|
|
376
|
+
}
|
|
377
|
+
notify();
|
|
378
|
+
return frame;
|
|
379
|
+
} finally {
|
|
380
|
+
moving = false;
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
return {
|
|
384
|
+
isRemoteOrigin: (value: unknown) => value === remote,
|
|
385
|
+
hasPending: () => pending !== null,
|
|
386
|
+
canUndo: () => !pending && undo.length > 0,
|
|
387
|
+
canRedo: () => !pending && redo.length > 0,
|
|
388
|
+
nextStructural: (direction: "undo" | "redo") => (direction === "undo" ? undo : redo).at(-1)?.kind === "structure",
|
|
389
|
+
undoDepth: () => undo.length,
|
|
390
|
+
redoDepth: () => redo.length,
|
|
391
|
+
undo: () => move("undo"),
|
|
392
|
+
redo: () => move("redo"),
|
|
393
|
+
setInverseHandler(handler: (request: SharedTextInverseRequest) => Promise<void>) {
|
|
394
|
+
inverse = handler;
|
|
395
|
+
},
|
|
396
|
+
begin(id: string) {
|
|
397
|
+
return begin(id);
|
|
398
|
+
},
|
|
399
|
+
apply(update: Uint8Array, actionId?: string, cursor?: string, retained = false) {
|
|
400
|
+
Y.decodeUpdate(update);
|
|
401
|
+
const action = pending?.id === actionId ? pending : null;
|
|
402
|
+
if (action && !cursor) throw new Error("A structural receipt requires its retained cursor");
|
|
403
|
+
// Probe causal coverage before mutating, so a receipt never resolves
|
|
404
|
+
// while its target predecessors are still missing from this browser.
|
|
405
|
+
if (action) {
|
|
406
|
+
const probe = new Y.Doc();
|
|
407
|
+
try {
|
|
408
|
+
Y.applyUpdate(probe, Y.encodeStateAsUpdate(doc));
|
|
409
|
+
Y.applyUpdate(probe, update);
|
|
410
|
+
if (probe.store.pendingStructs || probe.store.pendingDs) throw new Error("The table action is missing preceding updates");
|
|
411
|
+
} finally {
|
|
412
|
+
probe.destroy();
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
history.stopCapturing();
|
|
416
|
+
Y.applyUpdate(doc, update, remote);
|
|
417
|
+
history.stopCapturing();
|
|
418
|
+
if (!action) return;
|
|
419
|
+
pending = null;
|
|
420
|
+
if (action.direction) {
|
|
421
|
+
const from = action.direction === "undo" ? undo : redo,
|
|
422
|
+
to = action.direction === "undo" ? redo : undo;
|
|
423
|
+
const index = from.indexOf(action.frame!);
|
|
424
|
+
if (index >= 0) from.splice(index, 1);
|
|
425
|
+
to.push({ kind: "structure", cursor: cursor! });
|
|
426
|
+
if (retained && action.direction === "undo") onRetainedColumn?.();
|
|
427
|
+
} else {
|
|
428
|
+
undo.push({ kind: "structure", cursor: cursor! });
|
|
429
|
+
redo.length = 0;
|
|
430
|
+
// New structural input invalidates native Redo too. Public clear keeps
|
|
431
|
+
// current typing Undo and releases its unreachable native metadata.
|
|
432
|
+
moving = true;
|
|
433
|
+
try {
|
|
434
|
+
history.clear(false, true);
|
|
435
|
+
} finally {
|
|
436
|
+
moving = false;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
action.resolve();
|
|
440
|
+
notify();
|
|
441
|
+
},
|
|
442
|
+
cancelPending(reason: unknown) {
|
|
443
|
+
if (pending) cancel(pending.id, reason);
|
|
444
|
+
},
|
|
445
|
+
clear() {
|
|
446
|
+
if (pending) cancel(pending.id, new Error("The editor history was cleared"));
|
|
447
|
+
undo.length = redo.length = 0;
|
|
448
|
+
deletedBoundaries = [];
|
|
449
|
+
history.clear();
|
|
450
|
+
notify();
|
|
451
|
+
},
|
|
452
|
+
dispose() {
|
|
453
|
+
if (disposed) return;
|
|
454
|
+
disposed = true;
|
|
455
|
+
if (pending) cancel(pending.id, new Error("The editor closed before the table action was confirmed"));
|
|
456
|
+
text.unobserve(observe);
|
|
457
|
+
deletedBoundaries = [];
|
|
458
|
+
history.off("stack-item-added", added);
|
|
459
|
+
history.off("stack-item-updated", updated);
|
|
460
|
+
history.off("stack-cleared", cleared);
|
|
461
|
+
undo.length = redo.length = 0;
|
|
462
|
+
},
|
|
463
|
+
};
|
|
464
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { openSharedTextOffline, type SharedTextOfflineIdentity, type SharedTextOfflineSession } from "./shared-text-offline";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Capture the device's logout fence BEFORE asking the server to authorize a
|
|
5
|
+
* session. Opening touches epoch metadata only. No draft bytes escape until
|
|
6
|
+
* authorization succeeds and load rechecks the same epoch transactionally.
|
|
7
|
+
*/
|
|
8
|
+
export const openAuthorizedSharedTextOffline = async <Authorized>(
|
|
9
|
+
identity: SharedTextOfflineIdentity,
|
|
10
|
+
authorize: (signal: AbortSignal) => Promise<Authorized>,
|
|
11
|
+
signal?: AbortSignal,
|
|
12
|
+
): Promise<{ session: SharedTextOfflineSession; authorization: Authorized; state: Uint8Array | null; baseCursor: string | null }> => {
|
|
13
|
+
signal?.throwIfAborted();
|
|
14
|
+
const session = await openSharedTextOffline(identity);
|
|
15
|
+
const stop = () => session.close();
|
|
16
|
+
signal?.addEventListener("abort", stop, { once: true });
|
|
17
|
+
try {
|
|
18
|
+
signal?.throwIfAborted();
|
|
19
|
+
const authorization = await authorize(session.signal);
|
|
20
|
+
session.signal.throwIfAborted();
|
|
21
|
+
const pending = await session.loadPending();
|
|
22
|
+
return { session, authorization, state: pending?.state ?? null, baseCursor: pending?.baseCursor ?? null };
|
|
23
|
+
} catch (error) {
|
|
24
|
+
session.close();
|
|
25
|
+
throw error;
|
|
26
|
+
} finally {
|
|
27
|
+
signal?.removeEventListener("abort", stop);
|
|
28
|
+
}
|
|
29
|
+
};
|