@cueloop/client 0.1.0-alpha.9 → 0.1.0-alpha.91
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/native/build-pty.sh +51 -0
- package/native/build.sh +82 -0
- package/native/darwin-arm64/libcuelooppty.dylib +0 -0
- package/native/darwin-arm64/libcueloopvt.dylib +0 -0
- package/native/src/pty.zig +193 -0
- package/native/src/shim.zig +114 -0
- package/package.json +42 -34
- package/src/App.appearance.test.tsx +53 -0
- package/src/App.autoclose.test.tsx +77 -51
- package/src/App.diff.test.tsx +259 -45
- package/src/App.export.test.tsx +58 -41
- package/src/App.focus.test.ts +89 -0
- package/src/App.identity.test.tsx +78 -0
- package/src/App.plan-render.test.tsx +209 -0
- package/src/App.plan-review.test.tsx +330 -161
- package/src/App.prototype-markdown.test.tsx +128 -0
- package/src/App.prototype.test.tsx +176 -0
- package/src/App.readonly.test.tsx +129 -67
- package/src/App.regression.test.tsx +94 -0
- package/src/App.rendering.test.tsx +61 -35
- package/src/App.share-dialog.test.tsx +172 -0
- package/src/App.submit-confirm.test.tsx +264 -0
- package/src/App.test.tsx +402 -126
- package/src/App.toast.test.tsx +98 -0
- package/src/App.tsx +1406 -1060
- package/src/App.visual.test.tsx +57 -0
- package/src/App.walk.test.tsx +299 -0
- package/src/App.workbench.test.tsx +787 -0
- package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
- package/src/annotation-palette.ts +62 -0
- package/src/ansi-screen.test.ts +28 -0
- package/src/ansi-screen.ts +72 -0
- package/src/app-key-state.ts +61 -0
- package/src/app-screens.tsx +577 -0
- package/src/app-view-model.ts +168 -0
- package/src/attribution.test.ts +31 -0
- package/src/attribution.ts +42 -0
- package/src/client-extension-registry.test.tsx +61 -0
- package/src/client-extension-registry.ts +159 -0
- package/src/clipboard.test.ts +23 -0
- package/src/clipboard.ts +33 -0
- package/src/components/AnnotatableFileView.stories.tsx +42 -0
- package/src/components/AnnotatableFileView.test.tsx +102 -0
- package/src/components/AnnotatableFileView.tsx +134 -0
- package/src/components/AnnotationCards.stories.tsx +86 -0
- package/src/components/AnnotationCards.tsx +341 -0
- package/src/components/AnsiScreen.stories.tsx +12 -0
- package/src/components/AnsiScreen.tsx +28 -0
- package/src/components/AppHeader.stories.tsx +50 -0
- package/src/components/AppHeader.tsx +83 -0
- package/src/components/AppShell.brand.test.tsx +58 -0
- package/src/components/AppShell.extensions.test.tsx +191 -0
- package/src/components/AppShell.stories.tsx +89 -0
- package/src/components/AppShell.tsx +362 -0
- package/src/components/AppShell.zoom.test.tsx +135 -0
- package/src/components/BareWorkbenchFileView.stories.tsx +27 -0
- package/src/components/BareWorkbenchFileView.tsx +64 -0
- package/src/components/Breadcrumb.stories.tsx +19 -0
- package/src/components/Breadcrumb.tsx +45 -0
- package/src/components/ChangesColumn.stories.tsx +44 -0
- package/src/components/ChangesColumn.tsx +199 -0
- package/src/components/ChangesFileTree.test.tsx +61 -0
- package/src/components/CodeBlock.stories.tsx +42 -0
- package/src/components/CodeBlock.tsx +84 -0
- package/src/components/CompletionOverlay.stories.tsx +37 -0
- package/src/components/CompletionOverlay.tsx +77 -0
- package/src/components/ConfirmCard.stories.tsx +37 -0
- package/src/components/ConfirmCard.tsx +150 -0
- package/src/components/ConfirmDialog.stories.tsx +21 -0
- package/src/components/ConfirmDialog.tsx +72 -0
- package/src/components/DiffContentView.scroll.test.tsx +149 -0
- package/src/components/DiffContentView.split.test.tsx +140 -0
- package/src/components/DiffContentView.stories.tsx +80 -0
- package/src/components/DiffContentView.syntax.test.tsx +82 -0
- package/src/components/DiffContentView.tsx +906 -0
- package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
- package/src/components/DiscussionMarkerRail.tsx +186 -0
- package/src/components/EditorGrid.stories.tsx +59 -0
- package/src/components/EditorGrid.tabs.test.tsx +118 -0
- package/src/components/EditorGrid.tsx +426 -0
- package/src/components/EditorGrid.zoom.test.tsx +68 -0
- package/src/components/ExtensionZone.stories.tsx +28 -0
- package/src/components/ExtensionZone.tsx +145 -0
- package/src/components/FileContentsView.stories.tsx +16 -0
- package/src/components/FileContentsView.test.tsx +29 -0
- package/src/components/FileContentsView.tsx +98 -0
- package/src/components/GridTabContent.stories.tsx +59 -0
- package/src/components/GridTabContent.tsx +182 -0
- package/src/components/HandleChipsInput.stories.tsx +24 -0
- package/src/components/HandleChipsInput.test.tsx +35 -0
- package/src/components/HandleChipsInput.tsx +145 -0
- package/src/components/KeybindsDialog.stories.tsx +31 -0
- package/src/components/KeybindsDialog.tsx +103 -0
- package/src/components/MarkdownGridBlock.stories.tsx +33 -0
- package/src/components/MarkdownGridBlock.tsx +72 -0
- package/src/components/MarkdownThreadEditor.stories.tsx +15 -0
- package/src/components/MarkdownThreadEditor.test.tsx +98 -0
- package/src/components/MarkdownThreadEditor.tsx +162 -0
- package/src/components/MenuBar.stories.tsx +28 -0
- package/src/components/MenuBar.tsx +44 -0
- package/src/components/NavModeHint.stories.tsx +40 -0
- package/src/components/NavModeHint.tsx +31 -0
- package/src/components/OverlayScrollbar.stories.tsx +33 -0
- package/src/components/OverlayScrollbar.test.tsx +139 -0
- package/src/components/OverlayScrollbar.tsx +129 -0
- package/src/components/PaneGrid.stories.tsx +84 -0
- package/src/components/PaneGrid.tsx +128 -0
- package/src/components/PanelColumn.stories.tsx +25 -0
- package/src/components/PanelColumn.tsx +98 -0
- package/src/components/ProjectTreeView.stories.tsx +25 -0
- package/src/components/ProjectTreeView.test.tsx +75 -0
- package/src/components/ProjectTreeView.tsx +125 -0
- package/src/components/PromptDialog.stories.tsx +36 -0
- package/src/components/PromptDialog.test.tsx +61 -0
- package/src/components/PromptDialog.tsx +99 -0
- package/src/components/PrototypeContentView.stories.tsx +19 -0
- package/src/components/PrototypeContentView.tsx +446 -0
- package/src/components/RootOverlay.stories.tsx +45 -0
- package/src/components/RootOverlay.tsx +48 -0
- package/src/components/ScrollArea.stories.tsx +20 -0
- package/src/components/ScrollArea.tsx +44 -0
- package/src/components/SettingsDialog.stories.tsx +117 -0
- package/src/components/SettingsDialog.tsx +186 -0
- package/src/components/SettingsRows.stories.tsx +18 -0
- package/src/components/SettingsRows.tsx +112 -0
- package/src/components/ShareDialog.stories.tsx +121 -0
- package/src/components/ShareDialog.tsx +552 -0
- package/src/components/ShellHeader.stories.tsx +50 -0
- package/src/components/ShellHeader.tsx +137 -0
- package/src/components/SlashComposer.stories.tsx +37 -0
- package/src/components/SlashComposer.tsx +87 -0
- package/src/components/ThreadFooter.stories.tsx +30 -0
- package/src/components/ThreadFooter.test.tsx +47 -0
- package/src/components/ThreadFooter.tsx +76 -0
- package/src/components/ThreadTree.stories.tsx +46 -0
- package/src/components/ThreadTree.tsx +255 -0
- package/src/components/ThreadView.selection.test.tsx +951 -0
- package/src/components/ThreadView.slash.test.tsx +146 -0
- package/src/components/ThreadView.stories.tsx +26 -0
- package/src/components/ThreadView.tsx +525 -0
- package/src/components/Toast.stories.tsx +19 -0
- package/src/components/Toast.tsx +52 -0
- package/src/components/Tooltip.stories.tsx +26 -0
- package/src/components/Tooltip.tsx +72 -0
- package/src/components/TreePane.stories.tsx +106 -0
- package/src/components/TreePane.tsx +157 -0
- package/src/components/WalkWizard.stories.tsx +100 -0
- package/src/components/WalkWizard.tsx +150 -0
- package/src/components/WelcomePlayground.stories.tsx +12 -0
- package/src/components/WelcomePlayground.test.tsx +49 -0
- package/src/components/WelcomePlayground.tsx +139 -0
- package/src/components/WorkspacePanels.stories.tsx +36 -0
- package/src/components/WorkspacePanels.tsx +116 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +2264 -0
- package/src/components/diff-content-view-layout.test.ts +198 -0
- package/src/components/diff-content-view-layout.ts +84 -0
- package/src/components/editor-grid.test.ts +86 -0
- package/src/components/editor-grid.ts +138 -0
- package/src/components/file-tree.test.ts +60 -0
- package/src/components/file-tree.ts +112 -0
- package/src/components/menu-control.stories.tsx +35 -0
- package/src/components/menu-control.tsx +43 -0
- package/src/components/pages/Workspace.stories.tsx +181 -0
- package/src/components/primitives/Button.stories.tsx +32 -0
- package/src/components/primitives/Button.tsx +59 -0
- package/src/components/primitives/Card.stories.tsx +25 -0
- package/src/components/primitives/Card.tsx +63 -0
- package/src/components/primitives/Dialog.stories.tsx +24 -0
- package/src/components/primitives/Dialog.test.tsx +37 -0
- package/src/components/primitives/Dialog.tsx +71 -0
- package/src/components/primitives/DialogActions.stories.tsx +20 -0
- package/src/components/primitives/DialogActions.tsx +39 -0
- package/src/components/primitives/IconButton.stories.tsx +36 -0
- package/src/components/primitives/IconButton.tsx +62 -0
- package/src/components/primitives/StatusBar.stories.tsx +11 -0
- package/src/components/primitives/StatusBar.tsx +23 -0
- package/src/components/primitives/Switch.stories.tsx +17 -0
- package/src/components/primitives/Switch.tsx +32 -0
- package/src/components/primitives/Tabs.stories.tsx +29 -0
- package/src/components/primitives/Tabs.tsx +102 -0
- package/src/components/primitives/Toolbar.stories.tsx +17 -0
- package/src/components/primitives/Toolbar.tsx +14 -0
- package/src/components/primitives/Tree.stories.tsx +124 -0
- package/src/components/primitives/Tree.tsx +100 -0
- package/src/components/primitives/frame.test.ts +12 -0
- package/src/components/primitives/frame.ts +17 -0
- package/src/components/primitives/icons.ts +61 -0
- package/src/components/primitives/tree-model.test.ts +99 -0
- package/src/components/primitives/tree-model.ts +137 -0
- package/src/components/quick-actions-editor.stories.tsx +58 -0
- package/src/components/quick-actions-editor.test.tsx +117 -0
- package/src/components/quick-actions-editor.tsx +226 -0
- package/src/components/share-dialog-store.ts +58 -0
- package/src/components/stories-app.tsx +193 -0
- package/src/components/stories.test.tsx +74 -0
- package/src/components/story-fixtures.ts +102 -0
- package/src/components/story-tree.test.ts +108 -0
- package/src/components/story.ts +159 -0
- package/src/components/syntax-highlight.ts +133 -0
- package/src/components/terminal-pane.test.tsx +40 -0
- package/src/components/terminal-pane.ts +199 -0
- package/src/components/theme-context.stories.tsx +46 -0
- package/src/components/theme-context.tsx +37 -0
- package/src/components/thread-tree.test.ts +134 -0
- package/src/components/thread-tree.ts +109 -0
- package/src/components/truncate-text.ts +7 -0
- package/src/components/truncate-title.test.ts +31 -0
- package/src/components/truncate-title.ts +15 -0
- package/src/config-identity.test.ts +78 -0
- package/src/config.test.ts +579 -34
- package/src/config.ts +533 -57
- package/src/diff-fold.test.ts +78 -0
- package/src/diff-fold.ts +126 -0
- package/src/diff-hunk-curate.test.ts +315 -0
- package/src/diff-hunk-curate.ts +153 -0
- package/src/diff-intraline.test.ts +238 -0
- package/src/diff-intraline.ts +236 -0
- package/src/diff-syntax.test.ts +112 -0
- package/src/diff-syntax.ts +179 -0
- package/src/discussions.ts +79 -0
- package/src/editor.test.ts +149 -0
- package/src/editor.ts +139 -9
- package/src/ghostty-terminal.test.ts +85 -0
- package/src/ghostty-terminal.ts +199 -0
- package/src/github-identity.test.ts +58 -0
- package/src/github-identity.ts +41 -0
- package/src/herdr.test.ts +9 -34
- package/src/herdr.ts +25 -21
- package/src/index.ts +29 -0
- package/src/integrations.test.ts +41 -0
- package/src/integrations.ts +32 -0
- package/src/intent-dispatch.test.ts +724 -0
- package/src/intent-dispatch.ts +690 -0
- package/src/key-bindings.test.ts +156 -0
- package/src/key-bindings.ts +459 -0
- package/src/keymap.test.ts +456 -115
- package/src/keymap.ts +315 -81
- package/src/kitty-image.test.ts +66 -0
- package/src/kitty-image.ts +138 -0
- package/src/launch-layout.test.ts +78 -0
- package/src/launch-layout.ts +62 -0
- package/src/mark-runs.ts +121 -0
- package/src/markdown-editor-syntax-style.ts +50 -0
- package/src/markdown-grid.test.ts +101 -0
- package/src/markdown-grid.ts +203 -0
- package/src/markdown-highlight.test.ts +61 -0
- package/src/markdown-highlight.ts +122 -0
- package/src/pasted-image.test.ts +67 -0
- package/src/pasted-image.ts +60 -0
- package/src/perf/otlp-trace.test.ts +116 -0
- package/src/perf/otlp-trace.ts +130 -0
- package/src/perf/perf-report.ts +17 -0
- package/src/perf/perf-timings.test.ts +18 -0
- package/src/perf/perf-timings.ts +38 -0
- package/src/prototype-browser.test.ts +81 -0
- package/src/prototype-browser.ts +329 -0
- package/src/prototype-pixels.tsx +35 -0
- package/src/pty.test.ts +81 -0
- package/src/pty.ts +245 -0
- package/src/ready-signal.test.ts +85 -0
- package/src/ready-signal.ts +52 -0
- package/src/run.ts +85 -12
- package/src/serve.test.ts +24 -12
- package/src/serve.ts +52 -10
- package/src/share.ts +237 -0
- package/src/skills.test.ts +74 -0
- package/src/skills.ts +75 -0
- package/src/slash-palette.test.ts +103 -0
- package/src/slash-palette.ts +129 -0
- package/src/split-diff.test.ts +110 -0
- package/src/split-diff.ts +149 -0
- package/src/test-support.test.ts +264 -0
- package/src/test-support.ts +369 -0
- package/src/theme-presets.test.ts +51 -0
- package/src/theme-presets.ts +219 -0
- package/src/theme.ts +88 -19
- package/src/thread-chords.test.ts +79 -0
- package/src/thread-chords.ts +163 -0
- package/src/thread-code-syntax.ts +45 -0
- package/src/thread-controller.curate.test.ts +476 -0
- package/src/thread-controller.derivation.test.ts +62 -0
- package/src/thread-controller.live-diff.test.ts +322 -0
- package/src/thread-controller.optimistic.test.ts +69 -0
- package/src/thread-controller.share.test.ts +561 -0
- package/src/thread-controller.switch-cache.test.ts +176 -0
- package/src/thread-controller.tree.test.ts +261 -0
- package/src/thread-controller.ts +2050 -0
- package/src/thread-selection.test.ts +210 -0
- package/src/thread-selection.ts +243 -0
- package/src/tree-view.test.ts +128 -0
- package/src/tree-view.ts +153 -0
- package/src/use-annotation-surface.tsx +975 -0
- package/src/use-changes-workbench.ts +190 -0
- package/src/use-frame-measure.ts +41 -0
- package/src/use-remember-layout.ts +31 -0
- package/src/use-repo-changes.ts +87 -0
- package/src/use-settings-dialog.tsx +364 -0
- package/src/use-terminal-virtualizer.ts +259 -0
- package/src/use-thread-body-editing.tsx +58 -0
- package/src/version.ts +5 -0
- package/src/view-diff.test.ts +279 -9
- package/src/view-diff.ts +460 -24
- package/src/view-plan.regression.test.ts +60 -0
- package/src/view-plan.test.ts +545 -0
- package/src/view-plan.ts +1019 -0
- package/src/visual/contrast.test.ts +35 -0
- package/src/visual/contrast.ts +33 -0
- package/src/walk.test.ts +121 -0
- package/src/walk.ts +74 -0
- package/src/workbench-snapshot.test.ts +99 -0
- package/src/workbench-snapshot.ts +59 -0
- package/src/session-controller.ts +0 -398
- package/src/syntax.test.ts +0 -41
- package/src/syntax.ts +0 -88
- package/src/view.test.ts +0 -157
- package/src/view.ts +0 -364
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
// Renders the Changes pane's editor grid: recursive branches split into rows/columns, each leaf an
|
|
2
|
+
// editor group with its own tab strip and header controls. The caller supplies renderTab so the grid
|
|
3
|
+
// stays layout-only - a tab becomes a diff, a file's contents, or the aggregate Changes view upstream.
|
|
4
|
+
|
|
5
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
6
|
+
import { useKeyboard } from "@opentui/react";
|
|
7
|
+
import type { BoxRenderable } from "@opentui/core";
|
|
8
|
+
import { DARK, type Theme } from "../theme";
|
|
9
|
+
import { useFrameMeasure } from "../use-frame-measure";
|
|
10
|
+
import { useRootOverlay } from "./RootOverlay";
|
|
11
|
+
import { useMenuControl } from "./menu-control";
|
|
12
|
+
import { IconButton } from "./primitives/IconButton";
|
|
13
|
+
import { NERD, HEADER_UNDERLINE_CHARS } from "./primitives/icons";
|
|
14
|
+
import type { EditorGroup, EditorNode, EditorTab, SplitDirection } from "./editor-grid";
|
|
15
|
+
|
|
16
|
+
export interface EditorGridProps {
|
|
17
|
+
tree: EditorNode;
|
|
18
|
+
focusedGroupId: string | null;
|
|
19
|
+
onFocusGroup: (groupId: string) => void;
|
|
20
|
+
onActivateTab: (groupId: string, tabId: string) => void;
|
|
21
|
+
onCloseTab: (groupId: string, tabId: string) => void;
|
|
22
|
+
onSplit: (groupId: string, direction: SplitDirection) => void;
|
|
23
|
+
onZoom: () => void;
|
|
24
|
+
zoomed: boolean;
|
|
25
|
+
/** The body of a group's active tab; `groupFocused` tells a keyboard-owning body whether it has the keys. */
|
|
26
|
+
renderTab: (tab: EditorTab, groupFocused: boolean) => React.ReactNode;
|
|
27
|
+
/** Comments per file path, shown as a dot-and-count badge on a file tab. */
|
|
28
|
+
commentCounts?: ReadonlyMap<string, number>;
|
|
29
|
+
theme?: Theme;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const SPLIT_ITEMS: ReadonlyArray<{ label: string; direction: SplitDirection; arrow: string }> = [
|
|
33
|
+
{ label: "left", direction: "left", arrow: "←" },
|
|
34
|
+
{ label: "right", direction: "right", arrow: "→" },
|
|
35
|
+
{ label: "up", direction: "up", arrow: "↑" },
|
|
36
|
+
{ label: "down", direction: "down", arrow: "↓" },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
const MENU_WIDTH = 12;
|
|
40
|
+
|
|
41
|
+
/** An arrow key maps straight to its split direction; other keys leave the menu untouched. */
|
|
42
|
+
function splitDirectionForKey(name: string): SplitDirection | null {
|
|
43
|
+
const item = SPLIT_ITEMS.find((entry) => entry.direction === name);
|
|
44
|
+
|
|
45
|
+
return item ? item.direction : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function EditorTabButton({
|
|
49
|
+
tab,
|
|
50
|
+
active,
|
|
51
|
+
commentCount,
|
|
52
|
+
onSelect,
|
|
53
|
+
onClose,
|
|
54
|
+
tokens,
|
|
55
|
+
}: {
|
|
56
|
+
tab: EditorTab;
|
|
57
|
+
active: boolean;
|
|
58
|
+
/** Comments on this tab's file, shown as a dot-and-count badge; 0 shows nothing. */
|
|
59
|
+
commentCount: number;
|
|
60
|
+
onSelect: () => void;
|
|
61
|
+
onClose: () => void;
|
|
62
|
+
tokens: Theme;
|
|
63
|
+
}): React.ReactNode {
|
|
64
|
+
const [hovered, setHovered] = useState(false);
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<box
|
|
68
|
+
id={tab.id}
|
|
69
|
+
onMouseUp={onSelect}
|
|
70
|
+
onMouseOver={() => setHovered(true)}
|
|
71
|
+
onMouseOut={() => setHovered(false)}
|
|
72
|
+
style={{
|
|
73
|
+
flexDirection: "row",
|
|
74
|
+
// a tab keeps the width of its name; the strip scrolls when the row overflows
|
|
75
|
+
flexShrink: 0,
|
|
76
|
+
paddingLeft: 1,
|
|
77
|
+
paddingRight: 1,
|
|
78
|
+
backgroundColor: active ? tokens.background : undefined,
|
|
79
|
+
borderStyle: "single",
|
|
80
|
+
border: ["right"],
|
|
81
|
+
borderColor: tokens.border,
|
|
82
|
+
}}
|
|
83
|
+
>
|
|
84
|
+
<text fg={active ? tokens.textMuted : tokens.textDim} style={{ wrapMode: "none" }}>
|
|
85
|
+
{tab.label}
|
|
86
|
+
</text>
|
|
87
|
+
{commentCount > 0 ? (
|
|
88
|
+
<text
|
|
89
|
+
fg={tokens.textDim}
|
|
90
|
+
style={{ flexShrink: 0, wrapMode: "none" }}
|
|
91
|
+
>{` ● ${commentCount}`}</text>
|
|
92
|
+
) : null}
|
|
93
|
+
<box
|
|
94
|
+
onMouseUp={(event) => {
|
|
95
|
+
event.stopPropagation();
|
|
96
|
+
onClose();
|
|
97
|
+
}}
|
|
98
|
+
style={{ paddingLeft: 1 }}
|
|
99
|
+
>
|
|
100
|
+
<text fg={tokens.textDim}>{hovered ? NERD.close : " "}</text>
|
|
101
|
+
</box>
|
|
102
|
+
</box>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Cells a tab occupies: padding, label, the ` ● N` comment badge when present, the close cell, the right rule. */
|
|
107
|
+
export function tabCellWidth(tab: EditorTab, commentCount = 0): number {
|
|
108
|
+
const badge = commentCount > 0 ? 3 + String(commentCount).length : 0;
|
|
109
|
+
|
|
110
|
+
return tab.label.length + 5 + badge;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The run of tabs `[first, end)` the strip shows. */
|
|
114
|
+
export interface TabWindow {
|
|
115
|
+
first: number;
|
|
116
|
+
end: number;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The run of tabs that fits the strip and contains the active tab; markers take a cell each side. */
|
|
120
|
+
export function visibleTabWindow(
|
|
121
|
+
tabs: EditorTab[],
|
|
122
|
+
firstVisible: number,
|
|
123
|
+
activeIndex: number,
|
|
124
|
+
stripWidth: number,
|
|
125
|
+
commentCount: (tab: EditorTab) => number = () => 0,
|
|
126
|
+
): TabWindow {
|
|
127
|
+
// unmeasured strip: show everything, the header clips until the width is known
|
|
128
|
+
if (stripWidth <= 0) return { first: 0, end: tabs.length };
|
|
129
|
+
const width = (index: number): number => tabCellWidth(tabs[index]!, commentCount(tabs[index]!));
|
|
130
|
+
const fitEnd = (first: number): number => {
|
|
131
|
+
let used = first > 0 ? 1 : 0;
|
|
132
|
+
let end = first;
|
|
133
|
+
|
|
134
|
+
while (end < tabs.length) {
|
|
135
|
+
const remaining = end + 1 < tabs.length ? 1 : 0;
|
|
136
|
+
|
|
137
|
+
if (used + width(end) + remaining > stripWidth) break;
|
|
138
|
+
used += width(end);
|
|
139
|
+
end++;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// always show at least the tab the window starts on, even when it is wider than the strip
|
|
143
|
+
return Math.max(end, Math.min(first + 1, tabs.length));
|
|
144
|
+
};
|
|
145
|
+
let first = Math.min(Math.max(0, firstVisible), Math.max(0, activeIndex));
|
|
146
|
+
let end = fitEnd(first);
|
|
147
|
+
|
|
148
|
+
// the active tab past the right edge pulls the window along until it fits
|
|
149
|
+
while (activeIndex >= end && first < activeIndex) {
|
|
150
|
+
first++;
|
|
151
|
+
end = fitEnd(first);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return { first, end };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function SplitMenu({
|
|
158
|
+
onPick,
|
|
159
|
+
tokens,
|
|
160
|
+
}: {
|
|
161
|
+
onPick: (direction: SplitDirection) => void;
|
|
162
|
+
tokens: Theme;
|
|
163
|
+
}): React.ReactNode {
|
|
164
|
+
return (
|
|
165
|
+
<box
|
|
166
|
+
onMouseUp={(event) => event.stopPropagation()}
|
|
167
|
+
style={{
|
|
168
|
+
width: MENU_WIDTH,
|
|
169
|
+
flexDirection: "column",
|
|
170
|
+
borderStyle: "single",
|
|
171
|
+
borderColor: tokens.border,
|
|
172
|
+
backgroundColor: tokens.elevated,
|
|
173
|
+
}}
|
|
174
|
+
>
|
|
175
|
+
{SPLIT_ITEMS.map((item) => (
|
|
176
|
+
<box
|
|
177
|
+
key={item.direction}
|
|
178
|
+
onMouseUp={() => onPick(item.direction)}
|
|
179
|
+
style={{ flexDirection: "row", paddingLeft: 1, paddingRight: 1 }}
|
|
180
|
+
>
|
|
181
|
+
<text fg={tokens.text}>{item.label}</text>
|
|
182
|
+
<box style={{ flexGrow: 1 }} />
|
|
183
|
+
<text fg={tokens.textDim}>{item.arrow}</text>
|
|
184
|
+
</box>
|
|
185
|
+
))}
|
|
186
|
+
</box>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function EditorGroupPane({
|
|
191
|
+
group,
|
|
192
|
+
props,
|
|
193
|
+
}: {
|
|
194
|
+
group: EditorGroup;
|
|
195
|
+
props: EditorGridProps;
|
|
196
|
+
}): React.ReactNode {
|
|
197
|
+
const tokens = props.theme ?? DARK;
|
|
198
|
+
const menuControl = useMenuControl();
|
|
199
|
+
const { setOverlay, clearOverlay } = useRootOverlay();
|
|
200
|
+
const menuId = `split:${group.id}`;
|
|
201
|
+
const menuOpen = menuControl.openMenuId === menuId;
|
|
202
|
+
const splitRef = useRef<BoxRenderable | null>(null);
|
|
203
|
+
const anchor = useFrameMeasure(
|
|
204
|
+
() => ({
|
|
205
|
+
x: splitRef.current?.x ?? 0,
|
|
206
|
+
y: splitRef.current?.y ?? 0,
|
|
207
|
+
width: splitRef.current?.width ?? 0,
|
|
208
|
+
}),
|
|
209
|
+
(left, right) => left.x === right.x && left.y === right.y && left.width === right.width,
|
|
210
|
+
{ x: 0, y: 0, width: 0 },
|
|
211
|
+
menuOpen,
|
|
212
|
+
);
|
|
213
|
+
const active = group.tabs.find((tab) => tab.id === group.activeTabId) ?? group.tabs[0];
|
|
214
|
+
const isFile = active?.kind === "file";
|
|
215
|
+
|
|
216
|
+
useKeyboard((key) => {
|
|
217
|
+
if (!menuOpen) return;
|
|
218
|
+
if (key.name === "escape") return menuControl.closeMenu();
|
|
219
|
+
const direction = splitDirectionForKey(key.name);
|
|
220
|
+
if (direction) {
|
|
221
|
+
props.onSplit(group.id, direction);
|
|
222
|
+
menuControl.closeMenu();
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
useEffect(() => {
|
|
227
|
+
if (!menuOpen) return;
|
|
228
|
+
setOverlay(
|
|
229
|
+
"split-menu",
|
|
230
|
+
<box
|
|
231
|
+
onMouseUp={menuControl.closeMenu}
|
|
232
|
+
style={{ position: "absolute", left: 0, top: 0, width: "100%", height: "100%" }}
|
|
233
|
+
>
|
|
234
|
+
<box
|
|
235
|
+
style={{
|
|
236
|
+
position: "absolute",
|
|
237
|
+
top: anchor.y + 1,
|
|
238
|
+
left: Math.max(0, anchor.x + anchor.width - MENU_WIDTH),
|
|
239
|
+
}}
|
|
240
|
+
>
|
|
241
|
+
<SplitMenu
|
|
242
|
+
onPick={(direction) => {
|
|
243
|
+
props.onSplit(group.id, direction);
|
|
244
|
+
menuControl.closeMenu();
|
|
245
|
+
}}
|
|
246
|
+
tokens={tokens}
|
|
247
|
+
/>
|
|
248
|
+
</box>
|
|
249
|
+
</box>,
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
return () => clearOverlay("split-menu");
|
|
253
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
254
|
+
}, [menuOpen, anchor, tokens]);
|
|
255
|
+
// The strip windows its tabs rather than scrolling a nested scrollbox (which stalls the
|
|
256
|
+
// renderer inside this header): tabs keep the width of their names, only the run that fits
|
|
257
|
+
// renders, and the window follows the active tab so a tab opened past the edge is never hidden.
|
|
258
|
+
const stripRef = useRef<BoxRenderable | null>(null);
|
|
259
|
+
const stripWidth = useFrameMeasure(
|
|
260
|
+
() => stripRef.current?.width ?? 0,
|
|
261
|
+
(left, right) => left === right,
|
|
262
|
+
0,
|
|
263
|
+
);
|
|
264
|
+
const [firstVisible, setFirstVisible] = useState(0);
|
|
265
|
+
// a zoom re-anchors the strip to the leftmost tab, so the new width fills from the left
|
|
266
|
+
// and the step markers disappear when every tab now fits
|
|
267
|
+
const [wasZoomed, setWasZoomed] = useState(props.zoomed);
|
|
268
|
+
|
|
269
|
+
if (wasZoomed !== props.zoomed) {
|
|
270
|
+
setWasZoomed(props.zoomed);
|
|
271
|
+
setFirstVisible(0);
|
|
272
|
+
}
|
|
273
|
+
const activeIndex = Math.max(
|
|
274
|
+
0,
|
|
275
|
+
group.tabs.findIndex((tab) => tab.id === active?.id),
|
|
276
|
+
);
|
|
277
|
+
// derived every render: the manual offset is a floor the active tab can pull the window past
|
|
278
|
+
const tabWindow = visibleTabWindow(group.tabs, firstVisible, activeIndex, stripWidth, (tab) =>
|
|
279
|
+
tab.path ? (props.commentCounts?.get(tab.path) ?? 0) : 0,
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
return (
|
|
283
|
+
<box
|
|
284
|
+
onMouseDown={() => props.onFocusGroup(group.id)}
|
|
285
|
+
style={{ flexDirection: "column", flexGrow: 1, flexBasis: 0, minWidth: 0, minHeight: 0 }}
|
|
286
|
+
>
|
|
287
|
+
<box
|
|
288
|
+
customBorderChars={HEADER_UNDERLINE_CHARS}
|
|
289
|
+
style={{
|
|
290
|
+
flexDirection: "row",
|
|
291
|
+
// pin the controls to the right edge so their column never shifts with the container
|
|
292
|
+
// width parity; a lone flexGrow tab strip would drag them a cell on zoom
|
|
293
|
+
justifyContent: "space-between",
|
|
294
|
+
height: 2,
|
|
295
|
+
// match the pane headers: the panel token is transparent in the branded theme, so the
|
|
296
|
+
// header reads as terminal-through rather than a raised box
|
|
297
|
+
backgroundColor: tokens.panel,
|
|
298
|
+
borderStyle: "single",
|
|
299
|
+
border: ["bottom"],
|
|
300
|
+
borderColor: tokens.border,
|
|
301
|
+
}}
|
|
302
|
+
>
|
|
303
|
+
{/* the tab strip windows its tabs: the run that fits renders in full, edge markers step
|
|
304
|
+
the window, and the controls stay pinned on the right */}
|
|
305
|
+
<box
|
|
306
|
+
ref={stripRef}
|
|
307
|
+
style={{ flexDirection: "row", flexGrow: 1, flexShrink: 1, flexBasis: 0, minWidth: 0 }}
|
|
308
|
+
>
|
|
309
|
+
{tabWindow.first > 0 ? (
|
|
310
|
+
<box
|
|
311
|
+
onMouseUp={(event) => {
|
|
312
|
+
event.stopPropagation();
|
|
313
|
+
setFirstVisible(tabWindow.first - 1);
|
|
314
|
+
}}
|
|
315
|
+
style={{ flexShrink: 0 }}
|
|
316
|
+
>
|
|
317
|
+
<text fg={tokens.textDim} selectable={false}>
|
|
318
|
+
{"‹"}
|
|
319
|
+
</text>
|
|
320
|
+
</box>
|
|
321
|
+
) : null}
|
|
322
|
+
{group.tabs.slice(tabWindow.first, tabWindow.end).map((tab) => (
|
|
323
|
+
<EditorTabButton
|
|
324
|
+
key={tab.id}
|
|
325
|
+
tab={tab}
|
|
326
|
+
active={tab.id === active?.id}
|
|
327
|
+
commentCount={tab.path ? (props.commentCounts?.get(tab.path) ?? 0) : 0}
|
|
328
|
+
onSelect={() => props.onActivateTab(group.id, tab.id)}
|
|
329
|
+
onClose={() => props.onCloseTab(group.id, tab.id)}
|
|
330
|
+
tokens={tokens}
|
|
331
|
+
/>
|
|
332
|
+
))}
|
|
333
|
+
{tabWindow.end < group.tabs.length ? (
|
|
334
|
+
<box
|
|
335
|
+
onMouseUp={(event) => {
|
|
336
|
+
event.stopPropagation();
|
|
337
|
+
setFirstVisible(tabWindow.first + 1);
|
|
338
|
+
}}
|
|
339
|
+
style={{ flexShrink: 0 }}
|
|
340
|
+
>
|
|
341
|
+
<text fg={tokens.textDim} selectable={false}>
|
|
342
|
+
{"›"}
|
|
343
|
+
</text>
|
|
344
|
+
</box>
|
|
345
|
+
) : null}
|
|
346
|
+
</box>
|
|
347
|
+
<box style={{ flexDirection: "row", flexShrink: 0, paddingLeft: 1, paddingRight: 1 }}>
|
|
348
|
+
{/* search sits out until it does something; split takes its place */}
|
|
349
|
+
{isFile ? (
|
|
350
|
+
<box ref={splitRef} style={{ flexShrink: 0, marginRight: 2 }}>
|
|
351
|
+
<IconButton
|
|
352
|
+
glyph="split"
|
|
353
|
+
active={menuOpen}
|
|
354
|
+
onPress={() => menuControl.toggleMenu(menuId)}
|
|
355
|
+
theme={tokens}
|
|
356
|
+
/>
|
|
357
|
+
</box>
|
|
358
|
+
) : null}
|
|
359
|
+
<IconButton
|
|
360
|
+
glyph={NERD.zoom}
|
|
361
|
+
active={props.zoomed}
|
|
362
|
+
onPress={props.onZoom}
|
|
363
|
+
tip={props.zoomed ? "Zoom Out" : "Zoom In"}
|
|
364
|
+
theme={tokens}
|
|
365
|
+
/>
|
|
366
|
+
</box>
|
|
367
|
+
</box>
|
|
368
|
+
<box style={{ flexGrow: 1, flexDirection: "column" }}>
|
|
369
|
+
{active ? props.renderTab(active, props.focusedGroupId === group.id) : null}
|
|
370
|
+
</box>
|
|
371
|
+
</box>
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function GridNode({ node, props }: { node: EditorNode; props: EditorGridProps }): React.ReactNode {
|
|
376
|
+
if (node.type === "group") {
|
|
377
|
+
return <EditorGroupPane group={node} props={props} />;
|
|
378
|
+
}
|
|
379
|
+
const tokens = props.theme ?? DARK;
|
|
380
|
+
const horizontal = node.orientation === "horizontal";
|
|
381
|
+
return (
|
|
382
|
+
<box
|
|
383
|
+
style={{
|
|
384
|
+
flexDirection: horizontal ? "row" : "column",
|
|
385
|
+
flexGrow: 1,
|
|
386
|
+
flexBasis: 0,
|
|
387
|
+
minWidth: 0,
|
|
388
|
+
minHeight: 0,
|
|
389
|
+
}}
|
|
390
|
+
>
|
|
391
|
+
{node.children.map((child, index) => (
|
|
392
|
+
<React.Fragment key={child.id}>
|
|
393
|
+
{index > 0 ? (
|
|
394
|
+
<box
|
|
395
|
+
style={{
|
|
396
|
+
borderStyle: "single",
|
|
397
|
+
border: [horizontal ? "left" : "top"],
|
|
398
|
+
borderColor: tokens.border,
|
|
399
|
+
}}
|
|
400
|
+
/>
|
|
401
|
+
) : null}
|
|
402
|
+
<box
|
|
403
|
+
style={{
|
|
404
|
+
flexDirection: "column",
|
|
405
|
+
flexGrow: 1,
|
|
406
|
+
flexBasis: 0,
|
|
407
|
+
minWidth: 0,
|
|
408
|
+
minHeight: 0,
|
|
409
|
+
}}
|
|
410
|
+
>
|
|
411
|
+
<GridNode node={child} props={props} />
|
|
412
|
+
</box>
|
|
413
|
+
</React.Fragment>
|
|
414
|
+
))}
|
|
415
|
+
</box>
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** The Changes pane body: the editor grid of groups and splits, each rendering its active tab. */
|
|
420
|
+
export function EditorGrid(props: EditorGridProps): React.ReactNode {
|
|
421
|
+
return (
|
|
422
|
+
<box style={{ flexDirection: "column", flexGrow: 1, minWidth: 0, minHeight: 0 }}>
|
|
423
|
+
<GridNode node={props.tree} props={props} />
|
|
424
|
+
</box>
|
|
425
|
+
);
|
|
426
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression: the header controls (search, zoom, split) stay pinned to the right
|
|
3
|
+
* edge whatever the container width parity. A lone flexGrow tab strip used to
|
|
4
|
+
* underfill by a cell at even widths, dragging the zoom icon a column left when
|
|
5
|
+
* zoom widened the pane; space-between pins the controls so the column is stable.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from "bun:test";
|
|
9
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
10
|
+
import React from "react";
|
|
11
|
+
import { EditorGrid } from "./EditorGrid";
|
|
12
|
+
import { changesTab, fileTab, makeGroup } from "./editor-grid";
|
|
13
|
+
import { allowEventLoopUpdates } from "../test-support";
|
|
14
|
+
|
|
15
|
+
const grid = makeGroup([changesTab(), fileTab("App.tsx", "src/App.tsx", "diff")]);
|
|
16
|
+
|
|
17
|
+
async function zoomIconInsetFromRight(width: number, zoomed: boolean): Promise<number> {
|
|
18
|
+
const setup = await testRender(
|
|
19
|
+
<EditorGrid
|
|
20
|
+
tree={grid}
|
|
21
|
+
focusedGroupId={grid.id}
|
|
22
|
+
onFocusGroup={() => {}}
|
|
23
|
+
onActivateTab={() => {}}
|
|
24
|
+
onCloseTab={() => {}}
|
|
25
|
+
onSplit={() => {}}
|
|
26
|
+
onZoom={() => {}}
|
|
27
|
+
zoomed={zoomed}
|
|
28
|
+
renderTab={() => <text>body</text>}
|
|
29
|
+
/>,
|
|
30
|
+
{ width, height: 6 },
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
allowEventLoopUpdates();
|
|
34
|
+
await setup.waitForVisualIdle();
|
|
35
|
+
const line = setup
|
|
36
|
+
.captureCharFrame()
|
|
37
|
+
.split("\n")
|
|
38
|
+
.find((row) => row.includes("⛶"));
|
|
39
|
+
const inset = line ? width - line.indexOf("⛶") : -1;
|
|
40
|
+
|
|
41
|
+
setup.renderer.destroy();
|
|
42
|
+
|
|
43
|
+
return inset;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe("zoom icon position", () => {
|
|
47
|
+
test("stays a fixed inset from the right edge across width parities", async () => {
|
|
48
|
+
// Act - even and odd widths flip the flexGrow rounding the old layout was sensitive to
|
|
49
|
+
const insets = await Promise.all([
|
|
50
|
+
zoomIconInsetFromRight(100, false),
|
|
51
|
+
zoomIconInsetFromRight(101, false),
|
|
52
|
+
zoomIconInsetFromRight(102, false),
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
// Assert - one inset for all widths: the icon is pinned right, not drifting with parity
|
|
56
|
+
expect(new Set(insets).size).toBe(1);
|
|
57
|
+
expect(insets[0]).toBeGreaterThan(0);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("does not shift when zoom becomes active", async () => {
|
|
61
|
+
// Act
|
|
62
|
+
const inactive = await zoomIconInsetFromRight(100, false);
|
|
63
|
+
const active = await zoomIconInsetFromRight(100, true);
|
|
64
|
+
|
|
65
|
+
// Assert - active only recolours the glyph; it must not move
|
|
66
|
+
expect(active).toBe(inactive);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { ClientExtensionRegistry } from "../client-extension-registry";
|
|
3
|
+
import type { Story, StoryMeta } from "./story";
|
|
4
|
+
import { ExtensionZone } from "./ExtensionZone";
|
|
5
|
+
|
|
6
|
+
export const meta: StoryMeta = { title: "Extensions/ExtensionZone" };
|
|
7
|
+
|
|
8
|
+
const registry = new ClientExtensionRegistry();
|
|
9
|
+
|
|
10
|
+
void registry.load("example", (api) => {
|
|
11
|
+
api.registerSection({
|
|
12
|
+
id: "summary",
|
|
13
|
+
zone: "threads.sidebar",
|
|
14
|
+
title: "Summary",
|
|
15
|
+
Component: () => <text>Two pending Threads</text>,
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export const SidebarSection: Story = {
|
|
20
|
+
render: () => (
|
|
21
|
+
<ExtensionZone
|
|
22
|
+
zone="threads.sidebar"
|
|
23
|
+
registry={registry}
|
|
24
|
+
context={{ workspace: "/repo", threadId: null }}
|
|
25
|
+
/>
|
|
26
|
+
),
|
|
27
|
+
size: { width: 32, height: 8 },
|
|
28
|
+
};
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import React, { useEffect, useMemo, useSyncExternalStore } from "react";
|
|
2
|
+
import type { ExtensionUIContext } from "@cueloop/extension-api/client";
|
|
3
|
+
import type {
|
|
4
|
+
ClientExtensionRegistry,
|
|
5
|
+
ClientExtensionSnapshot,
|
|
6
|
+
} from "../client-extension-registry";
|
|
7
|
+
import { useComponentTheme } from "./theme-context";
|
|
8
|
+
import { IconButton } from "./primitives/IconButton";
|
|
9
|
+
import { NERD } from "./primitives/icons";
|
|
10
|
+
|
|
11
|
+
/** Subscribe to the client extension registry without coupling extensions to React state. */
|
|
12
|
+
export function useClientExtensionSnapshot(
|
|
13
|
+
registry: ClientExtensionRegistry,
|
|
14
|
+
): ClientExtensionSnapshot {
|
|
15
|
+
return useSyncExternalStore(registry.subscribe, registry.snapshot, registry.snapshot);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface VisibleContributions<T> {
|
|
19
|
+
items: T[];
|
|
20
|
+
errors: string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Evaluate visibility without letting one extension remove another's UI. */
|
|
24
|
+
export function visibleContributions<
|
|
25
|
+
T extends {
|
|
26
|
+
key: string;
|
|
27
|
+
value: { when?: (context: ExtensionUIContext) => boolean };
|
|
28
|
+
},
|
|
29
|
+
>(contributions: readonly T[], context: ExtensionUIContext): VisibleContributions<T> {
|
|
30
|
+
const items: T[] = [];
|
|
31
|
+
const errors: string[] = [];
|
|
32
|
+
|
|
33
|
+
for (const contribution of contributions) {
|
|
34
|
+
try {
|
|
35
|
+
if (!contribution.value.when || contribution.value.when(context)) items.push(contribution);
|
|
36
|
+
} catch (error) {
|
|
37
|
+
errors.push(`Extension ${contribution.key} visibility failed: ${String(error)}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return { items, errors };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
type VisibilityContribution = {
|
|
45
|
+
key: string;
|
|
46
|
+
value: { when?: (context: ExtensionUIContext) => boolean };
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
class ExtensionRenderBoundary extends React.Component<
|
|
50
|
+
{ children: React.ReactNode; onError?: (message: string) => void },
|
|
51
|
+
{ failed: boolean }
|
|
52
|
+
> {
|
|
53
|
+
state = { failed: false };
|
|
54
|
+
|
|
55
|
+
static getDerivedStateFromError() {
|
|
56
|
+
return { failed: true };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
componentDidCatch(error: Error): void {
|
|
60
|
+
console.error("Client extension rendering failed", error);
|
|
61
|
+
this.props.onError?.(`Extension rendering failed: ${error.message}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
render(): React.ReactNode {
|
|
65
|
+
if (this.state.failed) return <text>Extension failed</text>;
|
|
66
|
+
|
|
67
|
+
return this.props.children;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Render host-framed sections, host buttons, or the selected workspace view. */
|
|
72
|
+
export function ExtensionZone(props: {
|
|
73
|
+
zone: "threads.sidebar" | "thread.header" | "workspace.panels";
|
|
74
|
+
registry: ClientExtensionRegistry;
|
|
75
|
+
context: ExtensionUIContext;
|
|
76
|
+
selectedViewId?: string | null;
|
|
77
|
+
onError?: (message: string) => void;
|
|
78
|
+
}): React.ReactNode {
|
|
79
|
+
const { zone, registry, context, selectedViewId, onError } = props;
|
|
80
|
+
const snapshot = useClientExtensionSnapshot(registry);
|
|
81
|
+
const theme = useComponentTheme();
|
|
82
|
+
const { workspace, threadId } = context;
|
|
83
|
+
const visibility = useMemo(() => {
|
|
84
|
+
const contributions: readonly VisibilityContribution[] =
|
|
85
|
+
zone === "threads.sidebar"
|
|
86
|
+
? snapshot.sections
|
|
87
|
+
: zone === "thread.header"
|
|
88
|
+
? snapshot.actions
|
|
89
|
+
: [];
|
|
90
|
+
|
|
91
|
+
return visibleContributions(contributions, { workspace, threadId });
|
|
92
|
+
}, [zone, snapshot, workspace, threadId]);
|
|
93
|
+
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
for (const error of visibility.errors) onError?.(error);
|
|
96
|
+
}, [visibility, onError]);
|
|
97
|
+
|
|
98
|
+
const visibleKeys = new Set(visibility.items.map(({ key }) => key));
|
|
99
|
+
|
|
100
|
+
if (zone === "threads.sidebar") {
|
|
101
|
+
return snapshot.sections
|
|
102
|
+
.filter(({ key }) => visibleKeys.has(key))
|
|
103
|
+
.map(({ key, value }) => (
|
|
104
|
+
<box key={key} style={{ flexDirection: "column", maxHeight: 8, flexShrink: 0 }}>
|
|
105
|
+
<text fg={theme.textDim}>{value.title}</text>
|
|
106
|
+
<ExtensionRenderBoundary onError={onError}>
|
|
107
|
+
{React.createElement(value.Component, { context })}
|
|
108
|
+
</ExtensionRenderBoundary>
|
|
109
|
+
</box>
|
|
110
|
+
));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (zone === "thread.header") {
|
|
114
|
+
return snapshot.actions
|
|
115
|
+
.filter(({ key }) => visibleKeys.has(key))
|
|
116
|
+
.map(({ key, value }) => (
|
|
117
|
+
<IconButton
|
|
118
|
+
key={key}
|
|
119
|
+
glyph={value.glyph ?? NERD.kebab}
|
|
120
|
+
tip={value.label}
|
|
121
|
+
onPress={() => {
|
|
122
|
+
try {
|
|
123
|
+
void Promise.resolve(value.onPress(context)).catch((error) => {
|
|
124
|
+
console.error(`Client extension action failed: ${key}`, error);
|
|
125
|
+
onError?.(`Extension action failed: ${value.label}`);
|
|
126
|
+
});
|
|
127
|
+
} catch (error) {
|
|
128
|
+
console.error(`Client extension action failed: ${key}`, error);
|
|
129
|
+
onError?.(`Extension action failed: ${value.label}`);
|
|
130
|
+
}
|
|
131
|
+
}}
|
|
132
|
+
marginRight={1}
|
|
133
|
+
theme={theme}
|
|
134
|
+
/>
|
|
135
|
+
));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const selected = snapshot.views.find(({ key }) => key === selectedViewId);
|
|
139
|
+
|
|
140
|
+
return selected ? (
|
|
141
|
+
<ExtensionRenderBoundary key={selected.key} onError={onError}>
|
|
142
|
+
{React.createElement(selected.value.Component, { context })}
|
|
143
|
+
</ExtensionRenderBoundary>
|
|
144
|
+
) : null;
|
|
145
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { DARK } from "../theme";
|
|
3
|
+
import type { Story, StoryMeta } from "./story";
|
|
4
|
+
import { FileContentsView } from "./FileContentsView";
|
|
5
|
+
|
|
6
|
+
export const meta: StoryMeta = { title: "Surfaces/FileContentsView" };
|
|
7
|
+
|
|
8
|
+
const SAMPLE = `export function add(a: number, b: number): number {\n return a + b;\n}\n`;
|
|
9
|
+
|
|
10
|
+
export const Loaded: Story = {
|
|
11
|
+
render: () => (
|
|
12
|
+
<FileContentsView path="src/add.ts" loadContents={() => Promise.resolve(SAMPLE)} theme={DARK} />
|
|
13
|
+
),
|
|
14
|
+
expectedColors: [DARK.textDim],
|
|
15
|
+
size: { width: 60, height: 12 },
|
|
16
|
+
};
|