@cueloop/client 0.1.0-alpha.64 → 0.1.0-alpha.66

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.
Files changed (109) hide show
  1. package/package.json +5 -5
  2. package/src/App.autoclose.test.tsx +2 -2
  3. package/src/App.diff.test.tsx +8 -73
  4. package/src/App.export.test.tsx +2 -2
  5. package/src/App.plan-review.test.tsx +60 -412
  6. package/src/App.prototype.test.tsx +1 -8
  7. package/src/App.readonly.test.tsx +85 -62
  8. package/src/App.rendering.test.tsx +5 -6
  9. package/src/App.submit-confirm.test.tsx +32 -70
  10. package/src/App.test.tsx +136 -66
  11. package/src/App.toast.test.tsx +17 -8
  12. package/src/App.tsx +243 -284
  13. package/src/App.walk.test.tsx +1 -1
  14. package/src/app-screens.tsx +67 -52
  15. package/src/app-view-model.ts +6 -100
  16. package/src/components/AnnotationCard.stories.tsx +1 -1
  17. package/src/components/AnnotationCard.tsx +2 -2
  18. package/src/components/AppShell.stories.tsx +84 -0
  19. package/src/components/AppShell.tsx +127 -0
  20. package/src/components/Breadcrumb.stories.tsx +1 -1
  21. package/src/components/ChangesColumn.stories.tsx +39 -0
  22. package/src/components/ChangesColumn.tsx +147 -0
  23. package/src/components/CodeBlock.stories.tsx +1 -1
  24. package/src/components/CompletionOverlay.stories.tsx +1 -1
  25. package/src/components/ConfirmCard.stories.tsx +1 -1
  26. package/src/components/ConfirmCard.tsx +3 -4
  27. package/src/components/ConfirmDialog.stories.tsx +1 -1
  28. package/src/components/DiffSheet.stories.tsx +1 -1
  29. package/src/components/InboxList.stories.tsx +23 -8
  30. package/src/components/InboxList.tsx +45 -19
  31. package/src/components/KeybindsDialog.stories.tsx +1 -1
  32. package/src/components/KeybindsDialog.tsx +61 -17
  33. package/src/components/MarkerPopover.stories.tsx +1 -1
  34. package/src/components/MenuBar.stories.tsx +3 -12
  35. package/src/components/MenuBar.tsx +25 -56
  36. package/src/components/PromptDialog.stories.tsx +1 -1
  37. package/src/components/PrototypeSheet.stories.tsx +1 -1
  38. package/src/components/ReviewPanel.stories.tsx +15 -1
  39. package/src/components/ReviewRail.stories.tsx +15 -1
  40. package/src/components/ReviewRail.tsx +13 -6
  41. package/src/components/SettingsDialog.stories.tsx +7 -1
  42. package/src/components/SettingsDialog.tsx +112 -66
  43. package/src/components/SettingsRows.stories.tsx +1 -1
  44. package/src/components/ShellHeader.stories.tsx +50 -0
  45. package/src/components/ShellHeader.tsx +137 -0
  46. package/src/components/ThreadFooter.stories.tsx +30 -0
  47. package/src/components/ThreadFooter.tsx +49 -0
  48. package/src/components/ThreadView.selection.test.tsx +812 -0
  49. package/src/components/ThreadView.slash.test.tsx +113 -0
  50. package/src/components/ThreadView.stories.tsx +26 -0
  51. package/src/components/ThreadView.tsx +1708 -0
  52. package/src/components/ThreadsSidebar.stories.tsx +36 -0
  53. package/src/components/ThreadsSidebar.tsx +57 -0
  54. package/src/components/Toast.stories.tsx +1 -1
  55. package/src/components/TreePane.stories.tsx +106 -0
  56. package/src/components/TreePane.tsx +156 -0
  57. package/src/components/WalkWizard.stories.tsx +1 -1
  58. package/src/components/__snapshots__/stories.test.tsx.snap +463 -305
  59. package/src/components/agent-launcher.stories.tsx +1 -1
  60. package/src/components/file-tree.test.ts +45 -0
  61. package/src/components/file-tree.ts +57 -0
  62. package/src/components/pages/Workspace.stories.tsx +181 -0
  63. package/src/components/primitives/Button.stories.tsx +1 -1
  64. package/src/components/primitives/Card.stories.tsx +1 -1
  65. package/src/components/primitives/Dialog.stories.tsx +1 -1
  66. package/src/components/primitives/IconButton.stories.tsx +36 -0
  67. package/src/components/primitives/IconButton.tsx +43 -0
  68. package/src/components/primitives/StatusBar.stories.tsx +1 -1
  69. package/src/components/primitives/Tabs.stories.tsx +1 -1
  70. package/src/components/primitives/Toolbar.stories.tsx +1 -1
  71. package/src/components/primitives/Tree.stories.tsx +124 -0
  72. package/src/components/primitives/Tree.tsx +87 -0
  73. package/src/components/primitives/icons.ts +38 -0
  74. package/src/components/primitives/tree-model.test.ts +99 -0
  75. package/src/components/primitives/tree-model.ts +137 -0
  76. package/src/components/quick-actions-editor.stories.tsx +1 -1
  77. package/src/components/session-tree.test.ts +114 -0
  78. package/src/components/session-tree.ts +94 -0
  79. package/src/components/stories-app.tsx +142 -82
  80. package/src/components/story-tree.test.ts +100 -0
  81. package/src/components/story.ts +55 -0
  82. package/src/components/theme-context.stories.tsx +1 -1
  83. package/src/diff-hunk-curate.ts +4 -104
  84. package/src/index.ts +1 -0
  85. package/src/intent-dispatch.test.ts +136 -3
  86. package/src/intent-dispatch.ts +92 -24
  87. package/src/key-bindings.test.ts +1 -1
  88. package/src/keymap.test.ts +4 -4
  89. package/src/keymap.ts +14 -7
  90. package/src/session-controller.curate.test.ts +89 -2
  91. package/src/session-controller.share.test.ts +195 -55
  92. package/src/session-controller.tree.test.ts +228 -0
  93. package/src/session-controller.ts +331 -72
  94. package/src/share.ts +114 -16
  95. package/src/test-support.test.ts +193 -1
  96. package/src/test-support.ts +64 -0
  97. package/src/theme.ts +4 -4
  98. package/src/thread-chords.test.ts +154 -0
  99. package/src/thread-chords.ts +144 -0
  100. package/src/thread-selection.test.ts +210 -0
  101. package/src/thread-selection.ts +243 -0
  102. package/src/tree-view.test.ts +128 -0
  103. package/src/tree-view.ts +148 -0
  104. package/src/use-settings-dialog.tsx +9 -0
  105. package/src/view-plan.ts +29 -12
  106. package/src/App.review-panel.test.tsx +0 -143
  107. package/src/components/PlanSheet.stories.tsx +0 -113
  108. package/src/components/PlanSheet.tsx +0 -419
  109. package/src/components/plan-sheet-run-style.ts +0 -88
@@ -1,109 +1,169 @@
1
1
  /**
2
- * The interactive stories catalog (`bun run stories`): a left list of
3
- * component modules (j/k), h/l cycling through the module's stories, and the
2
+ * The interactive stories catalog (`bun run stories`): a nested tree of
3
+ * component modules on the left (j/k to move, h/l to fold or open) and the
4
4
  * selected story rendered live in the preview pane. The same story objects
5
5
  * back the snapshot harness - what this TUI shows is what the tests assert.
6
6
  */
7
7
 
8
- import React, { useMemo, useState } from "react";
9
- import { createCliRenderer } from "@opentui/core";
8
+ import React, { useEffect, useMemo, useRef, useState } from "react";
9
+ import { createCliRenderer, type ScrollBoxRenderable } from "@opentui/core";
10
10
  import { createRoot, useKeyboard } from "@opentui/react";
11
11
  import { DARK } from "../theme";
12
12
  import { ThemeProvider } from "./theme-context";
13
- import { loadStories, type LoadedStory } from "./story";
14
- import { FRAME_BORDER_STYLE } from "./primitives/frame";
13
+ import { buildStoryTree, loadStories, type LoadedStory } from "./story";
14
+ import { AppShell } from "./AppShell";
15
+ import { ShellHeader } from "./ShellHeader";
16
+ import { Tree } from "./primitives/Tree";
17
+ import { NERD } from "./primitives/icons";
18
+ import { allFolderIds, flattenTree } from "./primitives/tree-model";
15
19
 
16
- function StoriesApp({
17
- stories,
18
- onExit,
19
- }: {
20
+ interface StoriesAppProps {
20
21
  stories: LoadedStory[];
21
22
  onExit: () => void;
22
- }): React.ReactNode {
23
- const moduleTitles = useMemo(
24
- () => [...new Set(stories.map((story) => story.moduleTitle))],
23
+ }
24
+
25
+ function StoriesApp({ stories, onExit }: StoriesAppProps): React.ReactNode {
26
+ const treeNodes = useMemo(() => buildStoryTree(stories), [stories]);
27
+ const storiesById = useMemo(
28
+ () => new Map(stories.map((story) => [`${story.moduleTitle}/${story.storyName}`, story])),
25
29
  [stories],
26
30
  );
27
- const [moduleIndex, setModuleIndex] = useState(0);
28
- const [storyIndex, setStoryIndex] = useState(0);
29
- const moduleTitle = moduleTitles[moduleIndex]!;
30
- const moduleStories = stories.filter((story) => story.moduleTitle === moduleTitle);
31
- const current = moduleStories[Math.min(storyIndex, moduleStories.length - 1)]!;
31
+ const firstStoryId = `${stories[0]!.moduleTitle}/${stories[0]!.storyName}`;
32
+
33
+ const [expandedIds, setExpandedIds] = useState<ReadonlySet<string>>(() =>
34
+ allFolderIds(treeNodes),
35
+ );
36
+ const [selectedId, setSelectedId] = useState(firstStoryId);
37
+ const [openedId, setOpenedId] = useState(firstStoryId);
38
+ const [treeOpen, setTreeOpen] = useState(true);
39
+
40
+ const rows = flattenTree(treeNodes, { expandedIds });
41
+ const selectedRow = rows.find((row) => row.id === selectedId) ?? rows[0];
42
+ const opened = storiesById.get(openedId);
43
+ const sidebarScroll = useRef<ScrollBoxRenderable | null>(null);
44
+
45
+ // keep the moving selection inside the scrolled viewport
46
+ useEffect(() => {
47
+ try {
48
+ sidebarScroll.current?.scrollChildIntoView(`tree-row-${selectedId}`);
49
+ } catch {
50
+ // reveal is best-effort; the selection state is already correct
51
+ }
52
+ }, [selectedId]);
53
+
54
+ const openStory = (id: string): void => {
55
+ if (storiesById.has(id)) setOpenedId(id);
56
+ };
57
+
58
+ const toggleFolder = (id: string): void => {
59
+ setExpandedIds((current) => {
60
+ const next = new Set(current);
61
+ if (next.has(id)) next.delete(id);
62
+ else next.add(id);
63
+
64
+ return next;
65
+ });
66
+ };
67
+
68
+ const expandFolder = (id: string): void => {
69
+ setExpandedIds((current) => new Set(current).add(id));
70
+ };
71
+
72
+ const collapseFolder = (id: string): void => {
73
+ setExpandedIds((current) => {
74
+ const next = new Set(current);
75
+ next.delete(id);
76
+
77
+ return next;
78
+ });
79
+ };
80
+
81
+ const moveSelection = (delta: number): void => {
82
+ const index = rows.findIndex((row) => row.id === selectedId);
83
+ const nextIndex = Math.max(0, Math.min(rows.length - 1, index + delta));
84
+ const nextRow = rows[nextIndex];
85
+ if (nextRow !== undefined) setSelectedId(nextRow.id);
86
+ };
32
87
 
33
88
  useKeyboard((key) => {
34
89
  if (key.name === "q") onExit();
35
- else if (key.name === "j" || key.name === "down") {
36
- setModuleIndex((index) => Math.min(moduleTitles.length - 1, index + 1));
37
- setStoryIndex(0);
38
- } else if (key.name === "k" || key.name === "up") {
39
- setModuleIndex((index) => Math.max(0, index - 1));
40
- setStoryIndex(0);
41
- } else if (key.name === "l" || key.name === "right") {
42
- setStoryIndex((index) => (index + 1) % moduleStories.length);
90
+ else if (key.name === "j" || key.name === "down") moveSelection(1);
91
+ else if (key.name === "k" || key.name === "up") moveSelection(-1);
92
+ else if (selectedRow === undefined) return;
93
+ else if (key.name === "l" || key.name === "right") {
94
+ if (selectedRow.isFolder) expandFolder(selectedRow.id);
95
+ else openStory(selectedRow.id);
43
96
  } else if (key.name === "h" || key.name === "left") {
44
- setStoryIndex((index) => (index - 1 + moduleStories.length) % moduleStories.length);
97
+ if (selectedRow.isFolder) collapseFolder(selectedRow.id);
98
+ } else if (key.name === "return" || key.name === "space") {
99
+ if (selectedRow.isFolder) toggleFolder(selectedRow.id);
100
+ else openStory(selectedRow.id);
45
101
  }
46
102
  });
47
103
 
48
104
  return (
49
105
  <ThemeProvider theme={DARK}>
50
- <box
51
- style={{
52
- flexDirection: "column",
53
- width: "100%",
54
- height: "100%",
55
- backgroundColor: DARK.background,
56
- }}
57
- >
58
- <box style={{ height: 1, backgroundColor: DARK.panel, paddingLeft: 1 }}>
59
- <text fg={DARK.text}>
60
- <span fg={DARK.accent}>cueloop stories</span>
61
- <span fg={DARK.textDim}>
62
- {" "}
63
- · {moduleTitle} / {current.storyName}
64
- </span>
65
- </text>
66
- </box>
67
- <box style={{ flexDirection: "row", flexGrow: 1 }}>
68
- <box
69
- style={{
70
- width: 28,
71
- backgroundColor: DARK.panel,
72
- flexDirection: "column",
73
- paddingLeft: 1,
74
- paddingTop: 1,
75
- }}
76
- >
77
- {moduleTitles.map((title, index) => (
78
- <text key={title} fg={index === moduleIndex ? DARK.accent : DARK.textMuted}>
79
- {index === moduleIndex ? "▸ " : " "}
80
- {title}
81
- </text>
82
- ))}
83
- </box>
84
- <box
85
- style={{
86
- flexGrow: 1,
87
- flexDirection: "column",
88
- border: true,
89
- borderStyle: FRAME_BORDER_STYLE,
90
- borderColor: DARK.border,
91
- }}
92
- title={` ${current.storyName} (${moduleStories.indexOf(current) + 1}/${moduleStories.length}) `}
93
- >
94
- {/* remount per story so component-local state never leaks across */}
95
- <box
96
- key={`${moduleTitle}/${current.storyName}`}
97
- style={{ flexGrow: 1, flexDirection: "column" }}
98
- >
99
- {current.story.render()}
106
+ <AppShell
107
+ theme={DARK}
108
+ mainHeader={
109
+ <ShellHeader
110
+ theme={DARK}
111
+ leftIcons={[{ glyph: NERD.settings }]}
112
+ leftLabel="cueloop storybook"
113
+ inspectorOpen={treeOpen}
114
+ onToggleInspector={treeOpen ? undefined : () => setTreeOpen(true)}
115
+ />
116
+ }
117
+ inspectorHeader={
118
+ <ShellHeader
119
+ theme={DARK}
120
+ inspectorOpen={treeOpen}
121
+ onToggleInspector={() => setTreeOpen(false)}
122
+ />
123
+ }
124
+ main={
125
+ opened !== undefined ? (
126
+ <scrollbox style={{ flexGrow: 1, paddingLeft: 1, paddingTop: 1 }} focused={false}>
127
+ {/* remount per story so component-local state never leaks across */}
128
+ <box key={openedId} style={{ flexDirection: "column" }}>
129
+ {opened.story.render()}
130
+ </box>
131
+ </scrollbox>
132
+ ) : (
133
+ <box style={{ flexGrow: 1, paddingLeft: 1, paddingTop: 1 }}>
134
+ <text fg={DARK.textDim}>Select a story</text>
135
+ </box>
136
+ )
137
+ }
138
+ inspectorWidth={34}
139
+ inspector={
140
+ treeOpen ? (
141
+ <box style={{ flexDirection: "column", flexGrow: 1, paddingTop: 1 }}>
142
+ <scrollbox ref={sidebarScroll} style={{ flexGrow: 1 }} focused={false}>
143
+ <Tree
144
+ nodes={treeNodes}
145
+ expandedIds={expandedIds}
146
+ selectedId={selectedId}
147
+ theme={DARK}
148
+ onSelect={(id) => {
149
+ setSelectedId(id);
150
+ openStory(id);
151
+ }}
152
+ onToggle={(id) => {
153
+ setSelectedId(id);
154
+ toggleFolder(id);
155
+ }}
156
+ />
157
+ </scrollbox>
100
158
  </box>
159
+ ) : undefined
160
+ }
161
+ footer={
162
+ <box style={{ height: 1, paddingLeft: 1 }}>
163
+ <text fg={DARK.textDim}>j/k move · h/l fold or open · enter open · q quit</text>
101
164
  </box>
102
- </box>
103
- <box style={{ height: 1, backgroundColor: DARK.panel, paddingLeft: 1 }}>
104
- <text fg={DARK.textDim}>j/k component · h/l story · q quit</text>
105
- </box>
106
- </box>
165
+ }
166
+ />
107
167
  </ThemeProvider>
108
168
  );
109
169
  }
@@ -0,0 +1,100 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { buildStoryTree, type LoadedStory } from "./story";
3
+ import type { TreeNode } from "./primitives/tree-model";
4
+
5
+ /** A story object whose render is never invoked in these structural tests. */
6
+ const STUB = { render: () => null };
7
+
8
+ function story(moduleTitle: string, storyName: string): LoadedStory {
9
+ return { moduleTitle, storyName, story: STUB };
10
+ }
11
+
12
+ function childLabels(node: TreeNode): string[] {
13
+ return (node.children ?? []).map((child) => child.label);
14
+ }
15
+
16
+ describe("buildStoryTree", () => {
17
+ test("splits moduleTitle on / into nested folders keyed by their path", () => {
18
+ // Arrange
19
+ const stories = [story("Primitives/Tree", "Default"), story("Layout/AppShell", "Default")];
20
+
21
+ // Act
22
+ const tree = buildStoryTree(stories);
23
+
24
+ // Assert
25
+ expect(tree.map((node) => [node.id, node.label])).toEqual([
26
+ ["Primitives", "Primitives"],
27
+ ["Layout", "Layout"],
28
+ ]);
29
+ expect(tree[0]!.children!.map((node) => node.id)).toEqual(["Primitives/Tree"]);
30
+ });
31
+
32
+ test("groups sibling components under one shared parent folder", () => {
33
+ // Arrange
34
+ const stories = [story("Primitives/Tree", "Default"), story("Primitives/Card", "Default")];
35
+
36
+ // Act
37
+ const tree = buildStoryTree(stories);
38
+
39
+ // Assert
40
+ expect(tree).toHaveLength(1);
41
+ expect(childLabels(tree[0]!)).toEqual(["Tree", "Card"]);
42
+ });
43
+
44
+ test("nests to the full depth of the moduleTitle path", () => {
45
+ // Arrange
46
+ const stories = [story("A/B/C", "Only")];
47
+
48
+ // Act
49
+ const tree = buildStoryTree(stories);
50
+ const a = tree[0]!;
51
+ const b = a.children![0]!;
52
+
53
+ // Assert
54
+ expect([a.id, b.id]).toEqual(["A", "A/B"]);
55
+ expect(b.children![0]!.id).toBe("A/B/C");
56
+ });
57
+
58
+ test("adds each story as a leaf keyed `${moduleTitle}/${storyName}` under its component", () => {
59
+ // Arrange
60
+ const stories = [story("Primitives/Tree", "Empty"), story("Primitives/Tree", "Deep")];
61
+
62
+ // Act
63
+ const tree = buildStoryTree(stories);
64
+ const component = tree[0]!.children![0]!;
65
+
66
+ // Assert
67
+ expect(component.id).toBe("Primitives/Tree");
68
+ expect(component.children!.map((leaf) => [leaf.id, leaf.label])).toEqual([
69
+ ["Primitives/Tree/Empty", "Empty"],
70
+ ["Primitives/Tree/Deep", "Deep"],
71
+ ]);
72
+ });
73
+
74
+ test("hoists a component with a single story of its own name into one leaf row", () => {
75
+ // Arrange
76
+ const stories = [story("Primitives/Card", "Card")];
77
+
78
+ // Act
79
+ const tree = buildStoryTree(stories);
80
+ const hoisted = tree[0]!.children![0]!;
81
+
82
+ // Assert
83
+ expect(hoisted.children).toBeUndefined();
84
+ expect(hoisted.id).toBe("Primitives/Card/Card");
85
+ expect(hoisted.label).toBe("Card");
86
+ });
87
+
88
+ test("keeps the folder when its single story differs from the component name", () => {
89
+ // Arrange
90
+ const stories = [story("Primitives/Card", "Default")];
91
+
92
+ // Act
93
+ const tree = buildStoryTree(stories);
94
+ const component = tree[0]!.children![0]!;
95
+
96
+ // Assert
97
+ expect(component.id).toBe("Primitives/Card");
98
+ expect(component.children!.map((leaf) => leaf.id)).toEqual(["Primitives/Card/Default"]);
99
+ });
100
+ });
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import type React from "react";
10
+ import type { TreeNode } from "./primitives/tree-model";
10
11
 
11
12
  export interface StoryMeta {
12
13
  /** Catalog path, e.g. "primitives/Card". */
@@ -68,6 +69,60 @@ export async function loadStories(): Promise<LoadedStory[]> {
68
69
  return loaded;
69
70
  }
70
71
 
72
+ /** A folder node keeps its children array live so the builder can append to it. */
73
+ interface StoryTreeFolder extends TreeNode {
74
+ children: TreeNode[];
75
+ }
76
+
77
+ // A component folder holding one story of its own name reads as one row, not a folder wrapping a single leaf.
78
+ function hoistSingleStoryFolders(nodes: TreeNode[]): TreeNode[] {
79
+ return nodes.map((node) => {
80
+ if (node.children === undefined) return node;
81
+ const children = hoistSingleStoryFolders(node.children);
82
+ const onlyChild = children.length === 1 ? children[0] : undefined;
83
+ if (
84
+ onlyChild !== undefined &&
85
+ onlyChild.children === undefined &&
86
+ onlyChild.label === node.label
87
+ ) {
88
+ return onlyChild;
89
+ }
90
+
91
+ return { ...node, children };
92
+ });
93
+ }
94
+
95
+ /**
96
+ * Nest the flat catalog into folder/component groups by splitting each
97
+ * moduleTitle on "/". Each story becomes a leaf keyed `${moduleTitle}/${storyName}`
98
+ * under its component node; a component holding a single story of its own name
99
+ * collapses so its row IS that story leaf.
100
+ */
101
+ export function buildStoryTree(stories: LoadedStory[]): TreeNode[] {
102
+ const roots: TreeNode[] = [];
103
+ const foldersByPath = new Map<string, StoryTreeFolder>();
104
+
105
+ for (const { moduleTitle, storyName } of stories) {
106
+ let siblings = roots;
107
+ let path = "";
108
+
109
+ for (const segment of moduleTitle.split("/")) {
110
+ path = path === "" ? segment : `${path}/${segment}`;
111
+ let folder = foldersByPath.get(path);
112
+ if (folder === undefined) {
113
+ folder = { id: path, label: segment, children: [] };
114
+ foldersByPath.set(path, folder);
115
+ siblings.push(folder);
116
+ }
117
+ siblings = folder.children;
118
+ }
119
+
120
+ siblings.push({ id: `${moduleTitle}/${storyName}`, label: storyName });
121
+ }
122
+
123
+ return hoistSingleStoryFolders(roots);
124
+ }
125
+
71
126
  /** Component files that must carry stories: every .tsx that is not a story/test/prototype. */
72
127
  export function componentFilesMissingStories(): string[] {
73
128
  const componentGlob = new Bun.Glob("**/*.tsx");
@@ -4,7 +4,7 @@ import type { Story, StoryMeta } from "./story";
4
4
  import { ThemeProvider } from "./theme-context";
5
5
  import { Card } from "./primitives/Card";
6
6
 
7
- export const meta: StoryMeta = { title: "theme-context" };
7
+ export const meta: StoryMeta = { title: "Foundations/Theme" };
8
8
 
9
9
  /** A high-contrast variant to prove tokens flow through the provider. */
10
10
  const HIGH_CONTRAST: Theme = { ...DARK, accent: "#00ffff", elevated: "#000000", border: "#ffffff" };
@@ -13,16 +13,12 @@
13
13
  * the stored git patch and the model split hunks differently.
14
14
  */
15
15
 
16
- import { parseDiffFromFile, diffAcceptRejectHunk, type FileDiffMetadata } from "@pierre/diffs";
17
- import { unifiedDiffText, type DiffFileContents, type DiffFileStatus } from "@cueloop/schema";
16
+ import type { FileDiffMetadata } from "@pierre/diffs";
17
+ import type { HunkRejection } from "@cueloop/schema";
18
18
  import type { DiffRow } from "./view-diff";
19
19
 
20
- /** One reject decision; `changeIndex` absent rejects the whole hunk. */
21
- export interface HunkRejection {
22
- path: string;
23
- hunkIndex: number;
24
- changeIndex?: number;
25
- }
20
+ export { curateDiff, parseFileDiff } from "@cueloop/daemon/curate";
21
+ export type { HunkRejection } from "@cueloop/schema";
26
22
 
27
23
  /** Which side of the diff a line number addresses. */
28
24
  type DiffSide = "addition" | "deletion";
@@ -34,24 +30,6 @@ export interface LocatedLine {
34
30
  changeIndex?: number;
35
31
  }
36
32
 
37
- /**
38
- * Context lines around each change. Matching git's default (3) makes the model
39
- * split hunks like the rendered working-tree patch does; git's coalescing can
40
- * still merge adjacent hunks differently, but the row is mapped by absolute line
41
- * number (not by counting hunks), so a whole-hunk reject can only ever span the
42
- * model hunk it lands in - never the wrong file or a crash.
43
- */
44
- const DIFF_CONTEXT = 3;
45
-
46
- /** Parse one file's full contents into a non-partial diff model. */
47
- export function parseFileDiff(file: DiffFileContents): FileDiffMetadata {
48
- return parseDiffFromFile(
49
- { name: file.path, contents: file.oldContents },
50
- { name: file.path, contents: file.newContents },
51
- { context: DIFF_CONTEXT },
52
- );
53
- }
54
-
55
33
  /**
56
34
  * Resolve a 1-based file line on one side to its hunk and, when the line is
57
35
  * part of a change block, that block's `hunkContent` index.
@@ -173,81 +151,3 @@ export function isRowRejected(
173
151
  (rejection.changeIndex === undefined || rejection.changeIndex === located.changeIndex),
174
152
  );
175
153
  }
176
-
177
- /**
178
- * Apply one file's rejections and return its curated patch, or null when the
179
- * curated content equals the old content (every change rejected - no patch).
180
- * Whole-hunk rejections supersede change-level ones for the same hunk. Because
181
- * `diffAcceptRejectHunk` returns a model with the same hunk/content shape, the
182
- * indices stay valid across calls; they are applied high-to-low regardless, so
183
- * the order is deterministic.
184
- */
185
- function curateFilePatch(file: DiffFileContents, rejections: HunkRejection[]): string | null {
186
- let model = parseFileDiff(file);
187
- const wholeHunks = new Set(
188
- rejections
189
- .filter((rejection) => rejection.changeIndex === undefined)
190
- .map((rejection) => rejection.hunkIndex),
191
- );
192
-
193
- for (const hunkIndex of [...wholeHunks].sort((a, b) => b - a)) {
194
- model = diffAcceptRejectHunk(model, hunkIndex, "reject");
195
- }
196
- const changeRejections = rejections
197
- .filter(
198
- (rejection) => rejection.changeIndex !== undefined && !wholeHunks.has(rejection.hunkIndex),
199
- )
200
- .sort((a, b) => b.hunkIndex - a.hunkIndex || b.changeIndex! - a.changeIndex!);
201
-
202
- for (const rejection of changeRejections) {
203
- model = diffAcceptRejectHunk(model, rejection.hunkIndex, {
204
- type: "reject",
205
- changeIndex: rejection.changeIndex!,
206
- });
207
- }
208
- const curatedNew = model.additionLines.join("");
209
-
210
- if (curatedNew === file.oldContents) return null;
211
- const patch = unifiedDiffText(file.oldContents, curatedNew, file.path);
212
-
213
- if (patch === null) return null;
214
-
215
- return withFileStateHeaders(patch, file.path, file.status, curatedNew);
216
- }
217
-
218
- /**
219
- * Point the diff headers at /dev/null for a created or fully-deleted file so the
220
- * curated patch applies. git's status - not empty contents - decides existence,
221
- * so editing an existing empty file stays a modify; a deletion whose removals
222
- * were partly restored also falls back to a modify.
223
- */
224
- function withFileStateHeaders(
225
- patch: string,
226
- path: string,
227
- status: DiffFileStatus,
228
- curatedNew: string,
229
- ): string {
230
- if (status === "added") return patch.replace(`--- a/${path}`, "--- /dev/null");
231
- if (status === "deleted" && curatedNew === "") {
232
- return patch.replace(`+++ b/${path}`, "+++ /dev/null");
233
- }
234
-
235
- return patch;
236
- }
237
-
238
- /**
239
- * The curated unified-diff patch of the accepted changes across every file.
240
- * Files with no net change (unchanged, or with all changes rejected) drop out.
241
- */
242
- export function curateDiff(files: DiffFileContents[], rejections: HunkRejection[]): string {
243
- const parts: string[] = [];
244
-
245
- for (const file of files) {
246
- const fileRejections = rejections.filter((rejection) => rejection.path === file.path);
247
- const patch = curateFilePatch(file, fileRejections);
248
-
249
- if (patch) parts.push(patch);
250
- }
251
-
252
- return parts.join("\n");
253
- }
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@ export { serveClient, type ServeHandle, type ServeOptions } from "./serve";
3
3
  export { App, type AppProps } from "./App";
4
4
  export {
5
5
  collaboratorAnnotations,
6
+ mergeFromShare,
6
7
  publishShare,
7
8
  pullShare,
8
9
  pushShare,