@plannotator/ui 0.49.0 → 0.51.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/HANDOFF.md +202 -1
- package/LICENSE +191 -0
- package/README.md +23 -0
- package/components/AISettingsTab.tsx +33 -1
- package/components/AgentToolAnnouncementDialog.tsx +347 -0
- package/components/AgentsTab.tsx +9 -9
- package/components/AskSessionAnnouncementDialog.tsx +329 -0
- package/components/CommentPopover.tsx +32 -18
- package/components/DecisionControl.tsx +2 -7
- package/components/DiagramBlock.tsx +46 -4
- package/components/DiffFileTree.tsx +363 -0
- package/components/DocBadges.tsx +12 -8
- package/components/ImageLightbox.tsx +66 -0
- package/components/PlanHeaderMenu.tsx +1 -1
- package/components/ProviderIcons.tsx +15 -3
- package/components/Settings.tsx +73 -1
- package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
- package/components/Viewer.tsx +5 -28
- package/components/ai/AIProviderBar.tsx +20 -3
- package/components/ai/DocumentAIChatPanel.tsx +23 -3
- package/components/ai/SessionAskNotice.tsx +101 -0
- package/components/blocks/QuestionBlock.tsx +24 -19
- package/components/blocks/QuestionContext.tsx +81 -0
- package/components/diagram/DiagramComposer.tsx +54 -3
- package/components/diagram/DiagramViewer.tsx +31 -2
- package/components/diagram/index.ts +1 -1
- package/components/html-viewer/HtmlViewer.tsx +13 -3
- package/components/html-viewer/bridge-script.asset.js +209 -16
- package/components/html-viewer/bridge-script.ts +209 -16
- package/components/html-viewer/useHtmlAnnotation.ts +36 -55
- package/configure.ts +12 -0
- package/hooks/useAIChat.ts +96 -10
- package/hooks/useAIProviderActivation.ts +13 -0
- package/hooks/useAIProviderConfig.ts +32 -14
- package/hooks/useActiveSection.ts +3 -2
- package/hooks/useAgentToolSetting.ts +33 -0
- package/hooks/useAnnotationDraft.ts +53 -7
- package/hooks/useAnnotationHighlighter.ts +101 -5
- package/hooks/useDiffFileTreeExpansion.ts +92 -0
- package/hooks/useExternalAnnotations.ts +21 -1
- package/hooks/useFirstRunAnnouncementWindow.ts +114 -0
- package/hooks/useLatchedTrue.ts +15 -0
- package/hooks/useLinkedDoc.ts +64 -4
- package/hooks/useModelCatalogs.ts +9 -2
- package/package.json +3 -2
- package/styles.css +1 -1
- package/themes/gruvbox.css +6 -0
- package/types.ts +17 -0
- package/utils/agentClosed.ts +33 -0
- package/utils/agentToolAnnouncement.ts +109 -0
- package/utils/agentToolSetting.ts +108 -0
- package/utils/aiPrompt.ts +31 -0
- package/utils/aiProvider.ts +41 -0
- package/utils/annotationOwnsHighlight.ts +27 -0
- package/utils/annotationScope.ts +15 -0
- package/utils/askSessionAnnouncement.ts +107 -0
- package/utils/blockTargeting.ts +61 -2
- package/utils/diagramAskAI.ts +161 -0
- package/utils/diffFileTree.ts +203 -0
- package/utils/htmlLinkNavigation.ts +87 -3
- package/utils/parser.ts +236 -110
- package/utils/questionAnswers.ts +3 -1
- package/utils/quickLabels.ts +2 -2
- package/utils/syntaxTheme.ts +33 -0
package/utils/blockTargeting.ts
CHANGED
|
@@ -17,6 +17,10 @@ const SKIP_SELECTORS = [
|
|
|
17
17
|
].join(',');
|
|
18
18
|
|
|
19
19
|
const INLINE_TARGET_SELECTOR = 'strong,em,a,code:not(.pn-code)';
|
|
20
|
+
/** A `:::question` card (`QuestionBlock`): one block whose text is in parts. */
|
|
21
|
+
const QUESTION_CARD_SELECTOR = 'fieldset.question-block';
|
|
22
|
+
/** The card's text parts: the prompt, the context, and each choice's text. */
|
|
23
|
+
const QUESTION_PART_SELECTOR = '[data-question-part]';
|
|
20
24
|
const TABLE_EDGE_ZONE = 22;
|
|
21
25
|
|
|
22
26
|
/** The semantic kind of a document target. */
|
|
@@ -133,10 +137,22 @@ function groupLabel(group: HTMLElement): string {
|
|
|
133
137
|
}
|
|
134
138
|
|
|
135
139
|
function listContentElement(block: HTMLElement): HTMLElement | null {
|
|
140
|
+
// A question card also starts with a `select-none` child (its eyebrow), so
|
|
141
|
+
// without this its second child, the prompt, stood in for the whole card:
|
|
142
|
+
// every pinpoint hover or click inside it resolved to the prompt.
|
|
143
|
+
if (block.matches(QUESTION_CARD_SELECTOR)) return null;
|
|
136
144
|
if (!block.querySelector('.select-none')) return null;
|
|
137
145
|
return block.children[1] instanceof HTMLElement ? block.children[1] : null;
|
|
138
146
|
}
|
|
139
147
|
|
|
148
|
+
function questionPartLabel(part: HTMLElement): string {
|
|
149
|
+
const text = truncate(part.textContent?.trim() ?? '', 30);
|
|
150
|
+
const kind = part.dataset.questionPart;
|
|
151
|
+
if (kind === 'choice') return `choice: "${text}"`;
|
|
152
|
+
if (kind === 'context') return `context: "${text}"`;
|
|
153
|
+
return `question: "${text}"`;
|
|
154
|
+
}
|
|
155
|
+
|
|
140
156
|
function addInlineTargets(
|
|
141
157
|
targets: SemanticTarget[],
|
|
142
158
|
byElement: Map<HTMLElement, SemanticTarget>,
|
|
@@ -199,6 +215,38 @@ export function buildSemanticTargetGraph(container: HTMLElement): SemanticTarget
|
|
|
199
215
|
|
|
200
216
|
const group = block.closest<HTMLElement>('[data-pinpoint-group]');
|
|
201
217
|
const parentKey = group ? groupTargets.get(group)?.key ?? null : null;
|
|
218
|
+
|
|
219
|
+
// First: a card's context can hold a table, a code fence or a formula,
|
|
220
|
+
// and the card is still one question block made of its parts.
|
|
221
|
+
if (block.matches(QUESTION_CARD_SELECTOR)) {
|
|
222
|
+
const prompt = block.querySelector<HTMLElement>('[data-question-part="prompt"]');
|
|
223
|
+
const cardTarget: SemanticTarget = {
|
|
224
|
+
key: `${blockId}:block`,
|
|
225
|
+
blockId,
|
|
226
|
+
element: block,
|
|
227
|
+
label: prompt ? questionPartLabel(prompt) : 'question',
|
|
228
|
+
kind: 'block',
|
|
229
|
+
parentKey,
|
|
230
|
+
};
|
|
231
|
+
targets.push(cardTarget);
|
|
232
|
+
byElement.set(block, cardTarget);
|
|
233
|
+
blockKeys.push(cardTarget.key);
|
|
234
|
+
block.querySelectorAll<HTMLElement>(QUESTION_PART_SELECTOR).forEach((part, index) => {
|
|
235
|
+
const partTarget: SemanticTarget = {
|
|
236
|
+
key: `${blockId}:part:${index}`,
|
|
237
|
+
blockId,
|
|
238
|
+
element: part,
|
|
239
|
+
label: questionPartLabel(part),
|
|
240
|
+
kind: 'inline',
|
|
241
|
+
parentKey: cardTarget.key,
|
|
242
|
+
};
|
|
243
|
+
targets.push(partTarget);
|
|
244
|
+
byElement.set(part, partTarget);
|
|
245
|
+
addInlineTargets(targets, byElement, blockId, partTarget, part, partTarget.key);
|
|
246
|
+
});
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
|
|
202
250
|
const codeElement = block.querySelector<HTMLElement>('pre > code.pn-code');
|
|
203
251
|
const mathElement = block.matches('.math-annotatable,[data-math-tex]')
|
|
204
252
|
? block
|
|
@@ -455,8 +503,11 @@ export function resolveSemanticTargetAtPoint(
|
|
|
455
503
|
if (!block || !graph.container.contains(block) || block.tagName === 'HR') return null;
|
|
456
504
|
const blockTarget = targetForBlock(graph, block);
|
|
457
505
|
if (!blockTarget) return null;
|
|
506
|
+
// A question card's code fences and tables live in its context part; the
|
|
507
|
+
// part lookup below resolves them.
|
|
508
|
+
const card = block.matches(QUESTION_CARD_SELECTOR);
|
|
458
509
|
|
|
459
|
-
const code = block.querySelector<HTMLElement>('pre > code.pn-code');
|
|
510
|
+
const code = card ? null : block.querySelector<HTMLElement>('pre > code.pn-code');
|
|
460
511
|
if (
|
|
461
512
|
code
|
|
462
513
|
&& (pointerTarget === code || code.contains(pointerTarget) || pointerTarget.closest('pre'))
|
|
@@ -464,7 +515,7 @@ export function resolveSemanticTargetAtPoint(
|
|
|
464
515
|
return blockTarget;
|
|
465
516
|
}
|
|
466
517
|
|
|
467
|
-
const table = block.querySelector<HTMLTableElement>('table');
|
|
518
|
+
const table = card ? null : block.querySelector<HTMLTableElement>('table');
|
|
468
519
|
if (table && pointer) {
|
|
469
520
|
const rect = table.getBoundingClientRect();
|
|
470
521
|
const nearHorizontalEdge = pointer.clientX - rect.left < TABLE_EDGE_ZONE
|
|
@@ -484,6 +535,14 @@ export function resolveSemanticTargetAtPoint(
|
|
|
484
535
|
if (inlineTarget) return inlineTarget;
|
|
485
536
|
}
|
|
486
537
|
|
|
538
|
+
// Inside a question card: the part under the pointer. A choice row's
|
|
539
|
+
// padding and marker belong to that choice's text.
|
|
540
|
+
const part = pointerTarget.closest<HTMLElement>(QUESTION_PART_SELECTOR)
|
|
541
|
+
?? pointerTarget.closest<HTMLElement>('[data-question-option]')?.querySelector<HTMLElement>(QUESTION_PART_SELECTOR);
|
|
542
|
+
if (part && block.contains(part)) {
|
|
543
|
+
return graph.byElement.get(part) ?? blockTarget;
|
|
544
|
+
}
|
|
545
|
+
|
|
487
546
|
const cell = pointerTarget.closest<HTMLTableCellElement>('td,th');
|
|
488
547
|
if (cell && block.contains(cell)) {
|
|
489
548
|
return graph.byElement.get(cell) ?? blockTarget;
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import {
|
|
2
|
+
diagramAnchorLocationLine,
|
|
3
|
+
diagramTargetName,
|
|
4
|
+
diagramTargetText,
|
|
5
|
+
type DiagramAnchor,
|
|
6
|
+
type DiagramKind,
|
|
7
|
+
type DiagramTarget,
|
|
8
|
+
} from '@plannotator/core/diagram-anchor';
|
|
9
|
+
import type { CommentAskAIContext } from '../components/CommentPopover';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Ask AI from a diagram comment: the question rides the same
|
|
13
|
+
* `CommentAskAIContext` the markdown and html composers send, so the host's
|
|
14
|
+
* one Ask AI path (an SDK provider, "Ask this session", the agent terminal)
|
|
15
|
+
* carries it unchanged. `text` is the part's label (what the chat shows under
|
|
16
|
+
* the question); `detail` is the agent-facing identity, which
|
|
17
|
+
* `buildDefaultPrompt` puts on the USER message before the question, never in
|
|
18
|
+
* the system prompt:
|
|
19
|
+
*
|
|
20
|
+
* Diagram node Approve? (D), line 7 <- the export's location line
|
|
21
|
+
* Diagram type: Mermaid (flowchart)
|
|
22
|
+
* Diagram source, document lines 6–9:
|
|
23
|
+
* ````mermaid
|
|
24
|
+
* …
|
|
25
|
+
* ````
|
|
26
|
+
*
|
|
27
|
+
* The source is bounded (a window around the part when the diagram is long),
|
|
28
|
+
* so a huge whole-file diagram never floods a turn.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const ENGINE_LABELS: Record<DiagramKind, string> = { mermaid: 'Mermaid', graphviz: 'Graphviz' };
|
|
32
|
+
const FENCE_LANGUAGE: Record<DiagramKind, string> = { mermaid: 'mermaid', graphviz: 'dot' };
|
|
33
|
+
|
|
34
|
+
/** Most diagram source lines a question carries. */
|
|
35
|
+
export const MAX_ASK_AI_DIAGRAM_SOURCE_LINES = 40;
|
|
36
|
+
/** Most diagram source characters a question carries. */
|
|
37
|
+
export const MAX_ASK_AI_DIAGRAM_SOURCE_CHARS = 4000;
|
|
38
|
+
|
|
39
|
+
export interface DiagramAskAIInput {
|
|
40
|
+
readonly kind: DiagramKind;
|
|
41
|
+
/** The part the question is about; `sourceLine` names DOCUMENT lines. */
|
|
42
|
+
readonly anchor: DiagramAnchor;
|
|
43
|
+
/** Shift-click targets the draft also covers (absent or empty for most). */
|
|
44
|
+
readonly additionalTargets?: readonly DiagramTarget[];
|
|
45
|
+
/** The diagram text (the fence body, or the whole file). */
|
|
46
|
+
readonly source: string;
|
|
47
|
+
/** Lines to add to a diagram line to name the document line: the fence's
|
|
48
|
+
* opening line for a fence, 0 when the document is the diagram. */
|
|
49
|
+
readonly sourceLineOffset: number;
|
|
50
|
+
/** The document the diagram lives in, as the other composers send it. */
|
|
51
|
+
readonly sourcePath?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A fence that no backtick run in the source can close early. */
|
|
55
|
+
function fenceFor(source: string): string {
|
|
56
|
+
let longest = 0;
|
|
57
|
+
for (const run of source.match(/`+/g) ?? []) longest = Math.max(longest, run.length);
|
|
58
|
+
return '`'.repeat(Math.max(4, longest + 1));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A line longer than the whole budget is cut, with a visible marker. */
|
|
62
|
+
const TRUNCATION_MARKER = '…';
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The bounded slice of the source a question carries, grown outward from the
|
|
66
|
+
* part's line (the top of the diagram when the part has no line), one line
|
|
67
|
+
* below then one above, until the line cap or the character cap stops a
|
|
68
|
+
* side. The part's own line is always in it; a part line longer than the
|
|
69
|
+
* whole character budget is cut with a visible marker, so the slice never
|
|
70
|
+
* exceeds MAX_ASK_AI_DIAGRAM_SOURCE_CHARS (newlines included). Returns
|
|
71
|
+
* 1-based DIAGRAM lines and the text to quote.
|
|
72
|
+
*/
|
|
73
|
+
function sourceWindow(
|
|
74
|
+
lines: readonly string[],
|
|
75
|
+
anchor: DiagramAnchor,
|
|
76
|
+
offset: number,
|
|
77
|
+
): { first: number; last: number; body: string; excerpt: boolean } | null {
|
|
78
|
+
let wholeFirst = 1;
|
|
79
|
+
let wholeLast = lines.length;
|
|
80
|
+
while (wholeLast > 0 && (lines[wholeLast - 1] ?? '').trim() === '') wholeLast -= 1;
|
|
81
|
+
while (wholeFirst <= wholeLast && (lines[wholeFirst - 1] ?? '').trim() === '') wholeFirst += 1;
|
|
82
|
+
if (wholeLast === 0 || wholeFirst > wholeLast) return null;
|
|
83
|
+
|
|
84
|
+
const partLine = anchor.kind !== 'diagram' && anchor.sourceLine !== null ? anchor.sourceLine[0] - offset : null;
|
|
85
|
+
const center = partLine !== null && partLine >= wholeFirst && partLine <= wholeLast ? partLine : wholeFirst;
|
|
86
|
+
|
|
87
|
+
let centerText = lines[center - 1] ?? '';
|
|
88
|
+
let truncated = false;
|
|
89
|
+
if (centerText.length > MAX_ASK_AI_DIAGRAM_SOURCE_CHARS) {
|
|
90
|
+
centerText = centerText.slice(0, MAX_ASK_AI_DIAGRAM_SOURCE_CHARS - TRUNCATION_MARKER.length) + TRUNCATION_MARKER;
|
|
91
|
+
truncated = true;
|
|
92
|
+
}
|
|
93
|
+
let chars = centerText.length;
|
|
94
|
+
let count = 1;
|
|
95
|
+
let first = center;
|
|
96
|
+
let last = center;
|
|
97
|
+
let growDown = true;
|
|
98
|
+
let growUp = true;
|
|
99
|
+
while ((growDown || growUp) && count < MAX_ASK_AI_DIAGRAM_SOURCE_LINES) {
|
|
100
|
+
if (growDown) {
|
|
101
|
+
const next = last + 1;
|
|
102
|
+
const cost = (lines[next - 1] ?? '').length + 1;
|
|
103
|
+
if (next > wholeLast || chars + cost > MAX_ASK_AI_DIAGRAM_SOURCE_CHARS) growDown = false;
|
|
104
|
+
else {
|
|
105
|
+
last = next;
|
|
106
|
+
chars += cost;
|
|
107
|
+
count += 1;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (growUp && count < MAX_ASK_AI_DIAGRAM_SOURCE_LINES) {
|
|
111
|
+
const next = first - 1;
|
|
112
|
+
const cost = (lines[next - 1] ?? '').length + 1;
|
|
113
|
+
if (next < wholeFirst || chars + cost > MAX_ASK_AI_DIAGRAM_SOURCE_CHARS) growUp = false;
|
|
114
|
+
else {
|
|
115
|
+
first = next;
|
|
116
|
+
chars += cost;
|
|
117
|
+
count += 1;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const body = [...lines.slice(first - 1, center - 1), centerText, ...lines.slice(center, last)].join('\n');
|
|
122
|
+
return { first, last, body, excerpt: truncated || first > wholeFirst || last < wholeLast };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The agent-facing identity of a diagram part (the `detail` Ask AI sends). */
|
|
126
|
+
export function diagramIdentityForAskAI(input: DiagramAskAIInput): string {
|
|
127
|
+
const { kind, anchor, source, sourceLineOffset } = input;
|
|
128
|
+
const out: string[] = [diagramAnchorLocationLine(anchor)];
|
|
129
|
+
for (const extra of input.additionalTargets ?? []) {
|
|
130
|
+
const text = diagramTargetText(extra);
|
|
131
|
+
out.push(`Also: ${diagramTargetName(extra)}${text !== '' && text !== extra.id ? ` "${text}"` : ''}`);
|
|
132
|
+
}
|
|
133
|
+
out.push(`Diagram type: ${ENGINE_LABELS[kind]} (${anchor.family})`);
|
|
134
|
+
|
|
135
|
+
const lines = source.split('\n');
|
|
136
|
+
const window = sourceWindow(lines, anchor, sourceLineOffset);
|
|
137
|
+
if (window !== null) {
|
|
138
|
+
const body = window.body;
|
|
139
|
+
const docFirst = window.first + sourceLineOffset;
|
|
140
|
+
const docLast = window.last + sourceLineOffset;
|
|
141
|
+
const range = docLast > docFirst ? `lines ${docFirst}–${docLast}` : `line ${docFirst}`;
|
|
142
|
+
const partial = window.excerpt ? ' (excerpt)' : '';
|
|
143
|
+
const fence = fenceFor(body);
|
|
144
|
+
out.push(`Diagram source${partial}, document ${range}:`);
|
|
145
|
+
out.push(`${fence}${FENCE_LANGUAGE[kind]}\n${body}\n${fence}`);
|
|
146
|
+
}
|
|
147
|
+
return out.join('\n');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The Ask AI context for a question asked from a diagram comment. */
|
|
151
|
+
export function diagramAskAIContext(input: DiagramAskAIInput): CommentAskAIContext {
|
|
152
|
+
const { kind, anchor } = input;
|
|
153
|
+
const engine = ENGINE_LABELS[kind];
|
|
154
|
+
return {
|
|
155
|
+
kind: 'selection',
|
|
156
|
+
label: anchor.kind === 'diagram' ? `${engine} diagram` : `${engine} diagram ${diagramTargetName(anchor)}`,
|
|
157
|
+
text: diagramTargetText(anchor),
|
|
158
|
+
...(input.sourcePath !== undefined ? { sourcePath: input.sourcePath } : {}),
|
|
159
|
+
detail: diagramIdentityForAskAI(input),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure tree builder for a list of changed files (a diff), shared by
|
|
3
|
+
* Plannotator's code-review file tree (`packages/review-editor`) and the
|
|
4
|
+
* embeddable `DiffFileTree` component. Browser-safe, no React.
|
|
5
|
+
*
|
|
6
|
+
* Shape rules (the review's, unchanged when this moved here):
|
|
7
|
+
* - folders before files at every level, each group sorted by name
|
|
8
|
+
* (`localeCompare`);
|
|
9
|
+
* - a folder whose only child is a folder is merged into it
|
|
10
|
+
* (`packages/app/src`), recursively;
|
|
11
|
+
* - when the whole tree is one root folder holding only files, that folder is
|
|
12
|
+
* unwrapped and the files sit at the root;
|
|
13
|
+
* - a folder's +/- counts are the sums of everything under it.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Change status a tree row can show. 'binary' is a host-facing extra; the
|
|
17
|
+
* review's own parser never produces it. */
|
|
18
|
+
export type DiffFileTreeStatus = 'added' | 'modified' | 'deleted' | 'renamed' | 'binary';
|
|
19
|
+
|
|
20
|
+
/** The minimum a file needs to sit in the tree. */
|
|
21
|
+
export interface DiffFileTreeFile {
|
|
22
|
+
path: string;
|
|
23
|
+
oldPath?: string;
|
|
24
|
+
status: DiffFileTreeStatus;
|
|
25
|
+
additions: number;
|
|
26
|
+
deletions: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface DiffFileTreeNode<F extends DiffFileTreeFile = DiffFileTreeFile> {
|
|
30
|
+
type: 'file' | 'folder';
|
|
31
|
+
/** Display name: the file name, or the (possibly merged) folder segment(s). */
|
|
32
|
+
name: string;
|
|
33
|
+
/** File: the file's own path. Folder: the full folder path from the root. */
|
|
34
|
+
path: string;
|
|
35
|
+
depth: number;
|
|
36
|
+
/** Index of the file in the input array (file nodes only). */
|
|
37
|
+
fileIndex?: number;
|
|
38
|
+
file?: F;
|
|
39
|
+
children?: DiffFileTreeNode<F>[];
|
|
40
|
+
additions: number;
|
|
41
|
+
deletions: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface TrieNode<F> {
|
|
45
|
+
children: Map<string, TrieNode<F>>;
|
|
46
|
+
file?: { index: number; data: F };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function buildTrie<F extends DiffFileTreeFile>(files: readonly F[]): TrieNode<F> {
|
|
50
|
+
const root: TrieNode<F> = { children: new Map() };
|
|
51
|
+
|
|
52
|
+
for (let i = 0; i < files.length; i++) {
|
|
53
|
+
const segments = files[i].path.split('/').filter(Boolean);
|
|
54
|
+
let current = root;
|
|
55
|
+
|
|
56
|
+
for (let j = 0; j < segments.length - 1; j++) {
|
|
57
|
+
if (!current.children.has(segments[j])) {
|
|
58
|
+
current.children.set(segments[j], { children: new Map() });
|
|
59
|
+
}
|
|
60
|
+
current = current.children.get(segments[j])!;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const fileName = segments[segments.length - 1];
|
|
64
|
+
const leaf: TrieNode<F> = { children: new Map(), file: { index: i, data: files[i] } };
|
|
65
|
+
current.children.set(fileName, leaf);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return root;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function trieToNodes<F extends DiffFileTreeFile>(
|
|
72
|
+
trie: TrieNode<F>,
|
|
73
|
+
parentPath: string,
|
|
74
|
+
depth: number,
|
|
75
|
+
): DiffFileTreeNode<F>[] {
|
|
76
|
+
const folders: DiffFileTreeNode<F>[] = [];
|
|
77
|
+
const fileNodes: DiffFileTreeNode<F>[] = [];
|
|
78
|
+
|
|
79
|
+
for (const [name, child] of trie.children) {
|
|
80
|
+
const fullPath = parentPath ? `${parentPath}/${name}` : name;
|
|
81
|
+
|
|
82
|
+
if (child.file) {
|
|
83
|
+
fileNodes.push({
|
|
84
|
+
type: 'file',
|
|
85
|
+
name,
|
|
86
|
+
path: child.file.data.path,
|
|
87
|
+
depth,
|
|
88
|
+
fileIndex: child.file.index,
|
|
89
|
+
file: child.file.data,
|
|
90
|
+
additions: child.file.data.additions,
|
|
91
|
+
deletions: child.file.data.deletions,
|
|
92
|
+
});
|
|
93
|
+
} else {
|
|
94
|
+
const children = trieToNodes(child, fullPath, depth + 1);
|
|
95
|
+
const additions = children.reduce((s, c) => s + c.additions, 0);
|
|
96
|
+
const deletions = children.reduce((s, c) => s + c.deletions, 0);
|
|
97
|
+
|
|
98
|
+
folders.push({
|
|
99
|
+
type: 'folder',
|
|
100
|
+
name,
|
|
101
|
+
path: fullPath,
|
|
102
|
+
depth,
|
|
103
|
+
children,
|
|
104
|
+
additions,
|
|
105
|
+
deletions,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
folders.sort((a, b) => a.name.localeCompare(b.name));
|
|
111
|
+
fileNodes.sort((a, b) => a.name.localeCompare(b.name));
|
|
112
|
+
|
|
113
|
+
return [...folders, ...fileNodes];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function collapseSingleChild<F extends DiffFileTreeFile>(nodes: DiffFileTreeNode<F>[]): DiffFileTreeNode<F>[] {
|
|
117
|
+
return nodes.map(node => {
|
|
118
|
+
if (node.type !== 'folder' || !node.children) return node;
|
|
119
|
+
|
|
120
|
+
let current = node;
|
|
121
|
+
while (
|
|
122
|
+
current.children &&
|
|
123
|
+
current.children.length === 1 &&
|
|
124
|
+
current.children[0].type === 'folder'
|
|
125
|
+
) {
|
|
126
|
+
const child = current.children[0];
|
|
127
|
+
current = {
|
|
128
|
+
...child,
|
|
129
|
+
name: `${current.name}/${child.name}`,
|
|
130
|
+
depth: node.depth,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
...current,
|
|
136
|
+
children: current.children ? collapseSingleChild(fixDepths(current.children, node.depth + 1)) : undefined,
|
|
137
|
+
};
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function fixDepths<F extends DiffFileTreeFile>(nodes: DiffFileTreeNode<F>[], depth: number): DiffFileTreeNode<F>[] {
|
|
142
|
+
return nodes.map(node => ({
|
|
143
|
+
...node,
|
|
144
|
+
depth,
|
|
145
|
+
children: node.children ? fixDepths(node.children, depth + 1) : undefined,
|
|
146
|
+
}));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function buildDiffFileTree<F extends DiffFileTreeFile>(files: readonly F[]): DiffFileTreeNode<F>[] {
|
|
150
|
+
if (files.length === 0) return [];
|
|
151
|
+
|
|
152
|
+
const trie = buildTrie(files);
|
|
153
|
+
let tree = trieToNodes(trie, '', 0);
|
|
154
|
+
tree = collapseSingleChild(tree);
|
|
155
|
+
|
|
156
|
+
// Flat fallback: if the tree is a single root folder with only file children, unwrap it
|
|
157
|
+
if (
|
|
158
|
+
tree.length === 1 &&
|
|
159
|
+
tree[0].type === 'folder' &&
|
|
160
|
+
tree[0].children?.every(c => c.type === 'file')
|
|
161
|
+
) {
|
|
162
|
+
return fixDepths(tree[0].children!, 0);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return tree;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Every proper ancestor folder path of a file path (`a/b/c.ts` → `a`, `a/b`).
|
|
169
|
+
* A merged folder's path is one of these, so expanding them all reveals the file. */
|
|
170
|
+
export function getAncestorPaths(filePath: string): string[] {
|
|
171
|
+
const segments = filePath.split('/').filter(Boolean);
|
|
172
|
+
const paths: string[] = [];
|
|
173
|
+
for (let i = 1; i < segments.length; i++) {
|
|
174
|
+
paths.push(segments.slice(0, i).join('/'));
|
|
175
|
+
}
|
|
176
|
+
return paths;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** File indexes in the order the tree renders them (ignores collapse state). */
|
|
180
|
+
export function getVisualFileOrder<F extends DiffFileTreeFile>(nodes: DiffFileTreeNode<F>[]): number[] {
|
|
181
|
+
const order: number[] = [];
|
|
182
|
+
for (const node of nodes) {
|
|
183
|
+
if (node.type === 'file' && node.fileIndex != null) {
|
|
184
|
+
order.push(node.fileIndex);
|
|
185
|
+
} else if (node.children) {
|
|
186
|
+
order.push(...getVisualFileOrder(node.children));
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return order;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function getAllFolderPaths<F extends DiffFileTreeFile>(nodes: DiffFileTreeNode<F>[]): string[] {
|
|
193
|
+
const paths: string[] = [];
|
|
194
|
+
for (const node of nodes) {
|
|
195
|
+
if (node.type === 'folder') {
|
|
196
|
+
paths.push(node.path);
|
|
197
|
+
if (node.children) {
|
|
198
|
+
paths.push(...getAllFolderPaths(node.children));
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return paths;
|
|
203
|
+
}
|
|
@@ -12,8 +12,16 @@
|
|
|
12
12
|
* server origin and the directories, so every branch is unit-testable.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import { isReviewImagePath } from "@plannotator/core/diff-paths";
|
|
15
16
|
import { hasLinkedDocExtension } from "./markdownExtensions";
|
|
16
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Route the servers serve a raw-HTML page's support assets from. Spelled here
|
|
20
|
+
* rather than imported because `@plannotator/shared/html-assets` is node-side
|
|
21
|
+
* (parse5, `path`); `htmlLinkNavigation.test.ts` pins the two together.
|
|
22
|
+
*/
|
|
23
|
+
export const HTML_ASSET_ROUTE_PREFIX = "/api/html-assets";
|
|
24
|
+
|
|
17
25
|
/** Longest href the parent will look at. Matches the bridge's own cap. */
|
|
18
26
|
export const MAX_HTML_LINK_HREF_LENGTH = 2048;
|
|
19
27
|
|
|
@@ -27,11 +35,29 @@ export type HtmlLinkIntent =
|
|
|
27
35
|
| { kind: "document"; path: string; hash: string; rendersHtml: boolean }
|
|
28
36
|
/** Another origin. Opens in a new tab; the frame never navigates. */
|
|
29
37
|
| { kind: "external"; url: string }
|
|
30
|
-
/**
|
|
31
|
-
|
|
38
|
+
/**
|
|
39
|
+
* A local image inside the page's asset root, shown in the image lightbox.
|
|
40
|
+
* `url` is the page's own `/api/html-assets/<token>/…` route, so the
|
|
41
|
+
* lightbox reads exactly what the page itself can already load.
|
|
42
|
+
*/
|
|
43
|
+
| { kind: "image"; path: string; url: string; label: string }
|
|
44
|
+
/**
|
|
45
|
+
* A local file Plannotator will not open. `type`: not a document or image
|
|
46
|
+
* (`.pdf`, `.zip`, …). `outside-asset-root`: an image outside the page's
|
|
47
|
+
* own folder, which the asset route refuses. `no-asset-root`: an image on
|
|
48
|
+
* a page served without an asset route (share links, converted pages).
|
|
49
|
+
*/
|
|
50
|
+
| {
|
|
51
|
+
kind: "unsupported";
|
|
52
|
+
path: string;
|
|
53
|
+
label: string;
|
|
54
|
+
reason: HtmlLinkUnsupportedReason;
|
|
55
|
+
}
|
|
32
56
|
/** Nothing to do: empty, fragment-only, or a scheme we do not follow. */
|
|
33
57
|
| { kind: "ignored"; reason: HtmlLinkIgnoreReason };
|
|
34
58
|
|
|
59
|
+
export type HtmlLinkUnsupportedReason = "type" | "outside-asset-root" | "no-asset-root";
|
|
60
|
+
|
|
35
61
|
export type HtmlLinkIgnoreReason =
|
|
36
62
|
| "empty"
|
|
37
63
|
| "too-long"
|
|
@@ -55,6 +81,12 @@ export interface HtmlLinkContext {
|
|
|
55
81
|
/** The session's `--markdown` preference: HTML is Turndowned by `/api/doc`,
|
|
56
82
|
* so an `.html` target renders as markdown rather than as an HTML surface. */
|
|
57
83
|
convertHtml?: boolean;
|
|
84
|
+
/**
|
|
85
|
+
* The current page's asset root: the directory its `/api/html-assets`
|
|
86
|
+
* token was minted for, and that token's route (`/api/html-assets/<t>/`).
|
|
87
|
+
* Image links open in the lightbox only from inside `dir`.
|
|
88
|
+
*/
|
|
89
|
+
assetRoot?: { dir: string; url: string } | null;
|
|
58
90
|
}
|
|
59
91
|
|
|
60
92
|
/** Control characters never appear in a real href; they are how structure gets smuggled. */
|
|
@@ -119,11 +151,13 @@ function resolveRelative(
|
|
|
119
151
|
if (!baseDir) return { kind: "ignored", reason: "no-base" };
|
|
120
152
|
const resolved = joinPath(baseDir, decoded);
|
|
121
153
|
if (!resolved) return { kind: "ignored", reason: "invalid" };
|
|
154
|
+
const label = basename(resolved);
|
|
155
|
+
if (isReviewImagePath(resolved)) return resolveImage(resolved, label, context.assetRoot);
|
|
122
156
|
// Containment is the server's call (`/api/doc` answers 403 for an escaping
|
|
123
157
|
// path). The extension gate is ours: a `.pdf` would be a pointless fetch
|
|
124
158
|
// and a confusing server error, so it is reported as unsupported here.
|
|
125
159
|
if (!hasLinkedDocExtension(resolved)) {
|
|
126
|
-
return { kind: "unsupported", path: resolved, label:
|
|
160
|
+
return { kind: "unsupported", path: resolved, label, reason: "type" };
|
|
127
161
|
}
|
|
128
162
|
return {
|
|
129
163
|
kind: "document",
|
|
@@ -133,6 +167,56 @@ function resolveRelative(
|
|
|
133
167
|
};
|
|
134
168
|
}
|
|
135
169
|
|
|
170
|
+
/**
|
|
171
|
+
* An image link opens in the lightbox, read through the page's own asset
|
|
172
|
+
* route. That route serves only files below the token's directory (it refuses
|
|
173
|
+
* `..` and checks the realpath), so an image outside it is reported here
|
|
174
|
+
* instead of becoming a request the server would refuse. This check is
|
|
175
|
+
* lexical and only decides the UI; the server's check is the one that holds.
|
|
176
|
+
*/
|
|
177
|
+
function resolveImage(
|
|
178
|
+
path: string,
|
|
179
|
+
label: string,
|
|
180
|
+
assetRoot: HtmlLinkContext["assetRoot"],
|
|
181
|
+
): HtmlLinkIntent {
|
|
182
|
+
if (!assetRoot?.dir || !assetRoot.url) {
|
|
183
|
+
return { kind: "unsupported", path, label, reason: "no-asset-root" };
|
|
184
|
+
}
|
|
185
|
+
const dir = assetRoot.dir.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
186
|
+
if (!path.startsWith(`${dir}/`)) {
|
|
187
|
+
return { kind: "unsupported", path, label, reason: "outside-asset-root" };
|
|
188
|
+
}
|
|
189
|
+
const relative = path
|
|
190
|
+
.slice(dir.length + 1)
|
|
191
|
+
.split("/")
|
|
192
|
+
.map(encodeURIComponent)
|
|
193
|
+
.join("/");
|
|
194
|
+
const base = assetRoot.url.endsWith("/") ? assetRoot.url : `${assetRoot.url}/`;
|
|
195
|
+
return { kind: "image", path, url: `${base}${relative}`, label };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The asset route a served raw-HTML page is anchored at. Both servers install
|
|
200
|
+
* `<base href="/api/html-assets/<token>/">` first in `<head>` (re-anchoring a
|
|
201
|
+
* relative author base under the same token). Returns that token's route, or
|
|
202
|
+
* null when the page carries none (share links, an author's absolute base).
|
|
203
|
+
*
|
|
204
|
+
* Only the FIRST `<base>` element counts, as in the browser, and comments are
|
|
205
|
+
* skipped: a page cannot name a different token in a comment or a later base
|
|
206
|
+
* tag and have the lightbox read through it.
|
|
207
|
+
*/
|
|
208
|
+
const HTML_COMMENT_PATTERN = new RegExp("<!--[\\s\\S]*?(?:-->|$)", "g");
|
|
209
|
+
|
|
210
|
+
export function htmlAssetRouteFromDocument(rawHtml: string | null | undefined): string | null {
|
|
211
|
+
if (!rawHtml) return null;
|
|
212
|
+
// RegExp constructor, not a literal: Semgrep's TS parser chokes on `<!--` in a regex literal.
|
|
213
|
+
const withoutComments = rawHtml.replace(HTML_COMMENT_PATTERN, "");
|
|
214
|
+
const baseTag = /<base\b[^>]*>/i.exec(withoutComments);
|
|
215
|
+
if (!baseTag) return null;
|
|
216
|
+
const match = /\bhref\s*=\s*["']?\/api\/html-assets\/([A-Za-z0-9_-]+)\//i.exec(baseTag[0]);
|
|
217
|
+
return match ? `${HTML_ASSET_ROUTE_PREFIX}/${match[1]}/` : null;
|
|
218
|
+
}
|
|
219
|
+
|
|
136
220
|
/**
|
|
137
221
|
* Whether opening `path` lands on another raw-HTML surface rather than a
|
|
138
222
|
* markdown one. `--markdown` sessions convert HTML on the way in, so nothing
|