@cueloop/client 0.1.0-alpha.10 → 0.1.0-alpha.101
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/annotations/annotation-palette.ts +62 -0
- package/src/annotations/components/AnnotationCards.stories.tsx +86 -0
- package/src/annotations/components/AnnotationCards.test.tsx +42 -0
- package/src/annotations/components/AnnotationCards.tsx +369 -0
- package/src/annotations/components/DiscussionMarkerRail.stories.tsx +54 -0
- package/src/annotations/components/DiscussionMarkerRail.tsx +186 -0
- package/src/annotations/discussions.ts +77 -0
- package/src/annotations/thread-selection.test.ts +210 -0
- package/src/annotations/thread-selection.ts +242 -0
- package/src/annotations/use-annotation-surface.tsx +1243 -0
- package/src/app/App.appearance.test.tsx +53 -0
- package/src/{App.autoclose.test.tsx → app/App.autoclose.test.tsx} +77 -51
- package/src/app/App.deleted-scroll.test.tsx +187 -0
- package/src/app/App.diff.test.tsx +313 -0
- package/src/app/App.export.test.tsx +105 -0
- package/src/app/App.focus.test.ts +89 -0
- package/src/app/App.identity.test.tsx +84 -0
- package/src/app/App.plan-render.test.tsx +209 -0
- package/src/app/App.plan-review.test.tsx +428 -0
- package/src/app/App.prototype-markdown.test.tsx +128 -0
- package/src/app/App.prototype.test.tsx +176 -0
- package/src/app/App.readonly.test.tsx +210 -0
- package/src/app/App.regression.test.tsx +99 -0
- package/src/app/App.rendering.test.tsx +134 -0
- package/src/app/App.share-dialog.test.tsx +177 -0
- package/src/app/App.submit-confirm.test.tsx +267 -0
- package/src/app/App.test.tsx +496 -0
- package/src/app/App.thread-agent.test.ts +20 -0
- package/src/app/App.toast.test.tsx +124 -0
- package/src/app/App.tsx +1667 -0
- package/src/app/App.visual.test.tsx +59 -0
- package/src/app/App.walk.test.tsx +299 -0
- package/src/app/App.workbench.test.tsx +1149 -0
- package/src/app/app-key-state.ts +61 -0
- package/src/app/app-screens.tsx +606 -0
- package/src/app/app-view-model.ts +168 -0
- package/src/app/components/AppHeader.stories.tsx +50 -0
- package/src/app/components/AppHeader.tsx +81 -0
- package/src/app/components/AppShell.brand.test.tsx +58 -0
- package/src/app/components/AppShell.extensions.test.tsx +191 -0
- package/src/app/components/AppShell.stories.tsx +89 -0
- package/src/app/components/AppShell.tsx +358 -0
- package/src/app/components/AppShell.zoom.test.tsx +149 -0
- package/src/app/components/Breadcrumb.stories.tsx +19 -0
- package/src/app/components/Breadcrumb.tsx +45 -0
- package/src/app/components/MenuBar.stories.tsx +28 -0
- package/src/app/components/MenuBar.tsx +44 -0
- package/src/app/components/PaneGrid.stories.tsx +84 -0
- package/src/app/components/PaneGrid.tsx +128 -0
- package/src/app/components/PanelColumn.stories.tsx +25 -0
- package/src/app/components/PanelColumn.tsx +93 -0
- package/src/app/components/ShellHeader.stories.tsx +50 -0
- package/src/app/components/ShellHeader.tsx +137 -0
- package/src/app/components/WorkspacePanels.stories.tsx +36 -0
- package/src/app/components/WorkspacePanels.tsx +120 -0
- package/src/app/components/pages/Workspace.stories.tsx +181 -0
- package/src/app/intent-dispatch.test.ts +728 -0
- package/src/app/intent-dispatch.ts +687 -0
- package/src/app/launch-layout.test.ts +78 -0
- package/src/app/launch-layout.ts +57 -0
- package/src/app/ready-signal.test.ts +85 -0
- package/src/app/ready-signal.ts +52 -0
- package/src/app/run.ts +109 -0
- package/src/{serve.test.ts → app/serve.test.ts} +24 -12
- package/src/app/serve.ts +111 -0
- package/src/app/version.ts +5 -0
- package/src/appearance/components/theme-context.stories.tsx +46 -0
- package/src/appearance/components/theme-context.tsx +37 -0
- package/src/appearance/theme-presets.test.ts +51 -0
- package/src/appearance/theme-presets.ts +219 -0
- package/src/appearance/theme.ts +112 -0
- package/src/appearance/visual/contrast.test.ts +35 -0
- package/src/appearance/visual/contrast.ts +33 -0
- package/src/diff/components/AnnotatableFileView.stories.tsx +42 -0
- package/src/diff/components/AnnotatableFileView.test.tsx +157 -0
- package/src/diff/components/AnnotatableFileView.tsx +141 -0
- package/src/diff/components/BareWorkbenchFileView.stories.tsx +27 -0
- package/src/diff/components/BareWorkbenchFileView.tsx +63 -0
- package/src/diff/components/DiffContentView.scroll.test.tsx +434 -0
- package/src/diff/components/DiffContentView.split.test.tsx +140 -0
- package/src/diff/components/DiffContentView.stories.tsx +80 -0
- package/src/diff/components/DiffContentView.syntax.test.tsx +82 -0
- package/src/diff/components/DiffContentView.tsx +1055 -0
- package/src/diff/components/WalkWizard.stories.tsx +100 -0
- package/src/diff/components/WalkWizard.tsx +146 -0
- package/src/diff/components/diff-content-view-layout.test.ts +198 -0
- package/src/diff/components/diff-content-view-layout.ts +84 -0
- package/src/diff/diff-fold.test.ts +78 -0
- package/src/diff/diff-fold.ts +126 -0
- package/src/diff/diff-hunk-curate.test.ts +315 -0
- package/src/diff/diff-hunk-curate.ts +152 -0
- package/src/diff/diff-intraline.test.ts +239 -0
- package/src/diff/diff-intraline.ts +238 -0
- package/src/diff/diff-syntax.test.ts +125 -0
- package/src/diff/diff-syntax.ts +186 -0
- package/src/diff/split-diff.test.ts +110 -0
- package/src/diff/split-diff.ts +144 -0
- package/src/diff/view-diff.test.ts +325 -0
- package/src/diff/view-diff.ts +522 -0
- package/src/diff/walk.test.ts +121 -0
- package/src/diff/walk.ts +73 -0
- package/src/index.ts +31 -2
- package/src/integrations/attribution.test.ts +31 -0
- package/src/integrations/attribution.ts +42 -0
- package/src/integrations/client-extension-registry.test.tsx +61 -0
- package/src/integrations/client-extension-registry.ts +159 -0
- package/src/integrations/clipboard.test.ts +23 -0
- package/src/integrations/clipboard.ts +33 -0
- package/src/integrations/components/ExtensionZone.stories.tsx +28 -0
- package/src/integrations/components/ExtensionZone.tsx +145 -0
- package/src/integrations/components/ShareDialog.agent.test.tsx +50 -0
- package/src/integrations/components/ShareDialog.stories.tsx +121 -0
- package/src/integrations/components/ShareDialog.tsx +574 -0
- package/src/integrations/components/share-dialog-store.ts +59 -0
- package/src/integrations/editor.test.ts +149 -0
- package/src/integrations/editor.ts +160 -0
- package/src/integrations/github-identity.test.ts +58 -0
- package/src/integrations/github-identity.ts +41 -0
- package/src/integrations/herdr.test.ts +43 -0
- package/src/integrations/herdr.ts +38 -0
- package/src/integrations/integrations.test.ts +41 -0
- package/src/integrations/integrations.ts +32 -0
- package/src/integrations/pasted-image.test.ts +67 -0
- package/src/integrations/pasted-image.ts +60 -0
- package/src/integrations/share.ts +238 -0
- package/src/keyboard/components/CompletionOverlay.stories.tsx +37 -0
- package/src/keyboard/components/CompletionOverlay.tsx +75 -0
- package/src/keyboard/components/HandleChipsInput.stories.tsx +24 -0
- package/src/keyboard/components/HandleChipsInput.test.tsx +35 -0
- package/src/keyboard/components/HandleChipsInput.tsx +143 -0
- package/src/keyboard/components/KeybindsDialog.stories.tsx +31 -0
- package/src/keyboard/components/KeybindsDialog.tsx +103 -0
- package/src/keyboard/components/NavModeHint.stories.tsx +40 -0
- package/src/keyboard/components/NavModeHint.tsx +31 -0
- package/src/keyboard/components/SlashComposer.stories.tsx +37 -0
- package/src/keyboard/components/SlashComposer.tsx +87 -0
- package/src/keyboard/key-bindings.test.ts +156 -0
- package/src/keyboard/key-bindings.ts +459 -0
- package/src/keyboard/keymap.test.ts +611 -0
- package/src/keyboard/keymap.ts +395 -0
- package/src/keyboard/skills.test.ts +74 -0
- package/src/keyboard/skills.ts +75 -0
- package/src/keyboard/slash-palette.test.ts +103 -0
- package/src/keyboard/slash-palette.ts +129 -0
- package/src/keyboard/thread-chords.test.ts +79 -0
- package/src/keyboard/thread-chords.ts +163 -0
- package/src/keyboard/use-shared-keyboard.ts +55 -0
- package/src/markdown/__snapshots__/view-plan.regression.test.ts.snap +100 -0
- package/src/markdown/components/MarkdownGridBlock.stories.tsx +33 -0
- package/src/markdown/components/MarkdownGridBlock.tsx +72 -0
- package/src/markdown/components/MarkdownThreadEditor.stories.tsx +15 -0
- package/src/markdown/components/MarkdownThreadEditor.test.tsx +98 -0
- package/src/markdown/components/MarkdownThreadEditor.tsx +160 -0
- package/src/markdown/components/ThreadView.selection.test.tsx +951 -0
- package/src/markdown/components/ThreadView.slash.test.tsx +146 -0
- package/src/markdown/components/ThreadView.stories.tsx +26 -0
- package/src/markdown/components/ThreadView.tsx +560 -0
- package/src/markdown/mark-runs.ts +121 -0
- package/src/markdown/markdown-editor-syntax-style.ts +50 -0
- package/src/markdown/markdown-grid.test.ts +101 -0
- package/src/markdown/markdown-grid.ts +203 -0
- package/src/markdown/markdown-highlight.test.ts +61 -0
- package/src/markdown/markdown-highlight.ts +124 -0
- package/src/markdown/thread-code-syntax.ts +45 -0
- package/src/markdown/use-thread-body-editing.tsx +54 -0
- package/src/markdown/view-plan.regression.test.ts +60 -0
- package/src/markdown/view-plan.test.ts +545 -0
- package/src/markdown/view-plan.ts +1009 -0
- package/src/performance/otlp-trace.test.ts +119 -0
- package/src/performance/otlp-trace.ts +134 -0
- package/src/performance/perf-report.ts +18 -0
- package/src/performance/perf-timings.test.ts +18 -0
- package/src/performance/perf-timings.ts +39 -0
- package/src/prototype/components/PrototypeContentView.stories.tsx +19 -0
- package/src/prototype/components/PrototypeContentView.tsx +442 -0
- package/src/prototype/prototype-browser.test.ts +81 -0
- package/src/prototype/prototype-browser.ts +321 -0
- package/src/prototype/prototype-pixels.tsx +35 -0
- package/src/settings/components/SettingsDialog.stories.tsx +117 -0
- package/src/settings/components/SettingsDialog.tsx +183 -0
- package/src/settings/components/SettingsRows.stories.tsx +18 -0
- package/src/settings/components/SettingsRows.tsx +112 -0
- package/src/settings/components/quick-actions-editor.stories.tsx +58 -0
- package/src/settings/components/quick-actions-editor.test.tsx +120 -0
- package/src/settings/components/quick-actions-editor.tsx +222 -0
- package/src/settings/config-identity.test.ts +78 -0
- package/src/settings/config.test.ts +676 -0
- package/src/settings/config.ts +628 -0
- package/src/settings/use-settings-dialog.tsx +351 -0
- package/src/stories/__snapshots__/stories.test.tsx.snap +2358 -0
- package/src/stories/stories-app.tsx +196 -0
- package/src/stories/stories.test.tsx +74 -0
- package/src/stories/story-fixtures.ts +102 -0
- package/src/stories/story-tree.test.ts +108 -0
- package/src/stories/story.ts +154 -0
- package/src/terminal/ansi-screen.test.ts +28 -0
- package/src/terminal/ansi-screen.ts +72 -0
- package/src/terminal/components/AnsiScreen.stories.tsx +12 -0
- package/src/terminal/components/AnsiScreen.tsx +28 -0
- package/src/terminal/components/terminal-pane.test.tsx +40 -0
- package/src/terminal/components/terminal-pane.ts +196 -0
- package/src/terminal/ghostty-terminal.test.ts +85 -0
- package/src/terminal/ghostty-terminal.ts +197 -0
- package/src/terminal/kitty-image.test.ts +66 -0
- package/src/terminal/kitty-image.ts +137 -0
- package/src/terminal/pty.test.ts +81 -0
- package/src/terminal/pty.ts +246 -0
- package/src/testing/test-support.test.ts +264 -0
- package/src/testing/test-support.ts +367 -0
- package/src/thread/agent-continuation.test.ts +32 -0
- package/src/thread/agent-continuation.ts +13 -0
- package/src/thread/agent-transcript.test.ts +161 -0
- package/src/thread/agent-transcript.ts +336 -0
- package/src/thread/components/AgentConfigControls.stories.tsx +39 -0
- package/src/thread/components/AgentConfigControls.test.tsx +112 -0
- package/src/thread/components/AgentConfigControls.tsx +114 -0
- package/src/thread/components/AgentThreadPane.stories.tsx +105 -0
- package/src/thread/components/AgentThreadPane.test.tsx +935 -0
- package/src/thread/components/AgentThreadPane.tsx +377 -0
- package/src/thread/components/ThreadFooter.stories.tsx +30 -0
- package/src/thread/components/ThreadFooter.test.tsx +49 -0
- package/src/thread/components/ThreadFooter.tsx +77 -0
- package/src/thread/components/ThreadTree.stories.tsx +46 -0
- package/src/thread/components/ThreadTree.test.tsx +41 -0
- package/src/thread/components/ThreadTree.tsx +267 -0
- package/src/thread/components/thread-tree.test.ts +137 -0
- package/src/thread/components/thread-tree.ts +112 -0
- package/src/thread/thread-controller.curate.test.ts +502 -0
- package/src/thread/thread-controller.derivation.test.ts +62 -0
- package/src/thread/thread-controller.live-diff.test.ts +357 -0
- package/src/thread/thread-controller.optimistic.test.ts +69 -0
- package/src/thread/thread-controller.share.test.ts +573 -0
- package/src/thread/thread-controller.switch-cache.test.ts +174 -0
- package/src/thread/thread-controller.tree.test.ts +263 -0
- package/src/thread/thread-controller.ts +2026 -0
- package/src/thread/tree-view.test.ts +128 -0
- package/src/thread/tree-view.ts +146 -0
- package/src/thread/use-thread-agent.test.tsx +208 -0
- package/src/thread/use-thread-agent.ts +117 -0
- package/src/ui/components/CodeBlock.stories.tsx +42 -0
- package/src/ui/components/CodeBlock.tsx +80 -0
- package/src/ui/components/ConfirmCard.stories.tsx +37 -0
- package/src/ui/components/ConfirmCard.tsx +151 -0
- package/src/ui/components/ConfirmDialog.stories.tsx +21 -0
- package/src/ui/components/ConfirmDialog.tsx +72 -0
- package/src/ui/components/OverlayScrollbar.stories.tsx +33 -0
- package/src/ui/components/OverlayScrollbar.test.tsx +139 -0
- package/src/ui/components/OverlayScrollbar.tsx +129 -0
- package/src/ui/components/PromptDialog.stories.tsx +36 -0
- package/src/ui/components/PromptDialog.test.tsx +65 -0
- package/src/ui/components/PromptDialog.tsx +99 -0
- package/src/ui/components/RootOverlay.stories.tsx +45 -0
- package/src/ui/components/RootOverlay.tsx +49 -0
- package/src/ui/components/ScrollArea.stories.tsx +20 -0
- package/src/ui/components/ScrollArea.tsx +61 -0
- package/src/ui/components/Toast.stories.tsx +19 -0
- package/src/ui/components/Toast.tsx +54 -0
- package/src/ui/components/Tooltip.stories.tsx +26 -0
- package/src/ui/components/Tooltip.tsx +72 -0
- package/src/ui/components/menu-control.stories.tsx +35 -0
- package/src/ui/components/menu-control.tsx +43 -0
- package/src/ui/components/primitives/Button.stories.tsx +32 -0
- package/src/ui/components/primitives/Button.tsx +57 -0
- package/src/ui/components/primitives/Card.stories.tsx +25 -0
- package/src/ui/components/primitives/Card.tsx +62 -0
- package/src/ui/components/primitives/Dialog.stories.tsx +24 -0
- package/src/ui/components/primitives/Dialog.test.tsx +38 -0
- package/src/ui/components/primitives/Dialog.tsx +69 -0
- package/src/ui/components/primitives/DialogActions.stories.tsx +20 -0
- package/src/ui/components/primitives/DialogActions.tsx +39 -0
- package/src/ui/components/primitives/IconButton.stories.tsx +36 -0
- package/src/ui/components/primitives/IconButton.tsx +58 -0
- package/src/ui/components/primitives/StatusBar.stories.tsx +11 -0
- package/src/ui/components/primitives/StatusBar.tsx +23 -0
- package/src/ui/components/primitives/Switch.stories.tsx +17 -0
- package/src/ui/components/primitives/Switch.tsx +32 -0
- package/src/ui/components/primitives/Tabs.stories.tsx +29 -0
- package/src/ui/components/primitives/Tabs.tsx +101 -0
- package/src/ui/components/primitives/Toolbar.stories.tsx +17 -0
- package/src/ui/components/primitives/Toolbar.tsx +14 -0
- package/src/ui/components/primitives/Tree.stories.tsx +124 -0
- package/src/ui/components/primitives/Tree.tsx +133 -0
- package/src/ui/components/primitives/frame.test.ts +12 -0
- package/src/ui/components/primitives/frame.ts +17 -0
- package/src/ui/components/primitives/icons.ts +61 -0
- package/src/ui/components/primitives/tree-model.test.ts +99 -0
- package/src/ui/components/primitives/tree-model.ts +137 -0
- package/src/ui/components/syntax-highlight.ts +133 -0
- package/src/ui/components/truncate-text.ts +7 -0
- package/src/ui/components/truncate-title.test.ts +31 -0
- package/src/ui/components/truncate-title.ts +15 -0
- package/src/ui/use-frame-measure.ts +74 -0
- package/src/ui/use-terminal-virtualizer.ts +259 -0
- package/src/workbench/components/ChangesColumn.stories.tsx +44 -0
- package/src/workbench/components/ChangesColumn.tsx +203 -0
- package/src/workbench/components/ChangesFileTree.test.tsx +181 -0
- package/src/workbench/components/EditorGrid.split.test.tsx +118 -0
- package/src/workbench/components/EditorGrid.stories.tsx +86 -0
- package/src/workbench/components/EditorGrid.tabs.test.tsx +118 -0
- package/src/workbench/components/EditorGrid.tsx +544 -0
- package/src/workbench/components/EditorGrid.zoom.test.tsx +132 -0
- package/src/workbench/components/FileContentsView.stories.tsx +16 -0
- package/src/workbench/components/FileContentsView.test.tsx +29 -0
- package/src/workbench/components/FileContentsView.tsx +100 -0
- package/src/workbench/components/GridTabContent.actions.test.tsx +151 -0
- package/src/workbench/components/GridTabContent.stories.tsx +59 -0
- package/src/workbench/components/GridTabContent.tsx +193 -0
- package/src/workbench/components/ProjectTreeView.stories.tsx +25 -0
- package/src/workbench/components/ProjectTreeView.test.tsx +236 -0
- package/src/workbench/components/ProjectTreeView.tsx +151 -0
- package/src/workbench/components/TreePane.stories.tsx +106 -0
- package/src/workbench/components/TreePane.tsx +155 -0
- package/src/workbench/components/WelcomePlayground.stories.tsx +12 -0
- package/src/workbench/components/WelcomePlayground.test.tsx +83 -0
- package/src/workbench/components/WelcomePlayground.tsx +138 -0
- package/src/workbench/components/editor-grid.test.ts +184 -0
- package/src/workbench/components/editor-grid.ts +281 -0
- package/src/workbench/components/file-tree.test.ts +60 -0
- package/src/workbench/components/file-tree.ts +112 -0
- package/src/workbench/use-changes-workbench.ts +205 -0
- package/src/workbench/use-remember-layout.ts +31 -0
- package/src/workbench/use-repo-changes.ts +91 -0
- package/src/workbench/workbench-snapshot.test.ts +99 -0
- package/src/workbench/workbench-snapshot.ts +59 -0
- package/src/App.diff.test.tsx +0 -99
- package/src/App.export.test.tsx +0 -88
- package/src/App.plan-review.test.tsx +0 -259
- package/src/App.readonly.test.tsx +0 -148
- package/src/App.rendering.test.tsx +0 -108
- package/src/App.submit-confirm.test.tsx +0 -183
- package/src/App.test.tsx +0 -218
- package/src/App.tsx +0 -1249
- package/src/config.test.ts +0 -116
- package/src/config.ts +0 -150
- package/src/editor.ts +0 -34
- package/src/herdr.test.ts +0 -68
- package/src/herdr.ts +0 -34
- package/src/keymap.test.ts +0 -270
- package/src/keymap.ts +0 -173
- package/src/run.ts +0 -30
- package/src/serve.ts +0 -75
- package/src/session-controller.ts +0 -398
- package/src/syntax.test.ts +0 -41
- package/src/syntax.ts +0 -88
- package/src/theme.ts +0 -46
- package/src/view-diff.test.ts +0 -55
- package/src/view-diff.ts +0 -86
- package/src/view.test.ts +0 -157
- package/src/view.ts +0 -364
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The welcome page is a real annotation surface, but ephemeral: selecting its copy and typing leaves
|
|
3
|
+
* a comment that shows inline, exactly as a file does, and it lives only in the component - there is
|
|
4
|
+
* no controller or daemon behind it, so nothing is persisted. The copy prompts the two first gestures.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from "bun:test";
|
|
8
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
9
|
+
import React from "react";
|
|
10
|
+
import { WelcomePlayground } from "./WelcomePlayground";
|
|
11
|
+
import { DARK } from "../../appearance/theme";
|
|
12
|
+
import { CLIENT_VERSION } from "../../app/version";
|
|
13
|
+
import {
|
|
14
|
+
dragText,
|
|
15
|
+
locateText,
|
|
16
|
+
press,
|
|
17
|
+
typeText,
|
|
18
|
+
pressKey,
|
|
19
|
+
waitForText,
|
|
20
|
+
} from "../../testing/test-support";
|
|
21
|
+
|
|
22
|
+
describe("WelcomePlayground", () => {
|
|
23
|
+
test("the copy prompts the select-and-type and the slash gestures", async () => {
|
|
24
|
+
const setup = await testRender(<WelcomePlayground quickActions={[]} theme={DARK} />, {
|
|
25
|
+
width: 80,
|
|
26
|
+
height: 24,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
await waitForText(setup, "Getting started");
|
|
30
|
+
const frame = setup.captureCharFrame();
|
|
31
|
+
|
|
32
|
+
expect(frame).toContain("start typing to leave your first comment");
|
|
33
|
+
expect(frame).toContain('type "/"');
|
|
34
|
+
expect(frame).toContain(`cueloop v${CLIENT_VERSION}`);
|
|
35
|
+
|
|
36
|
+
setup.renderer.destroy();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("selecting the copy and typing leaves a comment inline, exactly like a file", async () => {
|
|
40
|
+
const setup = await testRender(<WelcomePlayground quickActions={[]} theme={DARK} />, {
|
|
41
|
+
width: 80,
|
|
42
|
+
height: 24,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Act - select the practice line, type a comment, send it
|
|
46
|
+
await waitForText(setup, "quick brown fox");
|
|
47
|
+
await dragText(setup, "quick brown fox", "quick brown fox", "quick brown fox".length);
|
|
48
|
+
await typeText(setup, "my first note");
|
|
49
|
+
await pressKey(setup, "RETURN", { meta: true });
|
|
50
|
+
|
|
51
|
+
// Assert - the note renders on the surface, no controller or daemon involved
|
|
52
|
+
await waitForText(setup, "my first note");
|
|
53
|
+
|
|
54
|
+
setup.renderer.destroy();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("the caret marker remains visible when Up crosses an empty line", async () => {
|
|
58
|
+
const setup = await testRender(<WelcomePlayground quickActions={[]} theme={DARK} />, {
|
|
59
|
+
width: 100,
|
|
60
|
+
height: 28,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
await waitForText(setup, "Start");
|
|
64
|
+
const start = locateText(setup, "Start");
|
|
65
|
+
|
|
66
|
+
await setup.mockMouse.click(start.column, start.row);
|
|
67
|
+
await press(setup, "escape");
|
|
68
|
+
|
|
69
|
+
const markerRows = () =>
|
|
70
|
+
setup
|
|
71
|
+
.captureCharFrame()
|
|
72
|
+
.split("\n")
|
|
73
|
+
.flatMap((line, row) => (line.includes("▎") ? [row] : []));
|
|
74
|
+
|
|
75
|
+
expect(markerRows()).toEqual([start.row]);
|
|
76
|
+
await press(setup, "up");
|
|
77
|
+
expect(markerRows()).toEqual([start.row - 1]);
|
|
78
|
+
await press(setup, "up");
|
|
79
|
+
expect(markerRows()).toEqual([start.row - 2]);
|
|
80
|
+
|
|
81
|
+
setup.renderer.destroy();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// The welcome page as a live annotation playground: the onboarding copy renders through the same
|
|
2
|
+
// diff sheet a file uses, so selecting text and typing leaves a comment with the identical gesture a
|
|
3
|
+
// real review uses. The notes are ephemeral - there is no file or thread behind onboarding copy, so
|
|
4
|
+
// they live only in this component's state and are never stored or re-anchored. The copy teaches the
|
|
5
|
+
// two first gestures: select-and-type to comment, and "/" for the quick actions and skills.
|
|
6
|
+
|
|
7
|
+
import React, { useMemo, useState } from "react";
|
|
8
|
+
import {
|
|
9
|
+
makeAnchor,
|
|
10
|
+
newAnnotationId,
|
|
11
|
+
SCHEMA_VERSION,
|
|
12
|
+
type Annotation,
|
|
13
|
+
type Thread,
|
|
14
|
+
} from "@cueloop/schema";
|
|
15
|
+
import type { Theme } from "../../appearance/theme";
|
|
16
|
+
import type { QuickAction } from "../../settings/config";
|
|
17
|
+
import { CLIENT_VERSION } from "../../app/version";
|
|
18
|
+
import { DiffContentView } from "../../diff/components/DiffContentView";
|
|
19
|
+
import { diffRowBlocks, fileContentsRows, marksByRows } from "../../diff/view-diff";
|
|
20
|
+
|
|
21
|
+
export interface WelcomePlaygroundProps {
|
|
22
|
+
quickActions: QuickAction[];
|
|
23
|
+
onComposingChange?: (composing: boolean) => void;
|
|
24
|
+
suspended?: boolean;
|
|
25
|
+
theme?: Theme;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const DOCS_URL = "https://cueloop.dev/docs/";
|
|
29
|
+
|
|
30
|
+
/** The playground copy: a heading, the two guided prompts, a line to practice on, and the links. */
|
|
31
|
+
function welcomeCopy(version: string): string {
|
|
32
|
+
return [
|
|
33
|
+
"Getting started",
|
|
34
|
+
"Comment on agent-authored work.",
|
|
35
|
+
"",
|
|
36
|
+
"This page is a playground. Nothing you write here is saved.",
|
|
37
|
+
"",
|
|
38
|
+
"Select the line below and start typing to leave your first comment:",
|
|
39
|
+
"",
|
|
40
|
+
" The quick brown fox jumps over the lazy dog.",
|
|
41
|
+
"",
|
|
42
|
+
'While composing, type "/" to see the quick actions and skills.',
|
|
43
|
+
"",
|
|
44
|
+
"Start",
|
|
45
|
+
" - select a thread",
|
|
46
|
+
" - inspect files and changes",
|
|
47
|
+
"",
|
|
48
|
+
"Learn",
|
|
49
|
+
` - docs ${DOCS_URL}`,
|
|
50
|
+
"",
|
|
51
|
+
"Version",
|
|
52
|
+
` - cueloop v${version}`,
|
|
53
|
+
].join("\n");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** An ephemeral thread that holds nothing but the playground's notes, so the surface groups
|
|
57
|
+
* discussions exactly as a real review does without any of it reaching the daemon. */
|
|
58
|
+
function playgroundThread(annotations: Annotation[]): Thread {
|
|
59
|
+
return {
|
|
60
|
+
schemaVersion: SCHEMA_VERSION,
|
|
61
|
+
id: "welcome",
|
|
62
|
+
workspace: { repoRoot: "", branch: "main" },
|
|
63
|
+
artifact: { type: "plan", content: "", meta: {} },
|
|
64
|
+
revisions: [],
|
|
65
|
+
annotations,
|
|
66
|
+
message: null,
|
|
67
|
+
status: "pending",
|
|
68
|
+
createdAt: "1970-01-01T00:00:00.000Z",
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function WelcomePlayground(props: WelcomePlaygroundProps): React.ReactNode {
|
|
73
|
+
const [annotations, setAnnotations] = useState<Annotation[]>([]);
|
|
74
|
+
const rows = useMemo(() => fileContentsRows("welcome", welcomeCopy(CLIENT_VERSION)), []);
|
|
75
|
+
const session = playgroundThread(annotations);
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<DiffContentView
|
|
79
|
+
rows={rows}
|
|
80
|
+
session={session}
|
|
81
|
+
marks={marksByRows(annotations, rows)}
|
|
82
|
+
quickActions={props.quickActions}
|
|
83
|
+
observer={false}
|
|
84
|
+
commentsEnabled
|
|
85
|
+
fileView
|
|
86
|
+
suspended={props.suspended}
|
|
87
|
+
onComposingChange={props.onComposingChange}
|
|
88
|
+
onAnnotate={(span, body) =>
|
|
89
|
+
setAnnotations((current) => [
|
|
90
|
+
...current,
|
|
91
|
+
{
|
|
92
|
+
id: newAnnotationId(),
|
|
93
|
+
kind: "comment",
|
|
94
|
+
anchor: makeAnchor(
|
|
95
|
+
diffRowBlocks(rows),
|
|
96
|
+
span.start.blockIndex,
|
|
97
|
+
span.start.char,
|
|
98
|
+
span.end.char,
|
|
99
|
+
span.end.blockIndex,
|
|
100
|
+
),
|
|
101
|
+
target: { kind: "welcome" },
|
|
102
|
+
body,
|
|
103
|
+
createdAt: new Date().toISOString(),
|
|
104
|
+
},
|
|
105
|
+
])
|
|
106
|
+
}
|
|
107
|
+
onReply={(rootAnnotationId, body) =>
|
|
108
|
+
setAnnotations((current) => {
|
|
109
|
+
const root = current.find((annotation) => annotation.id === rootAnnotationId);
|
|
110
|
+
|
|
111
|
+
if (!root) return current;
|
|
112
|
+
|
|
113
|
+
return [
|
|
114
|
+
...current,
|
|
115
|
+
{
|
|
116
|
+
id: newAnnotationId(),
|
|
117
|
+
kind: "comment",
|
|
118
|
+
anchor: root.anchor,
|
|
119
|
+
target: { kind: "welcome" },
|
|
120
|
+
body,
|
|
121
|
+
replyTo: rootAnnotationId,
|
|
122
|
+
createdAt: new Date().toISOString(),
|
|
123
|
+
},
|
|
124
|
+
];
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
onUpdateAnnotation={(id, body) =>
|
|
128
|
+
setAnnotations((current) =>
|
|
129
|
+
current.map((annotation) =>
|
|
130
|
+
annotation.id === id ? { ...annotation, body } : annotation,
|
|
131
|
+
),
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
onExit={() => {}}
|
|
135
|
+
theme={props.theme}
|
|
136
|
+
/>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
addTab,
|
|
4
|
+
changesTab,
|
|
5
|
+
closeTab,
|
|
6
|
+
countEditorGroups,
|
|
7
|
+
containsGroup,
|
|
8
|
+
fileTab,
|
|
9
|
+
firstGroupId,
|
|
10
|
+
keepFileTab,
|
|
11
|
+
makeGroup,
|
|
12
|
+
MAX_EDITOR_GROUPS,
|
|
13
|
+
openFileTab,
|
|
14
|
+
splitGroup,
|
|
15
|
+
type EditorBranch,
|
|
16
|
+
type EditorGroup,
|
|
17
|
+
type EditorNode,
|
|
18
|
+
} from "./editor-grid";
|
|
19
|
+
|
|
20
|
+
function asBranch(node: EditorNode | null): EditorBranch {
|
|
21
|
+
if (node === null || node.type !== "branch") throw new Error("expected a branch node");
|
|
22
|
+
|
|
23
|
+
return node;
|
|
24
|
+
}
|
|
25
|
+
function asGroup(node: EditorNode | null): EditorGroup {
|
|
26
|
+
if (node === null || node.type !== "group") throw new Error("expected a group node");
|
|
27
|
+
|
|
28
|
+
return node;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("editor grid", () => {
|
|
32
|
+
test("a fresh group holds its tabs with the first active", () => {
|
|
33
|
+
const tab = changesTab();
|
|
34
|
+
const group = makeGroup([tab]);
|
|
35
|
+
|
|
36
|
+
expect(group.type).toBe("group");
|
|
37
|
+
expect(group.activeTabId).toBe(tab.id);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("adding a tab focuses it", () => {
|
|
41
|
+
const group = makeGroup([changesTab()]);
|
|
42
|
+
const file = fileTab("App.tsx", "src/App.tsx", "diff");
|
|
43
|
+
const next = asGroup(addTab(group, group.id, file));
|
|
44
|
+
|
|
45
|
+
expect(next.tabs).toHaveLength(2);
|
|
46
|
+
expect(next.activeTabId).toBe(file.id);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("a new preview replaces the group's previous preview", () => {
|
|
50
|
+
const group = makeGroup([changesTab()]);
|
|
51
|
+
const first = asGroup(openFileTab(group, group.id, "src/a.ts", "contents", false).tree);
|
|
52
|
+
const second = asGroup(openFileTab(first, group.id, "src/b.ts", "contents", false).tree);
|
|
53
|
+
|
|
54
|
+
expect(second.tabs.map((tab) => tab.path)).toEqual([undefined, "src/b.ts"]);
|
|
55
|
+
expect(second.tabs[1]?.preview).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("keeping a preview makes subsequent previews open beside it", () => {
|
|
59
|
+
const group = makeGroup([changesTab()]);
|
|
60
|
+
const preview = asGroup(openFileTab(group, group.id, "src/a.ts", "contents", false).tree);
|
|
61
|
+
const kept = asGroup(keepFileTab(preview, group.id, preview.activeTabId!));
|
|
62
|
+
const next = asGroup(openFileTab(kept, group.id, "src/b.ts", "contents", false).tree);
|
|
63
|
+
|
|
64
|
+
expect(next.tabs.map((tab) => tab.path)).toEqual([undefined, "src/a.ts", "src/b.ts"]);
|
|
65
|
+
expect(next.tabs[1]?.preview).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("reopening a file focuses its existing tab across groups and changes its view", () => {
|
|
69
|
+
const group = makeGroup([changesTab()]);
|
|
70
|
+
const open = asGroup(openFileTab(group, group.id, "src/a.ts", "contents", true).tree);
|
|
71
|
+
const { tree } = splitGroup(open, group.id, "right");
|
|
72
|
+
const branch = asBranch(tree);
|
|
73
|
+
const right = asGroup(branch.children[1]!);
|
|
74
|
+
const reopened = openFileTab(tree, group.id, "src/a.ts", "diff", false);
|
|
75
|
+
|
|
76
|
+
expect(reopened.focusGroupId).toBe(right.id);
|
|
77
|
+
expect(asGroup(asBranch(reopened.tree).children[1]!).tabs).toHaveLength(1);
|
|
78
|
+
expect(asGroup(asBranch(reopened.tree).children[1]!).tabs[0]).toMatchObject({
|
|
79
|
+
path: "src/a.ts",
|
|
80
|
+
fileView: "diff",
|
|
81
|
+
preview: false,
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("splitting right makes a horizontal branch and focuses the new group", () => {
|
|
86
|
+
const group = makeGroup([fileTab("App.tsx", "src/App.tsx", "diff")]);
|
|
87
|
+
const { tree, focusGroupId } = splitGroup(group, group.id, "right");
|
|
88
|
+
|
|
89
|
+
expect(tree.type).toBe("branch");
|
|
90
|
+
const branch = asBranch(tree);
|
|
91
|
+
|
|
92
|
+
expect(branch.orientation).toBe("horizontal");
|
|
93
|
+
expect(branch.children).toHaveLength(2);
|
|
94
|
+
// the new group is the second child (right edge) and takes focus
|
|
95
|
+
expect(branch.children[1]!.id).toBe(focusGroupId);
|
|
96
|
+
expect(firstGroupId(tree)).toBe(group.id);
|
|
97
|
+
expect(asGroup(branch.children[0]!).tabs.some((tab) => tab.path === "src/App.tsx")).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("splitting up makes a vertical branch with the new group first", () => {
|
|
101
|
+
const group = makeGroup([fileTab("a.ts", "a.ts", "contents")]);
|
|
102
|
+
const { tree, focusGroupId } = splitGroup(group, group.id, "up");
|
|
103
|
+
const branch = asBranch(tree);
|
|
104
|
+
|
|
105
|
+
expect(branch.orientation).toBe("vertical");
|
|
106
|
+
expect(branch.children[0]!.id).toBe(focusGroupId);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("splitting a file-only group keeps the Changes tab unique", () => {
|
|
110
|
+
const changes = makeGroup([changesTab()]);
|
|
111
|
+
const file = makeGroup([fileTab("a.ts", "a.ts", "diff")]);
|
|
112
|
+
const tree: EditorNode = {
|
|
113
|
+
type: "branch",
|
|
114
|
+
id: "two-groups",
|
|
115
|
+
orientation: "horizontal",
|
|
116
|
+
children: [changes, file],
|
|
117
|
+
};
|
|
118
|
+
const split = splitGroup(tree, file.id, "down");
|
|
119
|
+
|
|
120
|
+
expect(asBranch(split.tree).children[0]).toBe(changes);
|
|
121
|
+
expect(asBranch(asBranch(split.tree).children[1]!).children[0]).toMatchObject({
|
|
122
|
+
tabs: [{ kind: "welcome" }],
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("splitting Changes moves it to the new group", () => {
|
|
127
|
+
const group = makeGroup([changesTab()]);
|
|
128
|
+
const split = splitGroup(group, group.id, "right");
|
|
129
|
+
const branch = asBranch(split.tree);
|
|
130
|
+
|
|
131
|
+
expect(asGroup(branch.children[0]!).tabs[0]?.kind).toBe("welcome");
|
|
132
|
+
expect(asGroup(branch.children[1]!).tabs[0]?.kind).toBe("changes");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("closing the last tab of a split collapses the branch back to one group", () => {
|
|
136
|
+
const group = makeGroup([fileTab("a.ts", "a.ts", "diff")]);
|
|
137
|
+
const { tree } = splitGroup(group, group.id, "right");
|
|
138
|
+
const branch = asBranch(tree);
|
|
139
|
+
const rightGroup = asGroup(branch.children[1]!);
|
|
140
|
+
const collapsed = closeTab(tree, rightGroup.id, rightGroup.tabs[0]!.id);
|
|
141
|
+
|
|
142
|
+
expect(collapsed?.type).toBe("group");
|
|
143
|
+
expect(asGroup(collapsed).id).toBe(group.id);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("closing the only tab of the whole grid returns null", () => {
|
|
147
|
+
const tab = changesTab();
|
|
148
|
+
const group = makeGroup([tab]);
|
|
149
|
+
|
|
150
|
+
expect(closeTab(group, group.id, tab.id)).toBeNull();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("containsGroup finds a live group and misses a pruned one", () => {
|
|
154
|
+
const group = makeGroup([fileTab("a.ts", "a.ts", "diff")]);
|
|
155
|
+
const { tree } = splitGroup(group, group.id, "right");
|
|
156
|
+
const branch = asBranch(tree);
|
|
157
|
+
const rightGroup = asGroup(branch.children[1]!);
|
|
158
|
+
|
|
159
|
+
expect(containsGroup(tree, group.id)).toBe(true);
|
|
160
|
+
const collapsed = closeTab(tree, rightGroup.id, rightGroup.tabs[0]!.id);
|
|
161
|
+
|
|
162
|
+
expect(containsGroup(asGroup(collapsed), rightGroup.id)).toBe(false);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("mixed split directions stop at eight editor groups", () => {
|
|
166
|
+
const first = makeGroup([changesTab()]);
|
|
167
|
+
let tree: EditorNode = first;
|
|
168
|
+
let focusGroupId = first.id;
|
|
169
|
+
const directions = ["right", "up", "left", "down"] as const;
|
|
170
|
+
|
|
171
|
+
for (const direction of [...directions, ...directions]) {
|
|
172
|
+
const result = splitGroup(tree, focusGroupId, direction);
|
|
173
|
+
|
|
174
|
+
tree = result.tree;
|
|
175
|
+
focusGroupId = result.focusGroupId;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
expect(countEditorGroups(tree)).toBe(MAX_EDITOR_GROUPS);
|
|
179
|
+
const excess = splitGroup(tree, focusGroupId, "right");
|
|
180
|
+
|
|
181
|
+
expect(excess.tree).toBe(tree);
|
|
182
|
+
expect(excess.focusGroupId).toBe(focusGroupId);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
// The Changes pane's editor layout: a tree of nodes where a leaf is an editor group (a tab strip plus
|
|
2
|
+
// its one active tab) and a branch splits child nodes along an orientation. Splitting a file tab
|
|
3
|
+
// moves it into a new group; a group is pruned when its last tab closes, collapsing the
|
|
4
|
+
// branch that held it.
|
|
5
|
+
|
|
6
|
+
/** One editor in a group's tab strip: the Changes tab (the whole diff), a per-file tab, or Welcome. */
|
|
7
|
+
export interface EditorTab {
|
|
8
|
+
id: string;
|
|
9
|
+
kind: "changes" | "file" | "welcome";
|
|
10
|
+
label: string;
|
|
11
|
+
path?: string;
|
|
12
|
+
fileView?: "diff" | "contents";
|
|
13
|
+
preview?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A leaf of the grid: one editor group holding tabs, with the active tab id. */
|
|
17
|
+
export interface EditorGroup {
|
|
18
|
+
type: "group";
|
|
19
|
+
id: string;
|
|
20
|
+
tabs: EditorTab[];
|
|
21
|
+
activeTabId: string | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A branch of the grid: children laid out along one orientation (horizontal = side by side). */
|
|
25
|
+
export interface EditorBranch {
|
|
26
|
+
type: "branch";
|
|
27
|
+
id: string;
|
|
28
|
+
orientation: "horizontal" | "vertical";
|
|
29
|
+
children: EditorNode[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type EditorNode = EditorGroup | EditorBranch;
|
|
33
|
+
|
|
34
|
+
/** Which edge a split places the new group on; Left/Right split horizontally, Up/Down vertically. */
|
|
35
|
+
export type SplitDirection = "left" | "right" | "up" | "down";
|
|
36
|
+
|
|
37
|
+
/** The editor grid stops at eight groups so every tile remains usable. */
|
|
38
|
+
export const MAX_EDITOR_GROUPS = 8;
|
|
39
|
+
|
|
40
|
+
/** A split's outcome: the new grid and the id of the group that should take focus. */
|
|
41
|
+
export interface SplitResult {
|
|
42
|
+
tree: EditorNode;
|
|
43
|
+
focusGroupId: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const freshId = (): string => crypto.randomUUID();
|
|
47
|
+
|
|
48
|
+
/** A new editor group holding the given tabs, with the first tab active. */
|
|
49
|
+
export function makeGroup(tabs: EditorTab[]): EditorGroup {
|
|
50
|
+
return { type: "group", id: freshId(), tabs, activeTabId: tabs[0]?.id ?? null };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The Changes tab: the whole diff in one scroll, the disposable default of the Changes pane. */
|
|
54
|
+
export function changesTab(): EditorTab {
|
|
55
|
+
return { id: freshId(), kind: "changes", label: "Changes" };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The Welcome tab: the getting-started surface, a disposable tab shown before any thread is opened. */
|
|
59
|
+
export function welcomeTab(): EditorTab {
|
|
60
|
+
return { id: freshId(), kind: "welcome", label: "Welcome" };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A file tab, opened from a tree; a diff view for a changed file, plain contents otherwise. */
|
|
64
|
+
export function fileTab(
|
|
65
|
+
label: string,
|
|
66
|
+
path: string,
|
|
67
|
+
fileView: "diff" | "contents",
|
|
68
|
+
preview = false,
|
|
69
|
+
): EditorTab {
|
|
70
|
+
return { id: freshId(), kind: "file", label, path, fileView, preview };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The id of the first editor group in reading order; the fallback focus target. */
|
|
74
|
+
export function firstGroupId(node: EditorNode): string {
|
|
75
|
+
return node.type === "group" ? node.id : firstGroupId(node.children[0]!);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Whether a group id still exists in the grid; used to re-home focus after a prune. */
|
|
79
|
+
export function containsGroup(node: EditorNode, groupId: string): boolean {
|
|
80
|
+
if (node.type === "group") return node.id === groupId;
|
|
81
|
+
|
|
82
|
+
return node.children.some((child) => containsGroup(child, groupId));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Count editor tiles, including groups inside nested horizontal and vertical splits. */
|
|
86
|
+
export function countEditorGroups(node: EditorNode): number {
|
|
87
|
+
if (node.type === "group") return 1;
|
|
88
|
+
|
|
89
|
+
return node.children.reduce((count, child) => count + countEditorGroups(child), 0);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function replaceGroup(
|
|
93
|
+
node: EditorNode,
|
|
94
|
+
id: string,
|
|
95
|
+
fn: (group: EditorGroup) => EditorNode,
|
|
96
|
+
): EditorNode {
|
|
97
|
+
if (node.type === "group") return node.id === id ? fn(node) : node;
|
|
98
|
+
|
|
99
|
+
return { ...node, children: node.children.map((child) => replaceGroup(child, id, fn)) };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Make a tab the active one in its group. */
|
|
103
|
+
export function activateTab(node: EditorNode, groupId: string, tabId: string): EditorNode {
|
|
104
|
+
return replaceGroup(node, groupId, (group) => ({ ...group, activeTabId: tabId }));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Append a tab to a group and focus it. */
|
|
108
|
+
export function addTab(node: EditorNode, groupId: string, tab: EditorTab): EditorNode {
|
|
109
|
+
return replaceGroup(node, groupId, (group) => ({
|
|
110
|
+
...group,
|
|
111
|
+
tabs: [...group.tabs, tab],
|
|
112
|
+
activeTabId: tab.id,
|
|
113
|
+
}));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Activate the one Changes tab, or add it beside the active group's existing tabs. */
|
|
117
|
+
export function openChangesTab(node: EditorNode, groupId: string): SplitResult {
|
|
118
|
+
const existing = findTabByKind(node, "changes");
|
|
119
|
+
|
|
120
|
+
if (existing) {
|
|
121
|
+
return {
|
|
122
|
+
tree: activateTab(node, existing.groupId, existing.tab.id),
|
|
123
|
+
focusGroupId: existing.groupId,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return { tree: addTab(node, groupId, changesTab()), focusGroupId: groupId };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function findTabByKind(
|
|
131
|
+
node: EditorNode,
|
|
132
|
+
kind: EditorTab["kind"],
|
|
133
|
+
): { groupId: string; tab: EditorTab } | null {
|
|
134
|
+
if (node.type === "group") {
|
|
135
|
+
const tab = node.tabs.find((candidate) => candidate.kind === kind);
|
|
136
|
+
|
|
137
|
+
return tab ? { groupId: node.id, tab } : null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
for (const child of node.children) {
|
|
141
|
+
const found = findTabByKind(child, kind);
|
|
142
|
+
|
|
143
|
+
if (found) return found;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
interface FileTabLocation {
|
|
150
|
+
groupId: string;
|
|
151
|
+
tab: EditorTab;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** A file path is one editor tab across the grid, even when the groups are split. */
|
|
155
|
+
function findFileTab(node: EditorNode, path: string): FileTabLocation | null {
|
|
156
|
+
if (node.type === "group") {
|
|
157
|
+
const tab = node.tabs.find((candidate) => candidate.kind === "file" && candidate.path === path);
|
|
158
|
+
|
|
159
|
+
return tab ? { groupId: node.id, tab } : null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
for (const child of node.children) {
|
|
163
|
+
const found = findFileTab(child, path);
|
|
164
|
+
|
|
165
|
+
if (found) return found;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Open a file once, replacing only a preview; reopening focuses its existing tab. */
|
|
172
|
+
export function openFileTab(
|
|
173
|
+
node: EditorNode,
|
|
174
|
+
groupId: string,
|
|
175
|
+
path: string,
|
|
176
|
+
fileView: "diff" | "contents",
|
|
177
|
+
persistent: boolean,
|
|
178
|
+
): SplitResult {
|
|
179
|
+
const existing = findFileTab(node, path);
|
|
180
|
+
|
|
181
|
+
if (existing) {
|
|
182
|
+
return {
|
|
183
|
+
tree: replaceGroup(node, existing.groupId, (group) => ({
|
|
184
|
+
...group,
|
|
185
|
+
tabs: group.tabs.map((tab) =>
|
|
186
|
+
tab.id === existing.tab.id
|
|
187
|
+
? { ...tab, fileView, preview: persistent ? false : tab.preview }
|
|
188
|
+
: tab,
|
|
189
|
+
),
|
|
190
|
+
activeTabId: existing.tab.id,
|
|
191
|
+
})),
|
|
192
|
+
focusGroupId: existing.groupId,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const label = path.split("/").pop() ?? path;
|
|
197
|
+
const tab = fileTab(label, path, fileView, !persistent);
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
tree: replaceGroup(node, groupId, (group) => {
|
|
201
|
+
const previewIndex = persistent ? -1 : group.tabs.findIndex((item) => item.preview);
|
|
202
|
+
const tabs = [...group.tabs];
|
|
203
|
+
|
|
204
|
+
if (previewIndex >= 0) tabs.splice(previewIndex, 1, tab);
|
|
205
|
+
else tabs.push(tab);
|
|
206
|
+
|
|
207
|
+
return { ...group, tabs, activeTabId: tab.id };
|
|
208
|
+
}),
|
|
209
|
+
focusGroupId: groupId,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Keep a preview file tab open when its tab is double-clicked. */
|
|
214
|
+
export function keepFileTab(node: EditorNode, groupId: string, tabId: string): EditorNode {
|
|
215
|
+
return replaceGroup(node, groupId, (group) => ({
|
|
216
|
+
...group,
|
|
217
|
+
tabs: group.tabs.map((tab) => (tab.id === tabId ? { ...tab, preview: false } : tab)),
|
|
218
|
+
}));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Drop empty groups and collapse a branch that ends up with one child; null when nothing remains. */
|
|
222
|
+
export function pruneEmpty(node: EditorNode): EditorNode | null {
|
|
223
|
+
if (node.type === "group") return node.tabs.length > 0 ? node : null;
|
|
224
|
+
const kept = node.children.map(pruneEmpty).filter((child): child is EditorNode => child !== null);
|
|
225
|
+
|
|
226
|
+
if (kept.length === 0) return null;
|
|
227
|
+
if (kept.length === 1) return kept[0]!;
|
|
228
|
+
|
|
229
|
+
return { ...node, children: kept };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Close a tab; returns the pruned tree, or null when the last tab of the whole grid is closed. */
|
|
233
|
+
export function closeTab(node: EditorNode, groupId: string, tabId: string): EditorNode | null {
|
|
234
|
+
const next = replaceGroup(node, groupId, (group) => {
|
|
235
|
+
const tabs = group.tabs.filter((tab) => tab.id !== tabId);
|
|
236
|
+
const activeTabId = group.activeTabId === tabId ? (tabs[0]?.id ?? null) : group.activeTabId;
|
|
237
|
+
|
|
238
|
+
return { ...group, tabs, activeTabId };
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
return pruneEmpty(next);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Split a group's active tab into a new group on the given edge; returns the tree and the new focus. */
|
|
245
|
+
export function splitGroup(
|
|
246
|
+
node: EditorNode,
|
|
247
|
+
groupId: string,
|
|
248
|
+
direction: SplitDirection,
|
|
249
|
+
): SplitResult {
|
|
250
|
+
if (countEditorGroups(node) >= MAX_EDITOR_GROUPS) return { tree: node, focusGroupId: groupId };
|
|
251
|
+
|
|
252
|
+
let focusGroupId = groupId;
|
|
253
|
+
const tree = replaceGroup(node, groupId, (group) => {
|
|
254
|
+
const active = group.tabs.find((tab) => tab.id === group.activeTabId) ?? group.tabs[0];
|
|
255
|
+
|
|
256
|
+
if (active === undefined) return group;
|
|
257
|
+
const movedTab: EditorTab =
|
|
258
|
+
active.kind === "welcome" ? { ...active, id: freshId() } : { ...active, preview: false };
|
|
259
|
+
const newGroup = makeGroup([movedTab]);
|
|
260
|
+
|
|
261
|
+
focusGroupId = newGroup.id;
|
|
262
|
+
const orientation = direction === "left" || direction === "right" ? "horizontal" : "vertical";
|
|
263
|
+
const newFirst = direction === "left" || direction === "up";
|
|
264
|
+
const remainingTabs =
|
|
265
|
+
active.kind === "welcome" ? group.tabs : group.tabs.filter((tab) => tab.id !== active.id);
|
|
266
|
+
const sourceTabs =
|
|
267
|
+
remainingTabs.length > 0
|
|
268
|
+
? remainingTabs
|
|
269
|
+
: [findTabByKind(node, "changes") ? welcomeTab() : changesTab()];
|
|
270
|
+
const sourceGroup = {
|
|
271
|
+
...group,
|
|
272
|
+
tabs: sourceTabs,
|
|
273
|
+
activeTabId: sourceTabs[0]!.id,
|
|
274
|
+
};
|
|
275
|
+
const children = newFirst ? [newGroup, sourceGroup] : [sourceGroup, newGroup];
|
|
276
|
+
|
|
277
|
+
return { type: "branch", id: freshId(), orientation, children };
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
return { tree, focusGroupId };
|
|
281
|
+
}
|