@plannotator/ui 0.47.0 → 0.50.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 +147 -0
- package/LICENSE +191 -0
- package/README.md +56 -1
- package/components/AISettingsTab.tsx +1 -1
- package/components/AnnotationPanel.tsx +44 -3
- package/components/BlockRenderer.tsx +55 -1
- package/components/CompletionOverlay.tsx +23 -1
- package/components/DiffFileTree.tsx +363 -0
- package/components/ImageLightbox.tsx +66 -0
- package/components/PRPlatformIcon.tsx +22 -0
- package/components/ProviderIcons.tsx +15 -3
- package/components/QuestionProgressChip.tsx +66 -0
- package/components/QuestionsPanelSection.tsx +121 -0
- package/components/Settings.tsx +25 -1
- package/components/Viewer.tsx +170 -34
- package/components/ai/AIProviderBar.tsx +5 -5
- package/components/ai/DocumentAIChatPanel.tsx +28 -3
- package/components/ai/SessionAskNotice.tsx +89 -0
- package/components/blocks/QuestionBlock.tsx +591 -0
- package/components/html-viewer/bridge-script.asset.js +85 -8
- package/components/html-viewer/bridge-script.ts +85 -8
- package/config/settings.ts +17 -0
- package/configure.ts +12 -0
- package/hooks/useAIChat.ts +74 -9
- package/hooks/useAIProviderConfig.ts +32 -14
- package/hooks/useAnnotationHighlighter.ts +4 -0
- package/hooks/useDiffFileTreeExpansion.ts +92 -0
- package/hooks/usePinpoint.ts +10 -0
- package/hooks/useScrollKeyRouting.ts +157 -0
- package/hooks/useUndoHistory.ts +17 -0
- package/hooks/useVimDocumentFocus.ts +2 -1
- package/package.json +3 -2
- package/styles.css +1 -1
- package/types.ts +7 -0
- package/utils/aiPrompt.ts +31 -0
- package/utils/aiProvider.ts +68 -0
- package/utils/autoUpdateNotice.ts +59 -0
- package/utils/diffFileTree.ts +203 -0
- package/utils/htmlLinkNavigation.ts +87 -3
- package/utils/parser.ts +125 -563
- package/utils/questionAnswers.ts +227 -0
- package/utils/syntaxTheme.ts +33 -0
package/types.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { DiagramAnchor } from '@plannotator/core/diagram-anchor';
|
|
2
2
|
import type { DiagramRenderKind } from '@plannotator/core/annotatable';
|
|
3
|
+
import type { QuestionAnswer } from '@plannotator/core/question-block';
|
|
3
4
|
|
|
4
5
|
export type { DiagramAnchor } from '@plannotator/core/diagram-anchor';
|
|
6
|
+
export type { QuestionAnswer } from '@plannotator/core/question-block';
|
|
5
7
|
export type { DiagramRenderKind } from '@plannotator/core/annotatable';
|
|
6
8
|
|
|
7
9
|
/**
|
|
@@ -100,6 +102,7 @@ export interface Annotation {
|
|
|
100
102
|
elementContext?: HtmlElementContext; // raw-HTML / live-app pinpoint: bounded agent-facing description of the primary element (never used by restore)
|
|
101
103
|
htmlAdditionalTargets?: HtmlAnnotationTarget[]; // raw-HTML shift-click multi-select: extra elements this one comment covers (primary stays htmlAnchor/originalText)
|
|
102
104
|
diagramAnchor?: DiagramAnchor; // a comment on a rendered diagram part (Mermaid / Graphviz fence): the part's own id, label and document source line; the highlighter skips it and the diagram overlay restores it (see @plannotator/core/diagram-anchor)
|
|
105
|
+
questionAnswer?: QuestionAnswer; // the reviewer's answer to a `:::question` block (id `ann-question-<key>`): the Viewer draws the answer from it, the highlighter skips it, and the export prints it in the "Answers to your questions" section instead of as a numbered comment (see @plannotator/core/question-block)
|
|
103
106
|
// web-highlighter metadata for cross-element selections
|
|
104
107
|
startMeta?: AnnotationTextMeta;
|
|
105
108
|
endMeta?: AnnotationTextMeta;
|
|
@@ -459,6 +462,10 @@ export interface AIResponse {
|
|
|
459
462
|
text: string;
|
|
460
463
|
isStreaming: boolean;
|
|
461
464
|
error?: string;
|
|
465
|
+
/** Server error code with `error` (e.g. `agent_busy`, `session_gone` from "Ask this session"). */
|
|
466
|
+
errorCode?: string;
|
|
467
|
+
/** "Ask this session": the question is waiting for a busy session, or interrupting it. */
|
|
468
|
+
status?: 'waiting' | 'interrupting';
|
|
462
469
|
createdAt: number;
|
|
463
470
|
}
|
|
464
471
|
|
package/utils/aiPrompt.ts
CHANGED
|
@@ -27,3 +27,34 @@ export function buildReviewContextPreamble(
|
|
|
27
27
|
? '[Still reviewing the same changes shown earlier in this conversation.]'
|
|
28
28
|
: ctx;
|
|
29
29
|
}
|
|
30
|
+
|
|
31
|
+
const MAX_IDENTITY_FILES = 30;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The code-review context for "Ask this session". The agent that opened the
|
|
35
|
+
* review wrote (or can read) the changes, so it gets the diff's identity —
|
|
36
|
+
* which comparison, against which base, which files — and never the patch
|
|
37
|
+
* itself, which would land permanently in its context window.
|
|
38
|
+
*/
|
|
39
|
+
export function buildSessionReviewIdentity(review: {
|
|
40
|
+
diffType?: string | null;
|
|
41
|
+
base?: string | null;
|
|
42
|
+
patch?: string | null;
|
|
43
|
+
}): string {
|
|
44
|
+
const files: string[] = [];
|
|
45
|
+
for (const match of (review.patch ?? '').matchAll(/^diff --git a\/.+? b\/(.+)$/gm)) {
|
|
46
|
+
files.push(match[1]);
|
|
47
|
+
}
|
|
48
|
+
const lines: string[] = [];
|
|
49
|
+
if (review.diffType) {
|
|
50
|
+
lines.push(`Diff on screen: ${review.diffType}${review.base ? ` (against ${review.base})` : ''}`);
|
|
51
|
+
} else if (review.base) {
|
|
52
|
+
lines.push(`Diff on screen: against ${review.base}`);
|
|
53
|
+
}
|
|
54
|
+
if (files.length > 0) {
|
|
55
|
+
const shown = files.slice(0, MAX_IDENTITY_FILES).join(', ');
|
|
56
|
+
const more = files.length > MAX_IDENTITY_FILES ? ` (+${files.length - MAX_IDENTITY_FILES} more)` : '';
|
|
57
|
+
lines.push(`Changed files (${files.length}): ${shown}${more}`);
|
|
58
|
+
}
|
|
59
|
+
return lines.join('\n');
|
|
60
|
+
}
|
package/utils/aiProvider.ts
CHANGED
|
@@ -25,6 +25,45 @@ export interface AIProviderOption {
|
|
|
25
25
|
modelsSource?: 'fallback' | 'discovered';
|
|
26
26
|
/** The installed CLI's version, when the server reports it. */
|
|
27
27
|
toolVersion?: string;
|
|
28
|
+
/** Display label overriding the name lookup (e.g. "Ask this session · Pi"). */
|
|
29
|
+
label?: string;
|
|
30
|
+
/** Present only on the "Ask this session" provider: the host session's live status. */
|
|
31
|
+
sessionBridge?: SessionBridgeInfo;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** "Ask this session": Ask AI answered by the agent session that opened Plannotator. */
|
|
35
|
+
export const SESSION_BRIDGE_PROVIDER_NAME = 'session-bridge';
|
|
36
|
+
|
|
37
|
+
export interface SessionBridgeInfo {
|
|
38
|
+
host: string;
|
|
39
|
+
status: 'ready' | 'busy' | 'blocked' | 'gone';
|
|
40
|
+
modes: { turn: boolean; transient: boolean };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Error codes "Ask this session" answers with that the panel turns into actions. */
|
|
44
|
+
export const SESSION_ASK_ERROR_CODES = {
|
|
45
|
+
agentBusy: 'agent_busy',
|
|
46
|
+
blocked: 'session_blocked',
|
|
47
|
+
gone: 'session_gone',
|
|
48
|
+
} as const;
|
|
49
|
+
|
|
50
|
+
export function isSessionBridgeProvider(provider: Pick<AIProviderOption, 'name' | 'sessionBridge'> | null | undefined): boolean {
|
|
51
|
+
return !!provider && (provider.name === SESSION_BRIDGE_PROVIDER_NAME || !!provider.sessionBridge);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The "Ask this session" provider when it can answer here: present, the
|
|
56
|
+
* session not gone, and not blocked unless it offers a transient answer.
|
|
57
|
+
*/
|
|
58
|
+
export function findUsableSessionBridge(providers: AIProviderOption[]): AIProviderOption | null {
|
|
59
|
+
for (const provider of providers) {
|
|
60
|
+
const bridge = provider.sessionBridge;
|
|
61
|
+
if (!bridge) continue;
|
|
62
|
+
if (bridge.status === 'gone') continue;
|
|
63
|
+
if (bridge.status === 'blocked' && !bridge.modes.transient) continue;
|
|
64
|
+
return provider;
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
28
67
|
}
|
|
29
68
|
|
|
30
69
|
export interface AIProviderSettings {
|
|
@@ -153,8 +192,20 @@ export function resolveAIProviderSelection(options: {
|
|
|
153
192
|
const byId = (id: string | null | undefined) =>
|
|
154
193
|
id ? providers.find(provider => provider.id === id) ?? null : null;
|
|
155
194
|
|
|
195
|
+
// An explicit saved pick wins, then "Ask this session" whenever the host
|
|
196
|
+
// session can answer, then the origin's own SDK provider and the older
|
|
197
|
+
// fallbacks. Without a usable bridge this is exactly the previous order.
|
|
198
|
+
const sessionBridge = findUsableSessionBridge(providers);
|
|
199
|
+
const explicitPick = sessionBridge
|
|
200
|
+
? byId(originHasDedicatedAIProvider(origin)
|
|
201
|
+
? (origin ? settings.providerByOrigin[origin] : null)
|
|
202
|
+
: settings.providerId)
|
|
203
|
+
: null;
|
|
204
|
+
|
|
156
205
|
const provider =
|
|
206
|
+
explicitPick ??
|
|
157
207
|
byId(origin ? settings.providerByOrigin[origin] : null) ??
|
|
208
|
+
sessionBridge ??
|
|
158
209
|
findOriginAIProvider(providers, origin) ??
|
|
159
210
|
byId(settings.providerId) ??
|
|
160
211
|
byId(serverDefaultProvider) ??
|
|
@@ -167,6 +218,23 @@ export function resolveAIProviderSelection(options: {
|
|
|
167
218
|
};
|
|
168
219
|
}
|
|
169
220
|
|
|
221
|
+
/**
|
|
222
|
+
* Where "Ask a separate AI instead" goes when "Ask this session" cannot
|
|
223
|
+
* answer: the selection the app would make if the bridge were not there.
|
|
224
|
+
*/
|
|
225
|
+
export function resolveSessionBridgeFallback(options: {
|
|
226
|
+
providers: AIProviderOption[];
|
|
227
|
+
origin?: Origin | null;
|
|
228
|
+
settings?: AIProviderSettings;
|
|
229
|
+
serverDefaultProvider?: string | null;
|
|
230
|
+
}): AIProviderSelection {
|
|
231
|
+
const providers = options.providers.filter(provider => !isSessionBridgeProvider(provider));
|
|
232
|
+
const serverDefaultProvider = providers.some(provider => provider.id === options.serverDefaultProvider)
|
|
233
|
+
? options.serverDefaultProvider
|
|
234
|
+
: null;
|
|
235
|
+
return resolveAIProviderSelection({ ...options, providers, serverDefaultProvider });
|
|
236
|
+
}
|
|
237
|
+
|
|
170
238
|
export function saveAIProviderSelection(options: {
|
|
171
239
|
providerId: string | null;
|
|
172
240
|
model?: string | null;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-time notice after a background auto-update (#1634).
|
|
3
|
+
*
|
|
4
|
+
* The compiled CLI's servers attach `autoUpdateNotice` to /api/plan and
|
|
5
|
+
* /api/diff when the data dir records an install attempt: `updated` once the
|
|
6
|
+
* running binary is the new version, `failed` when the install script exited
|
|
7
|
+
* non-zero. The notice id is stable per attempt; the "seen" marker is a cookie,
|
|
8
|
+
* which is shared across the random ports sessions run on, so the toast shows
|
|
9
|
+
* once whichever app (plan, annotate, review) loads first.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { getItem, setItem } from './storage';
|
|
13
|
+
|
|
14
|
+
export interface AutoUpdateNotice {
|
|
15
|
+
id: string;
|
|
16
|
+
kind: 'updated' | 'failed';
|
|
17
|
+
version: string;
|
|
18
|
+
releaseUrl: string;
|
|
19
|
+
logPath?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const SEEN_KEY = 'plannotator-auto-update-notice-seen';
|
|
23
|
+
|
|
24
|
+
export function parseAutoUpdateNotice(value: unknown): AutoUpdateNotice | undefined {
|
|
25
|
+
if (!value || typeof value !== 'object') return undefined;
|
|
26
|
+
const v = value as Record<string, unknown>;
|
|
27
|
+
if (typeof v.id !== 'string' || !v.id) return undefined;
|
|
28
|
+
if (v.kind !== 'updated' && v.kind !== 'failed') return undefined;
|
|
29
|
+
if (typeof v.version !== 'string' || typeof v.releaseUrl !== 'string') return undefined;
|
|
30
|
+
if (!/^https:\/\/github\.com\//.test(v.releaseUrl)) return undefined;
|
|
31
|
+
return {
|
|
32
|
+
id: v.id,
|
|
33
|
+
kind: v.kind,
|
|
34
|
+
version: v.version,
|
|
35
|
+
releaseUrl: v.releaseUrl,
|
|
36
|
+
...(typeof v.logPath === 'string' && { logPath: v.logPath }),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* True the first time a given notice is claimed in this browser, false after.
|
|
42
|
+
* Claiming marks it seen, so a caller shows the toast only when this is true.
|
|
43
|
+
*/
|
|
44
|
+
export function claimAutoUpdateNotice(notice: AutoUpdateNotice): boolean {
|
|
45
|
+
if (getItem(SEEN_KEY) === notice.id) return false;
|
|
46
|
+
setItem(SEEN_KEY, notice.id);
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Title and description for the toast. */
|
|
51
|
+
export function describeAutoUpdateNotice(notice: AutoUpdateNotice): { title: string; description: string } {
|
|
52
|
+
if (notice.kind === 'updated') {
|
|
53
|
+
return { title: `Updated to v${notice.version}`, description: 'Plannotator updated itself in the background.' };
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
title: 'Auto-update failed',
|
|
57
|
+
description: notice.logPath ? `See ${notice.logPath}` : `Could not install v${notice.version}.`,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -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
|