@plannotator/ui 0.53.0 → 0.54.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 +284 -27
- package/README.md +84 -14
- package/components/AgentToolAnnouncementDialog.tsx +1 -1
- package/components/AgentsTab.tsx +1 -1
- package/components/AnnotationPanel.tsx +177 -34
- package/components/AnnotationSidebar.tsx +1 -1
- package/components/AnnotationToolbar.tsx +64 -9
- package/components/AnnotationToolstrip.tsx +45 -40
- package/components/ApproveDropdown.tsx +7 -9
- package/components/AskSessionAnnouncementDialog.tsx +1 -1
- package/components/AttachmentsButton.tsx +1 -1
- package/components/BlockRenderer.tsx +19 -5
- package/components/CodeFilePopout.tsx +140 -5
- package/components/CommentPopover.tsx +46 -8
- package/components/DecisionControl.tsx +31 -5
- package/components/DiagramBlock.tsx +17 -12
- package/components/EditorAnnotationCard.tsx +1 -1
- package/components/ExportModal.tsx +77 -7
- package/components/HostTitle.tsx +0 -4
- package/components/HtmlSurfaceControls.tsx +2 -1
- package/components/ImageAnnotator/Toolbar.tsx +25 -1
- package/components/ImageAnnotator/index.tsx +24 -4
- package/components/ImageLightbox.tsx +658 -120
- package/components/ImagePeek.tsx +38 -4
- package/components/ImageRegions.tsx +369 -0
- package/components/ImageThumbnail.tsx +1 -1
- package/components/ImageZoomStage.tsx +796 -0
- package/components/InboxAnnouncementDialog.tsx +202 -0
- package/components/InlineMarkdown.tsx +152 -24
- package/components/KeyboardShortcuts.tsx +23 -0
- package/components/LazyBlockBoundary.tsx +100 -0
- package/components/ListMarker.tsx +3 -3
- package/components/LookAndFeelAnnouncementDialog.tsx +12 -2
- package/components/MentionPicker.tsx +51 -0
- package/components/MenuVersionSection.tsx +31 -9
- package/components/PlanDecisionMenuExtras.tsx +129 -0
- package/components/PlanHeaderMenu.tsx +15 -1
- package/components/ProviderIcons.tsx +8 -0
- package/components/RefHoverCard.tsx +913 -0
- package/components/SendFailureToast.tsx +126 -0
- package/components/Settings.tsx +617 -126
- package/components/SkillReferenceMenu.tsx +39 -12
- package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
- package/components/ThemeProvider.tsx +14 -1
- package/components/ThemeTab.tsx +16 -24
- package/components/Tooltip.tsx +49 -18
- package/components/Viewer.tsx +316 -47
- package/components/ai/DocumentAIChatPanel.tsx +29 -3
- package/components/ai/NewChatButton.tsx +74 -0
- package/components/blocks/CodeBlock.tsx +1 -1
- package/components/blocks/HtmlBlock.tsx +7 -1
- package/components/blocks/QuestionBlock.tsx +522 -62
- package/components/blocks/QuestionContext.tsx +3 -0
- package/components/diagram/DiagramComposer.tsx +5 -1
- package/components/diagram/DiagramPending.tsx +12 -2
- package/components/diagram/DiagramViewer.tsx +19 -3
- package/components/html-embed/HtmlEmbedBlock.tsx +374 -0
- package/components/html-embed/SvgFenceBlock.tsx +46 -0
- package/components/html-embed/index.ts +3 -0
- package/components/html-embed/languages.ts +15 -0
- package/components/html-embed/loadGate.ts +107 -0
- package/components/html-viewer/HtmlViewer.tsx +69 -6
- package/components/html-viewer/bridge-script.asset.js +504 -12
- package/components/html-viewer/bridge-script.lite.ts +1 -1
- package/components/html-viewer/bridge-script.ts +545 -12
- package/components/html-viewer/useHtmlAnnotation.ts +125 -0
- package/components/imageZoom.ts +265 -0
- package/components/plan-diff/PlanCleanDiffView.tsx +1 -1
- package/components/plan-diff/PlanDiffViewer.tsx +13 -2
- package/components/plan-diff/PlanRawDiffView.tsx +1 -1
- package/components/sidebar/FileBrowser.tsx +16 -2
- package/components/sidebar/SidebarTabs.tsx +3 -0
- package/config/index.ts +3 -0
- package/config/reviewView.ts +41 -1
- package/config/settings.ts +155 -4
- package/configure.ts +28 -0
- package/hooks/pfm/useCodeFilePopout.ts +7 -1
- package/hooks/useAIChat.ts +72 -25
- package/hooks/useAnnotationDraft.ts +71 -6
- package/hooks/useAnnotationHighlighter.ts +57 -3
- package/hooks/useCodeAnnotationDraft.ts +113 -17
- package/hooks/useCookieHostSetting.ts +96 -0
- package/hooks/useDocumentFont.ts +17 -0
- package/hooks/useDocumentTextSize.ts +20 -0
- package/hooks/useFileBrowser.ts +10 -1
- package/hooks/useLinkedDoc.ts +226 -13
- package/hooks/useMentionAutocomplete.ts +1 -1
- package/hooks/usePackageChangesSetting.ts +90 -0
- package/hooks/usePlanLookDialog.ts +67 -0
- package/hooks/useSharing.ts +11 -7
- package/hooks/useSkillReferenceAutocomplete.ts +7 -3
- package/hooks/useThemePaletteHalves.ts +37 -0
- package/hooks/useUpdateCheck.ts +22 -4
- package/hooks/useUpdateToast.ts +1 -1
- package/hooks/useVimSelection.ts +9 -0
- package/package.json +23 -22
- package/shortcuts/code-review/annotationToolbar.shortcuts.ts +1 -0
- package/shortcuts/code-review/explorer.shortcuts.ts +82 -0
- package/shortcuts/core.ts +63 -4
- package/shortcuts/index.ts +1 -0
- package/shortcuts/plan-review/annotationPanel.shortcuts.ts +1 -1
- package/shortcuts/plan-review/commentPopover.shortcuts.ts +1 -0
- package/shortcuts/runtime.ts +14 -6
- package/styles.css +1 -1
- package/theme.css +179 -1
- package/themes/plannotator-graphite.css +66 -0
- package/types.ts +26 -9
- package/utils/agentSwitch.ts +41 -20
- package/utils/agentToolSetting.ts +1 -1
- package/utils/aiChatFormat.ts +23 -7
- package/utils/annotationOwnsHighlight.ts +4 -2
- package/utils/asciidocParser.ts +570 -0
- package/utils/autoUpdateNotice.ts +24 -0
- package/utils/belowTitleSlot.ts +174 -0
- package/utils/codeLineScroll.ts +100 -0
- package/utils/commentSubmitKey.ts +48 -0
- package/utils/cssFontFamily.ts +65 -0
- package/utils/decisionSpec.ts +190 -2
- package/utils/defaultNotesApp.ts +1 -1
- package/utils/diffFonts.ts +4 -1
- package/utils/documentFont.ts +68 -0
- package/utils/documentTextSize.ts +54 -0
- package/utils/fontDetect.ts +63 -0
- package/utils/inboxAnnouncement.ts +78 -0
- package/utils/lookAndFeelAnnouncement.ts +82 -5
- package/utils/markdownExtensions.ts +2 -2
- package/utils/mentions.ts +34 -5
- package/utils/mermaid.ts +20 -3
- package/utils/mermaidTheme.ts +3 -51
- package/utils/notion.ts +58 -0
- package/utils/parser.ts +28 -1
- package/utils/platform.ts +46 -0
- package/utils/refCard.ts +82 -0
- package/utils/richClipboard.ts +380 -0
- package/utils/richClipboardLists.ts +57 -0
- package/utils/richCopyPolicy.ts +22 -0
- package/utils/scrollAnchor.ts +82 -0
- package/utils/sectionRefs.ts +123 -0
- package/utils/selectionBoundary.ts +40 -0
- package/utils/sendFailure.ts +85 -0
- package/utils/sharing.ts +5 -2
- package/utils/themeRegistry.ts +10 -0
- package/utils/themeTokens.ts +74 -0
- package/utils/updateNotice.ts +30 -8
- package/utils/vscodePanel.ts +9 -0
|
@@ -9,6 +9,12 @@ import type {
|
|
|
9
9
|
UseAnnotationHighlighterReturn,
|
|
10
10
|
} from "../../hooks/useAnnotationHighlighter";
|
|
11
11
|
import { BRIDGE_PROTOCOL_VERSION } from "./bridge-script";
|
|
12
|
+
import {
|
|
13
|
+
MAX_RICH_CLIPBOARD_HTML_LENGTH,
|
|
14
|
+
MAX_RICH_CLIPBOARD_OUTPUT_LENGTH,
|
|
15
|
+
sanitizeRichHtml,
|
|
16
|
+
type RichCopySource,
|
|
17
|
+
} from "../../utils/richClipboard";
|
|
12
18
|
import {
|
|
13
19
|
parseHtmlElementContext,
|
|
14
20
|
MAX_ELEMENT_CONTEXT_BYTES,
|
|
@@ -90,8 +96,27 @@ interface BridgeSelectionMessage {
|
|
|
90
96
|
targetLabel?: string;
|
|
91
97
|
/** Agent-facing element description (pinpoint clicks) — validated, size-capped. */
|
|
92
98
|
context?: HtmlElementContext;
|
|
99
|
+
/** The bridge answers `rich-copy-request` for this text selection. */
|
|
100
|
+
richCopy?: boolean;
|
|
101
|
+
/** `text` was cut to MAX_SELECTION_TEXT_LENGTH (the page's selection is longer). */
|
|
102
|
+
textCut?: boolean;
|
|
93
103
|
}
|
|
94
104
|
|
|
105
|
+
/** The bridge's answer to `rich-copy-request`: the pending range's markup
|
|
106
|
+
* (sanitized again here) and its text up to 256 KiB (`truncated` past it),
|
|
107
|
+
* or too large for rich text. */
|
|
108
|
+
interface BridgeRichCopyMessage {
|
|
109
|
+
type: `${typeof PREFIX}rich-copy`;
|
|
110
|
+
id: string;
|
|
111
|
+
html: string;
|
|
112
|
+
text: string;
|
|
113
|
+
truncated: boolean;
|
|
114
|
+
tooLarge: boolean;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** How long the rich-text Copy waits for the bridge before copying the quote as plain text. */
|
|
118
|
+
export const RICH_COPY_TIMEOUT_MS = 1500;
|
|
119
|
+
|
|
95
120
|
/** One draft target in an in-flight multi-select comment. Index 0 is primary. */
|
|
96
121
|
export interface HtmlDraftTarget {
|
|
97
122
|
key: string;
|
|
@@ -119,10 +144,12 @@ interface BridgeRect {
|
|
|
119
144
|
|
|
120
145
|
type BridgeMessage =
|
|
121
146
|
| BridgeSelectionMessage
|
|
147
|
+
| BridgeRichCopyMessage
|
|
122
148
|
| BridgeMultiTargetAddedMessage
|
|
123
149
|
| { type: `${typeof PREFIX}multi-target-removed`; key: string }
|
|
124
150
|
| { type: `${typeof PREFIX}pointer`; x: number; y: number; shift: boolean }
|
|
125
151
|
| { type: `${typeof PREFIX}selection-clear` }
|
|
152
|
+
| { type: `${typeof PREFIX}pin-visibility`; visible: boolean }
|
|
126
153
|
| { type: `${typeof PREFIX}selection-rect`; rect: BridgeRect }
|
|
127
154
|
| { type: `${typeof PREFIX}keytype`; key: string }
|
|
128
155
|
| { type: `${typeof PREFIX}mark-click`; id: string }
|
|
@@ -363,6 +390,8 @@ export function parseBridgeMessage(value: unknown): BridgeMessage | null {
|
|
|
363
390
|
return {
|
|
364
391
|
type: value.type,
|
|
365
392
|
text: capSelectionText(value.text),
|
|
393
|
+
...(value.richCopy === true ? { richCopy: true } : {}),
|
|
394
|
+
...(value.textCut === true ? { textCut: true } : {}),
|
|
366
395
|
rect,
|
|
367
396
|
modeOverride: parseEditorMode(value.modeOverride),
|
|
368
397
|
anchor: parseHtmlElementAnchor(value.anchor) ?? undefined,
|
|
@@ -395,6 +424,24 @@ export function parseBridgeMessage(value: unknown): BridgeMessage | null {
|
|
|
395
424
|
: null;
|
|
396
425
|
case `${PREFIX}selection-clear`:
|
|
397
426
|
return { type: value.type };
|
|
427
|
+
case `${PREFIX}rich-copy`: {
|
|
428
|
+
// Oversized or malformed markup reads as none (plain text is copied).
|
|
429
|
+
if (typeof value.id !== "string" || value.id.length === 0 || value.id.length > 64) return null;
|
|
430
|
+
const text = typeof value.text === "string" && value.text.length <= MAX_RICH_CLIPBOARD_HTML_LENGTH
|
|
431
|
+
? value.text : "";
|
|
432
|
+
const html = typeof value.html === "string" && value.html.length <= MAX_RICH_CLIPBOARD_HTML_LENGTH
|
|
433
|
+
? value.html : "";
|
|
434
|
+
return {
|
|
435
|
+
type: value.type,
|
|
436
|
+
id: value.id,
|
|
437
|
+
html,
|
|
438
|
+
text,
|
|
439
|
+
truncated: value.truncated === true,
|
|
440
|
+
tooLarge: value.tooLarge === true,
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
case `${PREFIX}pin-visibility`:
|
|
444
|
+
return typeof value.visible === 'boolean' ? { type: value.type, visible: value.visible } : null;
|
|
398
445
|
case `${PREFIX}selection-rect`: {
|
|
399
446
|
const rect = parseBridgeRect(value.rect);
|
|
400
447
|
return rect ? { type: value.type, rect } : null;
|
|
@@ -474,6 +521,12 @@ export function useHtmlAnnotation({
|
|
|
474
521
|
UseAnnotationHighlighterReturn,
|
|
475
522
|
"highlighterRef" | "highlightRange" | "highlightMathElement"
|
|
476
523
|
> & {
|
|
524
|
+
/** Whether any pinned draft target is still displayed by the page. Always
|
|
525
|
+
* true while no composer is open. */
|
|
526
|
+
pinVisible: boolean;
|
|
527
|
+
/** False once this draft's composer was hidden with its target: shown
|
|
528
|
+
* again, it must not take focus from where the person is. */
|
|
529
|
+
composerAutoFocus: boolean;
|
|
477
530
|
/** In-flight multi-select targets (index 0 = primary); empty outside pinpoint drafts. */
|
|
478
531
|
draftTargets: HtmlDraftTarget[];
|
|
479
532
|
/** Remove one draft target (chip X). Removing the last cancels the draft. */
|
|
@@ -495,6 +548,17 @@ export function useHtmlAnnotation({
|
|
|
495
548
|
const [quickLabelPicker, setQuickLabelPicker] = useState<QuickLabelPickerState | null>(null);
|
|
496
549
|
const [draftTargets, setDraftTargets] = useState<HtmlDraftTarget[]>([]);
|
|
497
550
|
const [composerFocusToken, setComposerFocusToken] = useState(0);
|
|
551
|
+
const [pinVisible, setPinVisible] = useState(true);
|
|
552
|
+
const [composerWasHidden, setComposerWasHidden] = useState(false);
|
|
553
|
+
const pinVisibleRef = useRef(pinVisible);
|
|
554
|
+
pinVisibleRef.current = pinVisible;
|
|
555
|
+
// Visibility belongs to one draft: whatever closes the composer (save, 👍,
|
|
556
|
+
// close, Escape, a cancelled multi-select, disabling) resets it.
|
|
557
|
+
useEffect(() => {
|
|
558
|
+
if (commentPopover) return;
|
|
559
|
+
setPinVisible(true);
|
|
560
|
+
setComposerWasHidden(false);
|
|
561
|
+
}, [commentPopover]);
|
|
498
562
|
|
|
499
563
|
const pendingTextRef = useRef<string>("");
|
|
500
564
|
// Element anchor for the pending pinpoint selection — committed onto the
|
|
@@ -553,6 +617,9 @@ export function useHtmlAnnotation({
|
|
|
553
617
|
hostCapRef.current = maxAdditionalTargets !== undefined;
|
|
554
618
|
|
|
555
619
|
const anchorRef = useRef<HTMLDivElement | null>(null);
|
|
620
|
+
/** Rich-text Copy requests waiting for the bridge's markup, by id. An
|
|
621
|
+
* answer with an id not in here (unsolicited, or after its timeout) is ignored. */
|
|
622
|
+
const richCopyRequestsRef = useRef(new Map<string, (answer: BridgeRichCopyMessage) => void>());
|
|
556
623
|
|
|
557
624
|
/** Post into the iframe with the live token + targetOrigin when a live
|
|
558
625
|
* session is active; srcdoc posts keep targetOrigin "*" and no token. */
|
|
@@ -665,7 +732,22 @@ export function useHtmlAnnotation({
|
|
|
665
732
|
return;
|
|
666
733
|
}
|
|
667
734
|
|
|
735
|
+
if (type === `${PREFIX}rich-copy`) {
|
|
736
|
+
const answer = richCopyRequestsRef.current.get(message.id);
|
|
737
|
+
if (!answer) return;
|
|
738
|
+
richCopyRequestsRef.current.delete(message.id);
|
|
739
|
+
answer(message);
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
if (type === `${PREFIX}pin-visibility`) {
|
|
744
|
+
setPinVisible(message.visible);
|
|
745
|
+
if (!message.visible && commentPopoverRef.current) setComposerWasHidden(true);
|
|
746
|
+
}
|
|
747
|
+
|
|
668
748
|
if (type === `${PREFIX}selection`) {
|
|
749
|
+
setPinVisible(true);
|
|
750
|
+
setComposerWasHidden(false);
|
|
669
751
|
pendingTextRef.current = message.text;
|
|
670
752
|
pendingAnchorRef.current = message.anchor ?? null;
|
|
671
753
|
pendingContextRef.current = message.context ?? null;
|
|
@@ -760,6 +842,35 @@ export function useHtmlAnnotation({
|
|
|
760
842
|
element: anchor,
|
|
761
843
|
source: null,
|
|
762
844
|
selectionText: message.text,
|
|
845
|
+
// The markup is built only when Copy is pressed: ask
|
|
846
|
+
// the bridge for the pending range's HTML and wait a moment.
|
|
847
|
+
...(message.richCopy ? {
|
|
848
|
+
getRichCopy: () => new Promise<RichCopySource>((resolve) => {
|
|
849
|
+
const id = `rc-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
850
|
+
const requests = richCopyRequestsRef.current;
|
|
851
|
+
// Without an answer only the quote is here: plain text, marked
|
|
852
|
+
// as cut when the bridge cut it.
|
|
853
|
+
const quote = { html: "", text: message.text, truncated: message.textCut === true };
|
|
854
|
+
requests.set(id, (answer) => {
|
|
855
|
+
const text = answer.text || quote.text;
|
|
856
|
+
const truncated = answer.text ? answer.truncated : quote.truncated;
|
|
857
|
+
if (answer.tooLarge) {
|
|
858
|
+
resolve({ tooLarge: true, text, truncated });
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
const html = answer.html ? sanitizeRichHtml(answer.html) : null;
|
|
862
|
+
if (html && html.length > MAX_RICH_CLIPBOARD_OUTPUT_LENGTH) {
|
|
863
|
+
resolve({ tooLarge: true, text, truncated });
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
resolve({ html: html ?? "", text, truncated });
|
|
867
|
+
});
|
|
868
|
+
window.setTimeout(() => {
|
|
869
|
+
if (requests.delete(id)) resolve(quote);
|
|
870
|
+
}, RICH_COPY_TIMEOUT_MS);
|
|
871
|
+
post({ type: `${PREFIX}rich-copy-request`, id });
|
|
872
|
+
}),
|
|
873
|
+
} : {}),
|
|
763
874
|
});
|
|
764
875
|
}
|
|
765
876
|
}
|
|
@@ -803,6 +914,18 @@ export function useHtmlAnnotation({
|
|
|
803
914
|
onHostDraftRef.current(null);
|
|
804
915
|
}
|
|
805
916
|
setToolbarState(null);
|
|
917
|
+
// The bridge closed the draft (its Escape rung) while the composer was
|
|
918
|
+
// hidden with its target: close the hidden composer too, or nothing on
|
|
919
|
+
// screen could ever close it. Its text stays in the draft store.
|
|
920
|
+
if (commentPopoverRef.current && !pinVisibleRef.current) {
|
|
921
|
+
post({ type: `${PREFIX}cancel-selection` });
|
|
922
|
+
setCommentPopover(null);
|
|
923
|
+
setDraftTargets([]);
|
|
924
|
+
pendingTextRef.current = "";
|
|
925
|
+
pendingAnchorRef.current = null;
|
|
926
|
+
pendingContextRef.current = null;
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
806
929
|
// Keep the captured text alive while a comment/quick-label is open: the user
|
|
807
930
|
// is composing, and the selection collapsing or scrolling out of view must
|
|
808
931
|
// not drop the annotation on submit. It's overwritten on the next selection.
|
|
@@ -1157,6 +1280,8 @@ export function useHtmlAnnotation({
|
|
|
1157
1280
|
|
|
1158
1281
|
return {
|
|
1159
1282
|
toolbarState,
|
|
1283
|
+
pinVisible: pinVisible || !commentPopover,
|
|
1284
|
+
composerAutoFocus: !composerWasHidden,
|
|
1160
1285
|
commentPopover,
|
|
1161
1286
|
quickLabelPicker,
|
|
1162
1287
|
handleAnnotate,
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The image lightbox's zoom math, kept pure so it can be tested without a
|
|
3
|
+
* layout engine. A viewport is a translate plus a scale on the image, where
|
|
4
|
+
* scale 1 is the image's own pixels (Actual size) and the translate is the
|
|
5
|
+
* image's top-left corner inside the stage, in CSS pixels.
|
|
6
|
+
*
|
|
7
|
+
* The rules: the smallest scale is Fit (never above 1, so a small image is
|
|
8
|
+
* not blown up), the largest is 8 (800%); an axis on which the image is
|
|
9
|
+
* smaller than the stage is centred, and an axis on which it is larger is
|
|
10
|
+
* kept covering the stage, so the image can never be panned out of sight.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface ZoomSize {
|
|
14
|
+
readonly width: number;
|
|
15
|
+
readonly height: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ZoomViewport {
|
|
19
|
+
readonly x: number;
|
|
20
|
+
readonly y: number;
|
|
21
|
+
readonly scale: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ZoomLimits {
|
|
25
|
+
readonly min: number;
|
|
26
|
+
readonly max: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The share of the stage Fit may fill on each axis (1 = edge to edge). */
|
|
30
|
+
export interface ZoomInset {
|
|
31
|
+
readonly x: number;
|
|
32
|
+
readonly y: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 800%: an image pixel is then 8 screen pixels, enough to read any screenshot. */
|
|
36
|
+
export const LIGHTBOX_ZOOM_MAX = 8;
|
|
37
|
+
/** Wheel zoom: factor = exp(-deltaY * sensitivity), with deltaY capped so a
|
|
38
|
+
* mouse notch (100 px) is about 1.28x, close to the diagram canvas's step,
|
|
39
|
+
* while a trackpad pinch (a few px per event) stays smooth. */
|
|
40
|
+
export const WHEEL_ZOOM_SENSITIVITY = 0.01;
|
|
41
|
+
export const WHEEL_ZOOM_DELTA_CAP = 25;
|
|
42
|
+
/** A plain-wheel gesture keeps the meaning it started with (zoom or scroll)
|
|
43
|
+
* until the wheel rests this long, so wheeling in past the screen's edge
|
|
44
|
+
* does not flip from zooming to scrolling halfway through. */
|
|
45
|
+
export const WHEEL_LATCH_MS = 250;
|
|
46
|
+
/** A press that travels less than this is a tap (a click), not a pan. */
|
|
47
|
+
export const TAP_SLOP_MOUSE_PX = 4;
|
|
48
|
+
export const TAP_SLOP_TOUCH_PX = 10;
|
|
49
|
+
/** Two taps this close in time and space are a double tap (or double click). */
|
|
50
|
+
export const DOUBLE_TAP_MS = 300;
|
|
51
|
+
export const DOUBLE_TAP_SLOP_PX = 24;
|
|
52
|
+
/** A double click at Fit zooms to twice the Fit scale. */
|
|
53
|
+
export const DOUBLE_TAP_ZOOM = 2;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Which way Fit fits: the whole image (`contain`, the default), or its width
|
|
57
|
+
* (a tall screenshot read from the top) or height (a panorama read from the
|
|
58
|
+
* left). The smallest scale is always `contain`, so a reader can zoom out to
|
|
59
|
+
* the whole image from either.
|
|
60
|
+
*/
|
|
61
|
+
export type FitAxis = 'contain' | 'width' | 'height';
|
|
62
|
+
|
|
63
|
+
const FULL: ZoomInset = { x: 1, y: 1 };
|
|
64
|
+
|
|
65
|
+
function valid(size: ZoomSize | null | undefined): size is ZoomSize {
|
|
66
|
+
return !!size && size.width > 0 && size.height > 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function clampNumber(value: number, min: number, max: number): number {
|
|
70
|
+
return Math.min(max, Math.max(min, value));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The scale at which the whole image (or its width, or its height) fits the stage, never above 1. */
|
|
74
|
+
export function fitScale(image: ZoomSize, stage: ZoomSize, inset: ZoomInset = FULL, axis: FitAxis = 'contain'): number {
|
|
75
|
+
if (!valid(image) || !valid(stage)) return 1;
|
|
76
|
+
const across = (stage.width * inset.x) / image.width;
|
|
77
|
+
const down = (stage.height * inset.y) / image.height;
|
|
78
|
+
if (axis === 'width') return Math.min(1, across);
|
|
79
|
+
if (axis === 'height') return Math.min(1, down);
|
|
80
|
+
return Math.min(1, across, down);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function zoomLimits(image: ZoomSize, stage: ZoomSize, inset: ZoomInset = FULL): ZoomLimits {
|
|
84
|
+
const min = fitScale(image, stage, inset);
|
|
85
|
+
return { min, max: Math.max(min, LIGHTBOX_ZOOM_MAX) };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function placeAxis(position: number, content: number, room: number): number {
|
|
89
|
+
if (content <= room) return (room - content) / 2;
|
|
90
|
+
return clampNumber(position, room - content, 0);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The scale within the limits, and the image centred or covering the stage on each axis. */
|
|
94
|
+
export function clampViewport(view: ZoomViewport, image: ZoomSize, stage: ZoomSize, limits: ZoomLimits): ZoomViewport {
|
|
95
|
+
const scale = clampNumber(view.scale, limits.min, limits.max);
|
|
96
|
+
return {
|
|
97
|
+
scale,
|
|
98
|
+
x: placeAxis(view.x, image.width * scale, stage.width),
|
|
99
|
+
y: placeAxis(view.y, image.height * scale, stage.height),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Fit: the whole image, centred (or its width from the top, or its height from the left). */
|
|
104
|
+
export function fitViewport(image: ZoomSize, stage: ZoomSize, inset: ZoomInset = FULL, axis: FitAxis = 'contain'): ZoomViewport {
|
|
105
|
+
const limits = zoomLimits(image, stage, inset);
|
|
106
|
+
return clampViewport({ x: 0, y: 0, scale: fitScale(image, stage, inset, axis) }, image, stage, limits);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Actual size: the image's own pixels. An axis on which it is larger than the
|
|
111
|
+
* stage starts at its top or left edge (a tall screenshot is read from the
|
|
112
|
+
* top); an axis on which it is smaller is centred.
|
|
113
|
+
*/
|
|
114
|
+
export function actualViewport(image: ZoomSize, stage: ZoomSize, inset: ZoomInset = FULL): ZoomViewport {
|
|
115
|
+
const limits = zoomLimits(image, stage, inset);
|
|
116
|
+
return clampViewport({ x: 0, y: 0, scale: 1 }, image, stage, limits);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Zoom by `factor` about a point of the stage, so the pixel under it stays under it. */
|
|
120
|
+
export function zoomAt(
|
|
121
|
+
view: ZoomViewport,
|
|
122
|
+
factor: number,
|
|
123
|
+
px: number,
|
|
124
|
+
py: number,
|
|
125
|
+
image: ZoomSize,
|
|
126
|
+
stage: ZoomSize,
|
|
127
|
+
limits: ZoomLimits,
|
|
128
|
+
): ZoomViewport {
|
|
129
|
+
const scale = clampNumber(view.scale * factor, limits.min, limits.max);
|
|
130
|
+
const ratio = scale / view.scale;
|
|
131
|
+
return clampViewport({ x: px - (px - view.x) * ratio, y: py - (py - view.y) * ratio, scale }, image, stage, limits);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Move the image by a pointer's travel (or a scroll's, negated by the caller). */
|
|
135
|
+
export function panBy(view: ZoomViewport, dx: number, dy: number, image: ZoomSize, stage: ZoomSize, limits: ZoomLimits): ZoomViewport {
|
|
136
|
+
return clampViewport({ x: view.x + dx, y: view.y + dy, scale: view.scale }, image, stage, limits);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Whether the image is larger than the stage on either axis (so there is something to pan). */
|
|
140
|
+
export function overflows(view: ZoomViewport, image: ZoomSize, stage: ZoomSize): boolean {
|
|
141
|
+
return image.width * view.scale > stage.width + 0.5 || image.height * view.scale > stage.height + 0.5;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Whether a viewport is at the Fit scale (`limits.min`, or a given Fit scale). */
|
|
145
|
+
export function atFit(view: ZoomViewport, limits: ZoomLimits | number): boolean {
|
|
146
|
+
const fit = typeof limits === 'number' ? limits : limits.min;
|
|
147
|
+
return Math.abs(view.scale - fit) < 1e-3;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The share of a stage that leaves `padding` CSS pixels on every side (at least a tenth). */
|
|
151
|
+
export function paddingInset(stage: ZoomSize, padding: number): ZoomInset {
|
|
152
|
+
if (!valid(stage) || padding <= 0) return FULL;
|
|
153
|
+
return { x: Math.max(0.1, (stage.width - 2 * padding) / stage.width), y: Math.max(0.1, (stage.height - 2 * padding) / stage.height) };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* A wheel event's travel in CSS pixels. Lines (Firefox's mouse wheel) count
|
|
158
|
+
* 16 px and pages the stage's height. Shift turns a vertical wheel sideways,
|
|
159
|
+
* as it does on a page (macOS already reports it as deltaX).
|
|
160
|
+
*/
|
|
161
|
+
export function wheelTravel(
|
|
162
|
+
event: { deltaX: number; deltaY: number; deltaMode: number; shiftKey: boolean },
|
|
163
|
+
pageHeight: number,
|
|
164
|
+
): { dx: number; dy: number } {
|
|
165
|
+
const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? pageHeight : 1;
|
|
166
|
+
let dx = event.deltaX * unit;
|
|
167
|
+
let dy = event.deltaY * unit;
|
|
168
|
+
if (event.shiftKey && dx === 0) {
|
|
169
|
+
dx = dy;
|
|
170
|
+
dy = 0;
|
|
171
|
+
}
|
|
172
|
+
return { dx, dy };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The zoom factor for a wheel's vertical travel: up (negative) zooms in. */
|
|
176
|
+
export function wheelZoomFactor(dy: number): number {
|
|
177
|
+
return Math.exp(-clampNumber(dy, -WHEEL_ZOOM_DELTA_CAP, WHEEL_ZOOM_DELTA_CAP) * WHEEL_ZOOM_SENSITIVITY);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// Region boxes (#2100): a box drawn on the image is kept in the image's own
|
|
182
|
+
// (natural) pixels, so it is the same box at every zoom and pan, and the
|
|
183
|
+
// agent can crop exactly that spot. These convert between the stage (CSS
|
|
184
|
+
// pixels, relative to the stage's top-left) and the image.
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
/** A rectangle in the image's own pixels (integers once made by `naturalBoxFromPoints`). */
|
|
188
|
+
export interface NaturalBox {
|
|
189
|
+
readonly x: number;
|
|
190
|
+
readonly y: number;
|
|
191
|
+
readonly w: number;
|
|
192
|
+
readonly h: number;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** A rectangle in stage pixels. */
|
|
196
|
+
export interface StageRect {
|
|
197
|
+
readonly left: number;
|
|
198
|
+
readonly top: number;
|
|
199
|
+
readonly width: number;
|
|
200
|
+
readonly height: number;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** A stage point (CSS pixels from the stage's top-left) in the image's own pixels. Not clamped. */
|
|
204
|
+
export function stageToNatural(view: ZoomViewport, px: number, py: number): { x: number; y: number } {
|
|
205
|
+
return { x: (px - view.x) / view.scale, y: (py - view.y) / view.scale };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The box two stage points span (a drag's start and end, in either
|
|
210
|
+
* direction), in the image's own pixels: clamped to the image, widened to
|
|
211
|
+
* whole pixels (floor the near edges, ceil the far ones), so the box always
|
|
212
|
+
* covers what the reviewer drew. Null when nothing of it is on the image.
|
|
213
|
+
*/
|
|
214
|
+
export function naturalBoxFromPoints(
|
|
215
|
+
view: ZoomViewport,
|
|
216
|
+
a: { x: number; y: number },
|
|
217
|
+
b: { x: number; y: number },
|
|
218
|
+
image: ZoomSize,
|
|
219
|
+
): NaturalBox | null {
|
|
220
|
+
const p = stageToNatural(view, a.x, a.y);
|
|
221
|
+
const q = stageToNatural(view, b.x, b.y);
|
|
222
|
+
const x0 = clampNumber(Math.floor(Math.min(p.x, q.x)), 0, image.width);
|
|
223
|
+
const y0 = clampNumber(Math.floor(Math.min(p.y, q.y)), 0, image.height);
|
|
224
|
+
const x1 = clampNumber(Math.ceil(Math.max(p.x, q.x)), 0, image.width);
|
|
225
|
+
const y1 = clampNumber(Math.ceil(Math.max(p.y, q.y)), 0, image.height);
|
|
226
|
+
if (x1 - x0 < 1 || y1 - y0 < 1) return null;
|
|
227
|
+
return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Where a natural-pixel box is on the stage at a viewport. */
|
|
231
|
+
export function naturalBoxToStage(view: ZoomViewport, box: NaturalBox): StageRect {
|
|
232
|
+
return { left: view.x + box.x * view.scale, top: view.y + box.y * view.scale, width: box.w * view.scale, height: box.h * view.scale };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* The part of the image the stage shows now, in the image's own pixels:
|
|
237
|
+
* what Enter comments on in Region mode (the keyboard's way to a box: frame
|
|
238
|
+
* the spot with Actual size and the scroll keys, then press Enter). Null when
|
|
239
|
+
* nothing of the image is on the stage.
|
|
240
|
+
*/
|
|
241
|
+
export function visibleNaturalBox(view: ZoomViewport, image: ZoomSize, stage: ZoomSize): NaturalBox | null {
|
|
242
|
+
return naturalBoxFromPoints(view, { x: 0, y: 0 }, { x: stage.width, y: stage.height }, image);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** The share of the stage a box fills when the viewer zooms to it. */
|
|
246
|
+
export const ZOOM_TO_BOX_SHARE = 0.5;
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* The viewport that shows a box large and centred: it fills `share` of the
|
|
250
|
+
* stage on its tighter axis, within the zoom limits (a box larger than that
|
|
251
|
+
* share at Fit stays at Fit), then the usual clamp keeps the image covering
|
|
252
|
+
* the stage, so a box at an edge sits as near the centre as the image allows.
|
|
253
|
+
*/
|
|
254
|
+
export function viewportForBox(
|
|
255
|
+
box: NaturalBox,
|
|
256
|
+
image: ZoomSize,
|
|
257
|
+
stage: ZoomSize,
|
|
258
|
+
limits: ZoomLimits,
|
|
259
|
+
share = ZOOM_TO_BOX_SHARE,
|
|
260
|
+
): ZoomViewport {
|
|
261
|
+
const scale = clampNumber(Math.min((stage.width * share) / Math.max(1, box.w), (stage.height * share) / Math.max(1, box.h)), limits.min, limits.max);
|
|
262
|
+
const cx = box.x + box.w / 2;
|
|
263
|
+
const cy = box.y + box.h / 2;
|
|
264
|
+
return clampViewport({ scale, x: stage.width / 2 - cx * scale, y: stage.height / 2 - cy * scale }, image, stage, limits);
|
|
265
|
+
}
|
|
@@ -573,7 +573,7 @@ const DiffBlockRenderer: React.FC<DiffBlockRendererProps> = ({
|
|
|
573
573
|
switch (block.type) {
|
|
574
574
|
case "unchanged":
|
|
575
575
|
return (
|
|
576
|
-
<div className={`plan-diff-unchanged ${unchangedClassName ?? DEFAULT_UNCHANGED_CLASS}`}>
|
|
576
|
+
<div className={`plan-diff-unchanged ${unchangedClassName ?? DEFAULT_UNCHANGED_CLASS}`} data-doc-text="">
|
|
577
577
|
<MarkdownChunk content={block.content} />
|
|
578
578
|
</div>
|
|
579
579
|
);
|
|
@@ -36,6 +36,12 @@ interface PlanDiffViewerProps {
|
|
|
36
36
|
selectedAnnotationId?: string | null;
|
|
37
37
|
mode?: EditorMode;
|
|
38
38
|
onOpenVscodeDiff?: (baseVersion: number) => Promise<{ ok?: boolean; error?: string }>;
|
|
39
|
+
/**
|
|
40
|
+
* Grow the diff card to the full height the parent gives it, like Viewer's
|
|
41
|
+
* `fillHeight`, so a short diff's card still runs to the bottom of the view.
|
|
42
|
+
* Default false: the card sizes to its content.
|
|
43
|
+
*/
|
|
44
|
+
fillHeight?: boolean;
|
|
39
45
|
}
|
|
40
46
|
|
|
41
47
|
const defaultOpenVscodeDiff = async (
|
|
@@ -77,6 +83,7 @@ export const PlanDiffViewer: React.FC<PlanDiffViewerProps> = ({
|
|
|
77
83
|
selectedAnnotationId,
|
|
78
84
|
mode,
|
|
79
85
|
onOpenVscodeDiff,
|
|
86
|
+
fillHeight = false,
|
|
80
87
|
}) => {
|
|
81
88
|
const [vscodeDiffLoading, setVscodeDiffLoading] = useState(false);
|
|
82
89
|
const [vscodeDiffError, setVscodeDiffError] = useState<string | null>(null);
|
|
@@ -102,8 +109,12 @@ export const PlanDiffViewer: React.FC<PlanDiffViewerProps> = ({
|
|
|
102
109
|
};
|
|
103
110
|
|
|
104
111
|
return (
|
|
105
|
-
<div
|
|
106
|
-
|
|
112
|
+
<div
|
|
113
|
+
className={`relative z-50 w-full${fillHeight ? ' flex flex-col print:block' : ''}`}
|
|
114
|
+
data-viewer-fill-height={fillHeight ? '' : undefined}
|
|
115
|
+
style={maxWidth ? { maxWidth } : { maxWidth: 832 }}
|
|
116
|
+
>
|
|
117
|
+
<article className={`w-full ${fillHeight ? 'flex-1 ' : ''}bg-card border border-border/50 rounded-xl shadow-xl p-5 md:p-8 lg:p-10 xl:p-12 relative`} data-doc-text-root="">
|
|
107
118
|
{/* Top-left: repo info + diff badge — matches Viewer layout (flex-col) so badge doesn't jump position */}
|
|
108
119
|
<div className="absolute top-3 left-3 md:top-4 md:left-5 flex flex-col items-start gap-1 text-[9px] text-muted-foreground/50 font-mono">
|
|
109
120
|
{repoInfo && (
|
|
@@ -67,7 +67,7 @@ export const PlanRawDiffView: React.FC<PlanRawDiffViewProps> = ({ blocks }) => {
|
|
|
67
67
|
}, [blocks]);
|
|
68
68
|
|
|
69
69
|
return (
|
|
70
|
-
<div className="font-mono text-[13px] leading-relaxed bg-muted/30 rounded-lg border border-border/30 overflow-x-auto">
|
|
70
|
+
<div className="font-mono text-[13px] leading-relaxed bg-muted/30 rounded-lg border border-border/30 overflow-x-auto" data-doc-text="">
|
|
71
71
|
<div>
|
|
72
72
|
{lines.map((line, index) => (
|
|
73
73
|
<div
|
|
@@ -10,6 +10,7 @@ import { Search, X } from "lucide-react";
|
|
|
10
10
|
import type { VaultNode } from "../../types";
|
|
11
11
|
import type { DirState } from "../../hooks/useFileBrowser";
|
|
12
12
|
import { CountBadge } from "./CountBadge";
|
|
13
|
+
import { HostTitle } from "../HostTitle";
|
|
13
14
|
import { ObsidianIconRaw } from "../icons/ObsidianIcons";
|
|
14
15
|
import type { WorkspaceFileChange, WorkspaceStatusPayload } from "@plannotator/core/workspace-status-types";
|
|
15
16
|
import { normalizeBrowserPath } from "@plannotator/core/browser-paths";
|
|
@@ -282,7 +283,9 @@ const TreeNode: React.FC<{
|
|
|
282
283
|
<svg className="w-3 h-3 flex-shrink-0 text-muted-foreground/60" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
283
284
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
|
284
285
|
</svg>
|
|
285
|
-
<
|
|
286
|
+
<HostTitle title={node.name} cut>
|
|
287
|
+
<span className="truncate">{node.name}</span>
|
|
288
|
+
</HostTitle>
|
|
286
289
|
<div className="ml-auto flex flex-shrink-0 items-center gap-1.5 text-[10px]">
|
|
287
290
|
{(aggregateChange.additions > 0 || aggregateChange.deletions > 0) && (
|
|
288
291
|
<>
|
|
@@ -360,7 +363,9 @@ const TreeNode: React.FC<{
|
|
|
360
363
|
<svg className="w-3 h-3 flex-shrink-0 opacity-40" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
361
364
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
362
365
|
</svg>
|
|
363
|
-
<
|
|
366
|
+
<HostTitle title={node.name} cut>
|
|
367
|
+
<span className={`truncate flex-1 min-w-0 ${isDeleted ? "line-through" : ""}`}>{displayName}</span>
|
|
368
|
+
</HostTitle>
|
|
364
369
|
<div className="ml-auto flex flex-shrink-0 items-center gap-1.5 text-[10px]">
|
|
365
370
|
{isActive && selectionPending && (
|
|
366
371
|
<span className="text-primary">Opening…</span>
|
|
@@ -455,10 +460,19 @@ const DirSection: React.FC<{
|
|
|
455
460
|
selectionPending={selectionPending}
|
|
456
461
|
/>
|
|
457
462
|
))}
|
|
463
|
+
{dir.truncated && <FileLimitNote limit={dir.fileLimit} />}
|
|
458
464
|
</div>
|
|
459
465
|
);
|
|
460
466
|
};
|
|
461
467
|
|
|
468
|
+
/** The folder holds more supported files than the server lists; say so once, quietly, at the foot of its tree. */
|
|
469
|
+
export const FileLimitNote: React.FC<{ limit?: number }> = ({ limit }) => (
|
|
470
|
+
<div data-file-limit-note className="px-2 pt-2 pb-1 text-[10px] leading-snug text-muted-foreground">
|
|
471
|
+
{limit ? `Showing the first ${limit.toLocaleString("en-US")} files.` : "Showing some of the files."}{" "}
|
|
472
|
+
Raise <code className="font-mono">PLANNOTATOR_FILE_BROWSER_MAX_FILES</code> to see more.
|
|
473
|
+
</div>
|
|
474
|
+
);
|
|
475
|
+
|
|
462
476
|
export const FileBrowser: React.FC<FileBrowserProps> = ({
|
|
463
477
|
dirs,
|
|
464
478
|
expandedFolders,
|
|
@@ -24,6 +24,7 @@ interface SidebarTabsProps {
|
|
|
24
24
|
hasFileAnnotations?: boolean;
|
|
25
25
|
hasMessageAnnotations?: boolean;
|
|
26
26
|
className?: string;
|
|
27
|
+
style?: React.CSSProperties;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
export const SidebarTabs: React.FC<SidebarTabsProps> = ({
|
|
@@ -40,11 +41,13 @@ export const SidebarTabs: React.FC<SidebarTabsProps> = ({
|
|
|
40
41
|
hasFileAnnotations,
|
|
41
42
|
hasMessageAnnotations,
|
|
42
43
|
className,
|
|
44
|
+
style,
|
|
43
45
|
}) => {
|
|
44
46
|
return (
|
|
45
47
|
<div
|
|
46
48
|
data-sidebar-tabs="true"
|
|
47
49
|
className={`flex flex-col gap-1 pt-3 pl-0.5 flex-shrink-0 ${className ?? ""}`}
|
|
50
|
+
style={style}
|
|
48
51
|
>
|
|
49
52
|
{showAgentTerminalTab && onToggleAgentTerminal && (
|
|
50
53
|
<button
|
package/config/index.ts
CHANGED
|
@@ -4,9 +4,12 @@ export { useConfigValue } from './useConfig';
|
|
|
4
4
|
export {
|
|
5
5
|
setReviewPanelView,
|
|
6
6
|
setReviewDefaultDiffType,
|
|
7
|
+
getProviderReviewDefaultDiffType,
|
|
8
|
+
setProviderReviewDefaultDiffType,
|
|
7
9
|
getPersistedReviewPanelView,
|
|
8
10
|
setReviewAutoViewed,
|
|
9
11
|
needsAutoViewedNotice,
|
|
10
12
|
markAutoViewedNoticeSeen,
|
|
11
13
|
type ReviewDefaultDiffType,
|
|
14
|
+
type VcsReviewSettingsDescriptor,
|
|
12
15
|
} from './reviewView';
|
package/config/reviewView.ts
CHANGED
|
@@ -2,6 +2,20 @@ import { configStore } from './configStore';
|
|
|
2
2
|
import { SETTINGS } from './settings';
|
|
3
3
|
import { storage } from '../utils/storage';
|
|
4
4
|
|
|
5
|
+
export interface ReviewSettingsDiffOption {
|
|
6
|
+
id: string;
|
|
7
|
+
label: string;
|
|
8
|
+
description: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** The default-diff choices Settings → Review offers for one VCS. */
|
|
12
|
+
export interface VcsReviewSettingsDescriptor {
|
|
13
|
+
id: string;
|
|
14
|
+
label: string;
|
|
15
|
+
defaultDiffType: string;
|
|
16
|
+
diffOptions: ReviewSettingsDiffOption[];
|
|
17
|
+
}
|
|
18
|
+
|
|
5
19
|
/**
|
|
6
20
|
* The ONLY writers for the coupled setting pair (reviewPanelView,
|
|
7
21
|
* defaultDiffType).
|
|
@@ -81,6 +95,32 @@ export function setReviewDefaultDiffType(
|
|
|
81
95
|
}
|
|
82
96
|
}
|
|
83
97
|
|
|
98
|
+
/**
|
|
99
|
+
* The saved default diff for a VCS other than Git (today: Jujutsu), read from
|
|
100
|
+
* `reviewDefaults[id]`. Git keeps its original `defaultDiffType` setting and
|
|
101
|
+
* its coupled setter above; a value this VCS does not offer reads as its own
|
|
102
|
+
* default, never as a Git mode.
|
|
103
|
+
*/
|
|
104
|
+
export function getProviderReviewDefaultDiffType(
|
|
105
|
+
descriptor: VcsReviewSettingsDescriptor,
|
|
106
|
+
store: PanelViewConfigStore = configStore,
|
|
107
|
+
): string {
|
|
108
|
+
const configured = store.get('reviewDefaults')[descriptor.id]?.defaultDiffType;
|
|
109
|
+
return configured && descriptor.diffOptions.some((option) => option.id === configured)
|
|
110
|
+
? configured
|
|
111
|
+
: descriptor.defaultDiffType;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function setProviderReviewDefaultDiffType(
|
|
115
|
+
providerId: string,
|
|
116
|
+
value: string,
|
|
117
|
+
store: PanelViewConfigStore = configStore,
|
|
118
|
+
): void {
|
|
119
|
+
store.set('reviewDefaults', {
|
|
120
|
+
...store.get('reviewDefaults'),
|
|
121
|
+
[providerId]: { defaultDiffType: value },
|
|
122
|
+
});
|
|
123
|
+
}
|
|
84
124
|
|
|
85
125
|
/**
|
|
86
126
|
* One-time gate for the auto-mark-viewed notice — the toast that fires the
|
|
@@ -90,7 +130,7 @@ export function setReviewDefaultDiffType(
|
|
|
90
130
|
* meaningful revision can re-show it.
|
|
91
131
|
*
|
|
92
132
|
* It lives beside the setting rather than in the review app because BOTH
|
|
93
|
-
* writers of the setting (Settings
|
|
133
|
+
* writers of the setting (Settings → Review here, and the file-list gear in the
|
|
94
134
|
* review app) must consume the gate: someone who found the switch has
|
|
95
135
|
* demonstrably discovered the feature and must never be told about it.
|
|
96
136
|
*/
|