@plannotator/ui 0.52.1 → 0.53.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/HANDOFF.md +284 -29
- package/README.md +45 -5
- package/components/AnnotationPanel.tsx +195 -118
- package/components/AnnotationToolbar.tsx +17 -14
- package/components/AnnotationToolstrip.tsx +54 -40
- package/components/BlockRenderer.tsx +20 -14
- package/components/CodeFilePopout.tsx +132 -69
- package/components/CommentPopover.tsx +8 -349
- package/components/ComposerTokenTextarea.tsx +379 -0
- package/components/ExportModal.tsx +6 -1
- package/components/HostTitle.tsx +108 -0
- package/components/HtmlSurfaceControls.tsx +2 -1
- package/components/ImageLightbox.tsx +288 -17
- package/components/ImagePeek.tsx +255 -0
- package/components/ImageResolverContext.tsx +31 -0
- package/components/InlineMarkdown.tsx +123 -15
- package/components/LocalLinkContext.tsx +49 -0
- package/components/MentionPicker.tsx +51 -0
- package/components/MenuVersionSection.tsx +51 -25
- package/components/OpenInAppButton.tsx +18 -49
- package/components/PlanHeaderMenu.tsx +2 -8
- package/components/ProviderIcons.tsx +10 -4
- package/components/RenderedMarkdown.tsx +40 -4
- package/components/Settings.tsx +33 -5
- package/components/SkillReferenceMenu.tsx +67 -18
- package/components/Tooltip.tsx +51 -4
- package/components/Viewer.tsx +220 -49
- package/components/ai/AIProviderBar.tsx +11 -0
- package/components/ai/AskSessionSetupHint.tsx +12 -0
- package/components/ai/DocumentAIChatPanel.tsx +17 -5
- package/components/ai/SessionAskNotice.tsx +4 -3
- package/components/blocks/BlockquoteBlock.tsx +159 -0
- package/components/blocks/HtmlBlock.tsx +121 -9
- package/components/blocks/QuestionBlock.tsx +183 -96
- package/components/blocks/QuestionContext.tsx +3 -0
- package/components/diagram/DiagramViewer.tsx +41 -4
- package/components/diagram/index.ts +1 -1
- package/components/html-viewer/HtmlViewer.tsx +95 -3
- package/components/html-viewer/bridge-script.asset.js +413 -7
- package/components/html-viewer/bridge-script.lite.ts +1 -1
- package/components/html-viewer/bridge-script.ts +454 -7
- package/components/html-viewer/useHtmlAnnotation.ts +51 -3
- package/components/icons/AgentIcons.tsx +9 -7
- package/components/plan-diff/PlanCleanDiffView.tsx +239 -36
- package/components/plan-diff/PlanDiffViewer.tsx +6 -1
- package/configure.ts +27 -1
- package/hooks/useAgentJobs.ts +8 -3
- package/hooks/useAnnotationHighlighter.ts +128 -23
- package/hooks/useExternalAnnotations.ts +6 -0
- package/hooks/useMentionAutocomplete.ts +1 -1
- package/hooks/useOpenInApps.ts +88 -0
- package/hooks/useSkillReferenceAutocomplete.ts +7 -3
- package/hooks/useUpdateCheck.ts +38 -98
- package/hooks/useUpdateCheckSetting.ts +43 -0
- package/hooks/useUpdateToast.ts +101 -0
- package/hooks/useVimSelection.ts +9 -0
- package/package.json +2 -2
- package/shortcuts/code-review/markdownPreview.shortcuts.ts +18 -0
- package/shortcuts/index.ts +1 -0
- package/shortcuts/runtime.ts +10 -3
- package/styles.css +1 -1
- package/types.ts +48 -0
- package/utils/agentClosed.ts +26 -0
- package/utils/agentToolSetting.ts +16 -9
- package/utils/aiProvider.ts +7 -0
- package/utils/askSessionSetupHint.ts +27 -0
- package/utils/blockTargeting.ts +14 -3
- package/utils/mentions.ts +34 -5
- package/utils/parser.ts +65 -51
- package/utils/pasteInsert.ts +234 -0
- package/utils/planDiffEngine.ts +272 -2
- package/utils/skillCatalog.ts +15 -2
- package/utils/skillReferences.ts +50 -13
- package/utils/storage.ts +5 -0
- package/utils/textCut.ts +41 -0
- package/utils/updateNotice.ts +120 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plain-text paste into our own text fields, inserted by the page.
|
|
3
|
+
*
|
|
4
|
+
* Works around a Chrome 155 to 156 bug on macOS, and runs only there
|
|
5
|
+
* (`chromiumPasteBugAffected`). Remove after Chrome 157 is the stable channel
|
|
6
|
+
* (it ships the fix).
|
|
7
|
+
*
|
|
8
|
+
* Chrome 155 added a check after the `paste` event (Chromium CL 8217390,
|
|
9
|
+
* commit 4f9d868279, "Ensure default paste behavior doesn't leak changed
|
|
10
|
+
* clipboard content"): the browser's own insertion is skipped when the
|
|
11
|
+
* clipboard's sequence token changed while the event ran. On macOS an older
|
|
12
|
+
* race in ClipboardMac can give the same NSPasteboard changeCount a second
|
|
13
|
+
* token when a paste starts right after an app wrote the pasteboard and
|
|
14
|
+
* posted a synthetic ⌘V. Dictation apps (Hex) and text expanders do exactly
|
|
15
|
+
* that. The `paste` event still carries the right `text/plain`, but
|
|
16
|
+
* `beforeinput` arrives with empty data and nothing is inserted: the first
|
|
17
|
+
* dictation after a page load lands, later ones vanish. Fixed in Chromium
|
|
18
|
+
* main (CL 8503629, commit fc5529f522, crbug 568453825), not before 157.
|
|
19
|
+
*
|
|
20
|
+
* So, for a paste into a plain-text field we own (a textarea or a text-like
|
|
21
|
+
* input) that nothing else handled, that carries no files and has text, the
|
|
22
|
+
* page cancels the browser's insertion and inserts the text itself with
|
|
23
|
+
* `execCommand('insertText')` (keeps undo, fires `input`, so React's onChange
|
|
24
|
+
* runs), else `setRangeText` plus an `input` event. That is what the browser
|
|
25
|
+
* would have done. It is gated to the affected engine: Safari's execCommand
|
|
26
|
+
* merges the insertion with the typing before it in the undo stack, so
|
|
27
|
+
* everywhere else the browser's own paste is kept. Only trusted (real) paste
|
|
28
|
+
* events are acted on.
|
|
29
|
+
*
|
|
30
|
+
* Left native: a paste that a handler already cancelled (image paste to
|
|
31
|
+
* attach), one that carries files (paste-to-attach keeps working; the
|
|
32
|
+
* browser inserts the text beside them), an empty one, a field that is
|
|
33
|
+
* read-only or disabled, one that is not focused, one mid IME composition,
|
|
34
|
+
* contenteditable and code editors (CodeMirror, Pierre's editor, the agent
|
|
35
|
+
* terminal's helper textarea), and a field marked `data-native-paste`.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/** Input types the page can insert into with a caret (email and number have no selection API). */
|
|
39
|
+
const TEXT_INPUT_TYPES = new Set(['text', 'search', 'url', 'tel', 'password']);
|
|
40
|
+
|
|
41
|
+
/** Editors that read the paste themselves through a hidden textarea or own their own model. */
|
|
42
|
+
const NATIVE_PASTE = '[data-native-paste], .xterm, .cm-editor, .monaco-editor';
|
|
43
|
+
|
|
44
|
+
export type PlainTextField = HTMLTextAreaElement | HTMLInputElement;
|
|
45
|
+
|
|
46
|
+
export interface PasteInsertion {
|
|
47
|
+
field: PlainTextField;
|
|
48
|
+
text: string;
|
|
49
|
+
start: number;
|
|
50
|
+
end: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
type PasteLike = Pick<ClipboardEvent, 'defaultPrevented' | 'clipboardData' | 'target'> & {
|
|
54
|
+
composedPath?: () => EventTarget[];
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
function plainTextField(target: EventTarget | null | undefined): PlainTextField | null {
|
|
58
|
+
if (!target || typeof (target as Node).nodeType !== 'number') return null;
|
|
59
|
+
const el = target as Element;
|
|
60
|
+
const view = el.ownerDocument?.defaultView;
|
|
61
|
+
if (!view) return null;
|
|
62
|
+
if (el instanceof view.HTMLTextAreaElement) return el;
|
|
63
|
+
if (el instanceof view.HTMLInputElement && TEXT_INPUT_TYPES.has(el.type)) return el;
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function carriesFiles(data: DataTransfer): boolean {
|
|
68
|
+
if ((data.files?.length ?? 0) > 0) return true;
|
|
69
|
+
for (const item of Array.from(data.items ?? [])) {
|
|
70
|
+
if (item.kind === 'file') return true;
|
|
71
|
+
}
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function focused(field: PlainTextField): boolean {
|
|
76
|
+
const root = field.getRootNode() as Document | ShadowRoot;
|
|
77
|
+
return 'activeElement' in root && root.activeElement === field;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Cut to `room` UTF-16 units without splitting a surrogate pair. */
|
|
81
|
+
function truncate(text: string, room: number): string {
|
|
82
|
+
if (text.length <= room) return text;
|
|
83
|
+
let cut = room;
|
|
84
|
+
const last = text.charCodeAt(cut - 1);
|
|
85
|
+
if (cut > 0 && last >= 0xd800 && last <= 0xdbff) cut -= 1;
|
|
86
|
+
return text.slice(0, cut);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* What the page should insert for this paste, or null to leave it to the
|
|
91
|
+
* browser. Pure apart from reading the event and the field.
|
|
92
|
+
*/
|
|
93
|
+
export function plainTextPasteInsertion(event: PasteLike, composing = false): PasteInsertion | null {
|
|
94
|
+
if (event.defaultPrevented || composing) return null;
|
|
95
|
+
const data = event.clipboardData;
|
|
96
|
+
if (!data) return null;
|
|
97
|
+
const path = event.composedPath?.() ?? [];
|
|
98
|
+
const field = plainTextField(path[0] ?? event.target);
|
|
99
|
+
if (!field) return null;
|
|
100
|
+
if (field.readOnly || field.disabled) return null;
|
|
101
|
+
if (field.closest(NATIVE_PASTE)) return null;
|
|
102
|
+
if (!focused(field)) return null;
|
|
103
|
+
if (carriesFiles(data)) return null;
|
|
104
|
+
let text = data.getData('text/plain');
|
|
105
|
+
if (!text) return null;
|
|
106
|
+
// What Chrome does with line breaks: a textarea keeps each as \n, a one-line input turns each into a space.
|
|
107
|
+
text = text.replace(/\r\n?/g, '\n');
|
|
108
|
+
if (field.tagName !== 'TEXTAREA') text = text.replace(/\n/g, ' ');
|
|
109
|
+
if (!text) return null;
|
|
110
|
+
const start = field.selectionStart;
|
|
111
|
+
const end = field.selectionEnd;
|
|
112
|
+
if (start === null || end === null) return null;
|
|
113
|
+
if (field.maxLength >= 0) {
|
|
114
|
+
const room = field.maxLength - (field.value.length - (end - start));
|
|
115
|
+
if (room <= 0) return null; // the browser inserts nothing either
|
|
116
|
+
text = truncate(text, room);
|
|
117
|
+
if (!text) return null;
|
|
118
|
+
}
|
|
119
|
+
return { field, text, start, end };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Insert as typing would: an undo step and an `input` event. */
|
|
123
|
+
export function insertPastedText({ field, text, start, end }: PasteInsertion): void {
|
|
124
|
+
const doc = field.ownerDocument;
|
|
125
|
+
let inserted = false;
|
|
126
|
+
try {
|
|
127
|
+
inserted = typeof doc.execCommand === 'function' && doc.execCommand('insertText', false, text) === true;
|
|
128
|
+
} catch {
|
|
129
|
+
inserted = false;
|
|
130
|
+
}
|
|
131
|
+
if (inserted) return;
|
|
132
|
+
field.setRangeText(text, start, end, 'end');
|
|
133
|
+
const caret = start + text.length;
|
|
134
|
+
field.setSelectionRange(caret, caret); // some engines put the caret elsewhere after setRangeText
|
|
135
|
+
const view = doc.defaultView;
|
|
136
|
+
const event =
|
|
137
|
+
view && typeof view.InputEvent === 'function'
|
|
138
|
+
? new view.InputEvent('input', { bubbles: true, inputType: 'insertFromPaste', data: text })
|
|
139
|
+
: new Event('input', { bubbles: true });
|
|
140
|
+
field.dispatchEvent(event);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** What the gate reads off `navigator` (User-Agent Client Hints are Chromium-only). */
|
|
144
|
+
export interface PasteEngineNavigator {
|
|
145
|
+
userAgent?: string;
|
|
146
|
+
platform?: string;
|
|
147
|
+
userAgentData?: { brands?: ReadonlyArray<{ brand: string; version: string }>; platform?: string } | null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The Chromium majors with the bug: 155 added the check, 157 ships the fix. */
|
|
151
|
+
const AFFECTED_CHROMIUM = new Set([155, 156]);
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Only Chromium 155 and 156 on macOS have the bug, so only they get the
|
|
155
|
+
* workaround: it turns itself off when 157 arrives, and Safari, Firefox and
|
|
156
|
+
* older Chromium (VS Code's webview) keep the browser's own paste. Safari
|
|
157
|
+
* matters most: there `execCommand('insertText')` merges with the typing
|
|
158
|
+
* before it, so one ⌘Z after a paste would also erase what was typed.
|
|
159
|
+
* Chrome, Edge, Brave, Arc and other Chromium browsers are told by the
|
|
160
|
+
* "Chromium" brand of User-Agent Client Hints, else by `Chrome/<major>` in
|
|
161
|
+
* the UA string (Safari and Firefox carry no `Chrome/`; Chrome on iOS is
|
|
162
|
+
* WebKit and says `CriOS/`).
|
|
163
|
+
*/
|
|
164
|
+
export function chromiumPasteBugAffected(nav: PasteEngineNavigator | undefined | null): boolean {
|
|
165
|
+
if (!nav) return false;
|
|
166
|
+
const ua = nav.userAgent ?? '';
|
|
167
|
+
const data = nav.userAgentData ?? null;
|
|
168
|
+
let major = Number.NaN;
|
|
169
|
+
const brand = data?.brands?.find((b) => b.brand === 'Chromium');
|
|
170
|
+
if (brand) major = Number.parseInt(brand.version, 10);
|
|
171
|
+
if (Number.isNaN(major)) {
|
|
172
|
+
const match = /Chrome\/(\d+)/.exec(ua);
|
|
173
|
+
if (match) major = Number.parseInt(match[1]!, 10);
|
|
174
|
+
}
|
|
175
|
+
if (!AFFECTED_CHROMIUM.has(major)) return false;
|
|
176
|
+
// Client hints name the platform when present; else navigator.platform or the UA string.
|
|
177
|
+
if (data?.platform) return /^mac/i.test(data.platform);
|
|
178
|
+
return /^mac/i.test(nav.platform ?? '') || /Macintosh|Mac OS X/.test(ua);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Tests (the e2e simulation, in a Chromium that is not 155 or 156) set this
|
|
183
|
+
* to `true` on the window to turn the workaround on whatever the engine.
|
|
184
|
+
*/
|
|
185
|
+
export const FORCE_PLAIN_TEXT_PASTE_FLAG = '__plannotatorForcePlainTextPaste';
|
|
186
|
+
|
|
187
|
+
export interface PlainTextPasteOptions {
|
|
188
|
+
/** Whether the workaround runs. Default: the engine gate, or the window's force flag. */
|
|
189
|
+
enabled?: boolean;
|
|
190
|
+
/** Act on script-dispatched (untrusted) paste events too; for tests. Default false. */
|
|
191
|
+
allowUntrusted?: boolean;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Install on a window; returns the uninstall. Listens in the bubble phase on
|
|
196
|
+
* the window, so every handler on the page (React's, the image-paste ones)
|
|
197
|
+
* runs first and can claim the paste. Does nothing outside the affected
|
|
198
|
+
* engine (`chromiumPasteBugAffected`).
|
|
199
|
+
*/
|
|
200
|
+
export function installPlainTextPaste(win: Window = window, options: PlainTextPasteOptions = {}): () => void {
|
|
201
|
+
const doc = win.document;
|
|
202
|
+
const engine = chromiumPasteBugAffected(win.navigator as unknown as PasteEngineNavigator);
|
|
203
|
+
const forced = () => (win as unknown as Record<string, unknown>)[FORCE_PLAIN_TEXT_PASTE_FLAG] === true;
|
|
204
|
+
const enabled = () => options.enabled ?? (engine || forced());
|
|
205
|
+
let composing = false;
|
|
206
|
+
const onCompositionStart = () => {
|
|
207
|
+
composing = true;
|
|
208
|
+
};
|
|
209
|
+
// Also on focus moving and the window losing focus: a compositionend that never arrives must not
|
|
210
|
+
// leave the workaround off until reload.
|
|
211
|
+
const onCompositionEnd = () => {
|
|
212
|
+
composing = false;
|
|
213
|
+
};
|
|
214
|
+
const onPaste = (event: ClipboardEvent) => {
|
|
215
|
+
if (!enabled()) return;
|
|
216
|
+
if (!event.isTrusted && !options.allowUntrusted) return; // a script's paste event inserts nothing
|
|
217
|
+
const insertion = plainTextPasteInsertion(event, composing);
|
|
218
|
+
if (!insertion) return;
|
|
219
|
+
event.preventDefault();
|
|
220
|
+
insertPastedText(insertion);
|
|
221
|
+
};
|
|
222
|
+
doc.addEventListener('compositionstart', onCompositionStart, true);
|
|
223
|
+
doc.addEventListener('compositionend', onCompositionEnd, true);
|
|
224
|
+
doc.addEventListener('focusin', onCompositionEnd, true);
|
|
225
|
+
win.addEventListener('blur', onCompositionEnd);
|
|
226
|
+
win.addEventListener('paste', onPaste);
|
|
227
|
+
return () => {
|
|
228
|
+
doc.removeEventListener('compositionstart', onCompositionStart, true);
|
|
229
|
+
doc.removeEventListener('compositionend', onCompositionEnd, true);
|
|
230
|
+
doc.removeEventListener('focusin', onCompositionEnd, true);
|
|
231
|
+
win.removeEventListener('blur', onCompositionEnd);
|
|
232
|
+
win.removeEventListener('paste', onPaste);
|
|
233
|
+
};
|
|
234
|
+
}
|
package/utils/planDiffEngine.ts
CHANGED
|
@@ -43,6 +43,17 @@ export interface PlanDiffBlock {
|
|
|
43
43
|
inlineTokens?: InlineDiffToken[];
|
|
44
44
|
/** Structural wrap metadata paired with inlineTokens. */
|
|
45
45
|
inlineWrap?: InlineDiffWrap;
|
|
46
|
+
/**
|
|
47
|
+
* Source line range of this block in the OLD text: 1-based, inclusive,
|
|
48
|
+
* trimmed of leading and trailing blank lines. Absent when the block has
|
|
49
|
+
* no non-blank old line (added blocks, or an all-blank chunk). Filled by
|
|
50
|
+
* `computePlanDiff`; plan review ignores it.
|
|
51
|
+
*/
|
|
52
|
+
oldStart?: number;
|
|
53
|
+
oldEnd?: number;
|
|
54
|
+
/** Same as `oldStart`/`oldEnd`, in the NEW text (absent on removed blocks). */
|
|
55
|
+
newStart?: number;
|
|
56
|
+
newEnd?: number;
|
|
46
57
|
}
|
|
47
58
|
|
|
48
59
|
export interface PlanDiffStats {
|
|
@@ -63,6 +74,35 @@ function countLines(text: string): number {
|
|
|
63
74
|
return lines.length;
|
|
64
75
|
}
|
|
65
76
|
|
|
77
|
+
/**
|
|
78
|
+
* The trimmed line range a chunk occupies when its first line is `start`
|
|
79
|
+
* (1-based): leading and trailing blank lines are not counted. Empty when the
|
|
80
|
+
* chunk has no non-blank line.
|
|
81
|
+
*/
|
|
82
|
+
function trimmedLineRange(
|
|
83
|
+
value: string,
|
|
84
|
+
start: number
|
|
85
|
+
): { start?: number; end?: number } {
|
|
86
|
+
const lines = value.split("\n");
|
|
87
|
+
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
88
|
+
let first = 0;
|
|
89
|
+
while (first < lines.length && lines[first].trim() === "") first++;
|
|
90
|
+
if (first >= lines.length) return {};
|
|
91
|
+
let last = lines.length - 1;
|
|
92
|
+
while (last > first && lines[last].trim() === "") last--;
|
|
93
|
+
return { start: start + first, end: start + last };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function oldRange(value: string, start: number): Pick<PlanDiffBlock, "oldStart" | "oldEnd"> {
|
|
97
|
+
const r = trimmedLineRange(value, start);
|
|
98
|
+
return r.start === undefined ? {} : { oldStart: r.start, oldEnd: r.end };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function newRange(value: string, start: number): Pick<PlanDiffBlock, "newStart" | "newEnd"> {
|
|
102
|
+
const r = trimmedLineRange(value, start);
|
|
103
|
+
return r.start === undefined ? {} : { newStart: r.start, newEnd: r.end };
|
|
104
|
+
}
|
|
105
|
+
|
|
66
106
|
const INLINE_DIFFABLE_TYPES = new Set<Block["type"]>([
|
|
67
107
|
"paragraph",
|
|
68
108
|
"heading",
|
|
@@ -213,6 +253,34 @@ function substituteFencedBlocks(
|
|
|
213
253
|
});
|
|
214
254
|
}
|
|
215
255
|
|
|
256
|
+
// A quote (a run of `>` lines) that holds a code fence is one block in the
|
|
257
|
+
// rendered plan, and each diff chunk renders as its own quote. Diffed line
|
|
258
|
+
// by line, an edit inside the quoted fence split it into chunks holding half
|
|
259
|
+
// a fence each, so one chunk opened a fence it never closed and rendered the
|
|
260
|
+
// prose after it as code. The whole run is therefore one atomic unit, like
|
|
261
|
+
// an unquoted fence. Quotes without a fence keep line-level diffing. Runs
|
|
262
|
+
// after substituteFencedBlocks, so a `>` line inside an unquoted fence is
|
|
263
|
+
// already behind its sentinel and never starts a run.
|
|
264
|
+
const QUOTE_RUN_PATTERN = /^[ \t]*>[^\n]*(?:\n[ \t]*>[^\n]*)*/gm;
|
|
265
|
+
const QUOTED_FENCE_LINE = /^[ \t]*(?:>[ \t]?)+[ \t]*`{3,}/m;
|
|
266
|
+
|
|
267
|
+
function substituteQuotedFenceRuns(
|
|
268
|
+
text: string,
|
|
269
|
+
fenceMap: Map<string, string>,
|
|
270
|
+
fenceToId: Map<string, number>
|
|
271
|
+
): string {
|
|
272
|
+
return text.replace(QUOTE_RUN_PATTERN, (match) => {
|
|
273
|
+
if (!QUOTED_FENCE_LINE.test(match)) return match;
|
|
274
|
+
let id = fenceToId.get(match);
|
|
275
|
+
if (id === undefined) {
|
|
276
|
+
id = fenceToId.size;
|
|
277
|
+
fenceMap.set(fenceSentinelFor(id), match);
|
|
278
|
+
fenceToId.set(match, id);
|
|
279
|
+
}
|
|
280
|
+
return fenceSentinelFor(id);
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
216
284
|
function restoreFencedBlocks(
|
|
217
285
|
value: string,
|
|
218
286
|
fenceMap: Map<string, string>
|
|
@@ -530,8 +598,16 @@ export function computePlanDiff(
|
|
|
530
598
|
// FENCE_BLOCK_PATTERN for the failure this prevents.
|
|
531
599
|
const fenceMap = new Map<string, string>();
|
|
532
600
|
const fenceToId = new Map<string, number>();
|
|
533
|
-
const substOld =
|
|
534
|
-
|
|
601
|
+
const substOld = substituteQuotedFenceRuns(
|
|
602
|
+
substituteFencedBlocks(oldText, fenceMap, fenceToId),
|
|
603
|
+
fenceMap,
|
|
604
|
+
fenceToId,
|
|
605
|
+
);
|
|
606
|
+
const substNew = substituteQuotedFenceRuns(
|
|
607
|
+
substituteFencedBlocks(newText, fenceMap, fenceToId),
|
|
608
|
+
fenceMap,
|
|
609
|
+
fenceToId,
|
|
610
|
+
);
|
|
535
611
|
|
|
536
612
|
const rawChanges: Change[] = diffLines(substOld, substNew);
|
|
537
613
|
// Restore the fenced-block content on each change value before the
|
|
@@ -544,6 +620,10 @@ export function computePlanDiff(
|
|
|
544
620
|
|
|
545
621
|
const blocks: PlanDiffBlock[] = [];
|
|
546
622
|
const stats: PlanDiffStats = { additions: 0, deletions: 0, modifications: 0 };
|
|
623
|
+
// First line (1-based) of the next chunk on each side; the restored chunk
|
|
624
|
+
// values hold the real source lines, so counting them keeps both in step.
|
|
625
|
+
let oldLine = 1;
|
|
626
|
+
let newLine = 1;
|
|
547
627
|
|
|
548
628
|
for (let i = 0; i < changes.length; i++) {
|
|
549
629
|
const change = changes[i];
|
|
@@ -558,7 +638,11 @@ export function computePlanDiff(
|
|
|
558
638
|
oldContent: change.value,
|
|
559
639
|
lines: countLines(next.value),
|
|
560
640
|
...(inline ? { inlineTokens: inline.tokens, inlineWrap: inline.wrap } : {}),
|
|
641
|
+
...oldRange(change.value, oldLine),
|
|
642
|
+
...newRange(next.value, newLine),
|
|
561
643
|
});
|
|
644
|
+
oldLine += countLines(change.value);
|
|
645
|
+
newLine += countLines(next.value);
|
|
562
646
|
stats.modifications++;
|
|
563
647
|
stats.additions += countLines(next.value);
|
|
564
648
|
stats.deletions += countLines(change.value);
|
|
@@ -568,23 +652,209 @@ export function computePlanDiff(
|
|
|
568
652
|
type: "added",
|
|
569
653
|
content: change.value,
|
|
570
654
|
lines: countLines(change.value),
|
|
655
|
+
...newRange(change.value, newLine),
|
|
571
656
|
});
|
|
657
|
+
newLine += countLines(change.value);
|
|
572
658
|
stats.additions += countLines(change.value);
|
|
573
659
|
} else if (change.removed) {
|
|
574
660
|
blocks.push({
|
|
575
661
|
type: "removed",
|
|
576
662
|
content: change.value,
|
|
577
663
|
lines: countLines(change.value),
|
|
664
|
+
...oldRange(change.value, oldLine),
|
|
578
665
|
});
|
|
666
|
+
oldLine += countLines(change.value);
|
|
579
667
|
stats.deletions += countLines(change.value);
|
|
580
668
|
} else {
|
|
581
669
|
blocks.push({
|
|
582
670
|
type: "unchanged",
|
|
583
671
|
content: change.value,
|
|
584
672
|
lines: countLines(change.value),
|
|
673
|
+
...oldRange(change.value, oldLine),
|
|
674
|
+
...newRange(change.value, newLine),
|
|
585
675
|
});
|
|
676
|
+
oldLine += countLines(change.value);
|
|
677
|
+
newLine += countLines(change.value);
|
|
586
678
|
}
|
|
587
679
|
}
|
|
588
680
|
|
|
589
681
|
return { blocks, stats };
|
|
590
682
|
}
|
|
683
|
+
|
|
684
|
+
// --- Folding unchanged runs (code review's rendered Markdown diff) ---------
|
|
685
|
+
|
|
686
|
+
/** A run of unchanged sections hidden behind one row. */
|
|
687
|
+
export interface PlanDiffFold {
|
|
688
|
+
kind: "fold";
|
|
689
|
+
/** Stable for the same diff: `fold-<block index>-<first hidden piece>`. */
|
|
690
|
+
key: string;
|
|
691
|
+
/** The hidden sections, each an 'unchanged' block with its own ranges. */
|
|
692
|
+
pieces: PlanDiffBlock[];
|
|
693
|
+
/** How many sections are hidden (`pieces.length`). */
|
|
694
|
+
sectionCount: number;
|
|
695
|
+
/** Plain text of the hidden headings, at most three. */
|
|
696
|
+
headings: string[];
|
|
697
|
+
oldStart?: number;
|
|
698
|
+
oldEnd?: number;
|
|
699
|
+
newStart?: number;
|
|
700
|
+
newEnd?: number;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
export type PlanDiffSegment =
|
|
704
|
+
| { kind: "block"; block: PlanDiffBlock; index: number }
|
|
705
|
+
| PlanDiffFold;
|
|
706
|
+
|
|
707
|
+
const MAX_FOLD_HEADINGS = 3;
|
|
708
|
+
|
|
709
|
+
/** Heading text without inline markdown markers. */
|
|
710
|
+
function plainHeadingText(text: string): string {
|
|
711
|
+
return text
|
|
712
|
+
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
|
|
713
|
+
.replace(/[*_`~]/g, "")
|
|
714
|
+
.replace(/\s+/g, " ")
|
|
715
|
+
.trim();
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
interface UnchangedPiece {
|
|
719
|
+
piece: PlanDiffBlock;
|
|
720
|
+
heading?: string;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* Split an unchanged chunk into one piece per markdown block: each piece is
|
|
725
|
+
* the exact source text from its block's first line up to the next block's
|
|
726
|
+
* (blank lines stay with the piece before them; anything before the first
|
|
727
|
+
* block, such as frontmatter, stays with the first piece). Concatenating the
|
|
728
|
+
* pieces gives the chunk back byte for byte.
|
|
729
|
+
*/
|
|
730
|
+
function splitUnchangedChunk(block: PlanDiffBlock): UnchangedPiece[] {
|
|
731
|
+
const content = block.content;
|
|
732
|
+
const parsed = parseMarkdownToBlocks(content);
|
|
733
|
+
if (parsed.length <= 1) {
|
|
734
|
+
const only = parsed[0];
|
|
735
|
+
return [only?.type === "heading" ? { piece: block, heading: plainHeadingText(only.content) } : { piece: block }];
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// Character offset of the start of each 1-based line.
|
|
739
|
+
const lineOffsets: number[] = [0];
|
|
740
|
+
for (let i = 0; i < content.length; i++) {
|
|
741
|
+
if (content[i] === "\n") lineOffsets.push(i + 1);
|
|
742
|
+
}
|
|
743
|
+
const lineCount = countLines(content);
|
|
744
|
+
const starts = Array.from(
|
|
745
|
+
new Set(parsed.map((b) => b.startLine).filter((line) => line >= 1 && line <= lineCount))
|
|
746
|
+
).sort((a, b) => a - b);
|
|
747
|
+
if (starts.length === 0) return [{ piece: block }];
|
|
748
|
+
starts[0] = 1;
|
|
749
|
+
|
|
750
|
+
// The chunk's untrimmed first line on each side (its ranges are trimmed).
|
|
751
|
+
const contentLines = content.split("\n");
|
|
752
|
+
let leadingBlank = 0;
|
|
753
|
+
while (leadingBlank < contentLines.length && contentLines[leadingBlank].trim() === "") leadingBlank++;
|
|
754
|
+
const oldBase = block.oldStart !== undefined ? block.oldStart - leadingBlank : undefined;
|
|
755
|
+
const newBase = block.newStart !== undefined ? block.newStart - leadingBlank : undefined;
|
|
756
|
+
|
|
757
|
+
const headingByStart = new Map<number, string>();
|
|
758
|
+
for (const b of parsed) {
|
|
759
|
+
if (b.type === "heading" && !headingByStart.has(b.startLine)) {
|
|
760
|
+
headingByStart.set(b.startLine, plainHeadingText(b.content));
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
return starts.map((startLine, i) => {
|
|
765
|
+
const nextStart = starts[i + 1];
|
|
766
|
+
const from = lineOffsets[startLine - 1] ?? content.length;
|
|
767
|
+
const to = nextStart !== undefined ? lineOffsets[nextStart - 1] ?? content.length : content.length;
|
|
768
|
+
const pieceContent = content.slice(from, to);
|
|
769
|
+
const piece: PlanDiffBlock = {
|
|
770
|
+
type: "unchanged",
|
|
771
|
+
content: pieceContent,
|
|
772
|
+
lines: countLines(pieceContent),
|
|
773
|
+
...(oldBase !== undefined ? oldRange(pieceContent, oldBase + startLine - 1) : {}),
|
|
774
|
+
...(newBase !== undefined ? newRange(pieceContent, newBase + startLine - 1) : {}),
|
|
775
|
+
};
|
|
776
|
+
// A heading piece begins at the heading itself (i > 0), or the first
|
|
777
|
+
// piece's parsed block is the heading.
|
|
778
|
+
const heading = headingByStart.get(i === 0 ? parsed[0].startLine : startLine);
|
|
779
|
+
return heading !== undefined ? { piece, heading } : { piece };
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/** The span of several pieces' ranges on each side. */
|
|
784
|
+
function spanOf(pieces: PlanDiffBlock[]): Pick<PlanDiffBlock, "oldStart" | "oldEnd" | "newStart" | "newEnd"> {
|
|
785
|
+
const withOld = pieces.filter((p) => p.oldStart !== undefined);
|
|
786
|
+
const withNew = pieces.filter((p) => p.newStart !== undefined);
|
|
787
|
+
return {
|
|
788
|
+
...(withOld.length > 0
|
|
789
|
+
? { oldStart: withOld[0].oldStart, oldEnd: withOld[withOld.length - 1].oldEnd }
|
|
790
|
+
: {}),
|
|
791
|
+
...(withNew.length > 0
|
|
792
|
+
? { newStart: withNew[0].newStart, newEnd: withNew[withNew.length - 1].newEnd }
|
|
793
|
+
: {}),
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/** Consecutive pieces as one unchanged block (kept together so list numbering stays continuous). */
|
|
798
|
+
function joinPieces(pieces: PlanDiffBlock[]): PlanDiffBlock {
|
|
799
|
+
if (pieces.length === 1) return pieces[0];
|
|
800
|
+
const content = pieces.map((p) => p.content).join("");
|
|
801
|
+
return { type: "unchanged", content, lines: countLines(content), ...spanOf(pieces) };
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* Fold long runs of unchanged sections behind one row, the way a code diff
|
|
806
|
+
* folds unmodified lines. Changed blocks pass through. Each unchanged block
|
|
807
|
+
* keeps `context` sections next to each neighbouring change visible (none
|
|
808
|
+
* toward the start or end of the document) and folds the rest when at least
|
|
809
|
+
* `minHidden` would be hidden; otherwise it is shown whole, untouched.
|
|
810
|
+
*
|
|
811
|
+
* Pure: the same blocks always give the same segments and fold keys.
|
|
812
|
+
*/
|
|
813
|
+
export function foldUnchangedPlanDiff(
|
|
814
|
+
blocks: PlanDiffBlock[],
|
|
815
|
+
options: { context?: number; minHidden?: number } = {}
|
|
816
|
+
): PlanDiffSegment[] {
|
|
817
|
+
const context = Math.max(0, options.context ?? 1);
|
|
818
|
+
const minHidden = Math.max(1, options.minHidden ?? 2);
|
|
819
|
+
const segments: PlanDiffSegment[] = [];
|
|
820
|
+
|
|
821
|
+
blocks.forEach((block, index) => {
|
|
822
|
+
if (block.type !== "unchanged") {
|
|
823
|
+
segments.push({ kind: "block", block, index });
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
const pieces = splitUnchangedChunk(block);
|
|
827
|
+
const head = index > 0 ? Math.min(context, pieces.length) : 0;
|
|
828
|
+
const tail = index < blocks.length - 1 ? Math.min(context, pieces.length - head) : 0;
|
|
829
|
+
const hidden = pieces.length - head - tail;
|
|
830
|
+
if (hidden < minHidden) {
|
|
831
|
+
segments.push({ kind: "block", block, index });
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
if (head > 0) {
|
|
835
|
+
segments.push({ kind: "block", block: joinPieces(pieces.slice(0, head).map((p) => p.piece)), index });
|
|
836
|
+
}
|
|
837
|
+
const folded = pieces.slice(head, head + hidden);
|
|
838
|
+
const foldPieces = folded.map((p) => p.piece);
|
|
839
|
+
segments.push({
|
|
840
|
+
kind: "fold",
|
|
841
|
+
key: `fold-${index}-${head}`,
|
|
842
|
+
pieces: foldPieces,
|
|
843
|
+
sectionCount: foldPieces.length,
|
|
844
|
+
headings: folded
|
|
845
|
+
.map((p) => p.heading)
|
|
846
|
+
.filter((h): h is string => !!h)
|
|
847
|
+
.slice(0, MAX_FOLD_HEADINGS),
|
|
848
|
+
...spanOf(foldPieces),
|
|
849
|
+
});
|
|
850
|
+
if (tail > 0) {
|
|
851
|
+
segments.push({
|
|
852
|
+
kind: "block",
|
|
853
|
+
block: joinPieces(pieces.slice(head + hidden).map((p) => p.piece)),
|
|
854
|
+
index,
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
return segments;
|
|
860
|
+
}
|
package/utils/skillCatalog.ts
CHANGED
|
@@ -81,6 +81,18 @@ async function requestCatalog(): Promise<SkillCatalogEntry[]> {
|
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
// Whether a fetched catalog is registered for the per-comment feedback export
|
|
85
|
+
// (skillReferenceExportBlock). Default on: Plannotator's exporters list each
|
|
86
|
+
// comment's references. A host that composes ONE block per message itself
|
|
87
|
+
// (the Plannotator Inbox) turns it off, so a comment's `$name` stays plain
|
|
88
|
+
// text in its export instead of carrying a second "Skills referenced" list.
|
|
89
|
+
let exportRegistration = true;
|
|
90
|
+
|
|
91
|
+
export function setSkillCatalogExportRegistration(on: boolean): void {
|
|
92
|
+
exportRegistration = on;
|
|
93
|
+
if (!on) setSkillCatalogForExport([]);
|
|
94
|
+
}
|
|
95
|
+
|
|
84
96
|
let cached: { at: number; skills: SkillCatalogEntry[] } | null = null;
|
|
85
97
|
let inflight: Promise<SkillCatalogEntry[]> | null = null;
|
|
86
98
|
// Bumped by resetSkillCatalogCache. A request that resolves after a reset (or
|
|
@@ -111,7 +123,7 @@ export function fetchSkillCatalog(): Promise<SkillCatalogEntry[]> {
|
|
|
111
123
|
} else {
|
|
112
124
|
cached = { at: Date.now(), skills: cached.skills };
|
|
113
125
|
}
|
|
114
|
-
setSkillCatalogForExport(cached.skills);
|
|
126
|
+
if (exportRegistration) setSkillCatalogForExport(cached.skills);
|
|
115
127
|
return cached.skills;
|
|
116
128
|
});
|
|
117
129
|
inflight = promise;
|
|
@@ -176,7 +188,8 @@ export function resetSkillContentTransport(): void {
|
|
|
176
188
|
contentTransport = defaultContentTransport;
|
|
177
189
|
}
|
|
178
190
|
|
|
179
|
-
|
|
191
|
+
/** A raw `{ content, truncated, dir, path }` record as an export content, or null when any part is missing. */
|
|
192
|
+
export function normalizeSkillContent(raw: unknown): SkillExportContent | null {
|
|
180
193
|
if (!raw || typeof raw !== 'object') return null;
|
|
181
194
|
const { content, truncated, dir, path } = raw as Record<string, unknown>;
|
|
182
195
|
if (typeof content !== 'string' || !content) return null;
|