@cueloop/client 0.1.0-alpha.66 → 0.1.0-alpha.68
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/package.json +6 -5
- package/src/App.autoclose.test.tsx +2 -2
- package/src/App.diff.test.tsx +71 -39
- package/src/App.plan-review.test.tsx +20 -21
- package/src/App.readonly.test.tsx +7 -3
- package/src/App.test.tsx +73 -14
- package/src/App.tsx +375 -129
- package/src/App.walk.test.tsx +14 -15
- package/src/App.workbench.test.tsx +390 -0
- package/src/__snapshots__/view-plan.regression.test.ts.snap +1 -1
- package/src/annotation-palette.ts +61 -0
- package/src/app-screens.tsx +176 -45
- package/src/app-view-model.ts +7 -65
- package/src/components/AnnotationCard.tsx +1 -1
- package/src/components/AnnotationCards.stories.tsx +87 -0
- package/src/components/AnnotationCards.tsx +303 -0
- package/src/components/AppHeader.stories.tsx +50 -0
- package/src/components/AppHeader.tsx +83 -0
- package/src/components/AppShell.stories.tsx +47 -42
- package/src/components/AppShell.tsx +212 -100
- package/src/components/AppShell.zoom.test.tsx +94 -0
- package/src/components/ChangesColumn.tsx +49 -26
- package/src/components/DiffSheet.scroll.test.tsx +38 -55
- package/src/components/DiffSheet.split.test.tsx +140 -0
- package/src/components/DiffSheet.stories.tsx +55 -53
- package/src/components/DiffSheet.syntax.test.tsx +24 -7
- package/src/components/DiffSheet.tsx +607 -276
- package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
- package/src/components/DiscussionMarkerRail.tsx +181 -0
- package/src/components/EditorGrid.stories.tsx +59 -0
- package/src/components/EditorGrid.tabs.test.tsx +105 -0
- package/src/components/EditorGrid.tsx +338 -0
- package/src/components/EditorGrid.zoom.test.tsx +68 -0
- package/src/components/FileContentsView.stories.tsx +16 -0
- package/src/components/FileContentsView.test.tsx +29 -0
- package/src/components/FileContentsView.tsx +78 -0
- package/src/components/InboxList.tsx +159 -30
- 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 +95 -0
- package/src/components/ProjectTreeView.stories.tsx +25 -0
- package/src/components/ProjectTreeView.test.tsx +32 -0
- package/src/components/ProjectTreeView.tsx +82 -0
- package/src/components/PromptDialog.tsx +8 -1
- package/src/components/RootOverlay.stories.tsx +44 -0
- package/src/components/RootOverlay.tsx +35 -0
- package/src/components/SettingsDialog.tsx +2 -1
- package/src/components/SurfaceScrollbar.stories.tsx +34 -0
- package/src/components/SurfaceScrollbar.test.tsx +111 -0
- package/src/components/SurfaceScrollbar.tsx +93 -0
- package/src/components/ThreadFooter.stories.tsx +2 -2
- package/src/components/ThreadFooter.test.tsx +31 -0
- package/src/components/ThreadFooter.tsx +12 -7
- package/src/components/ThreadView.tsx +62 -1440
- package/src/components/ThreadsSidebar.tsx +14 -1
- package/src/components/Tooltip.stories.tsx +26 -0
- package/src/components/Tooltip.tsx +72 -0
- package/src/components/WelcomeSurface.stories.tsx +12 -0
- package/src/components/WelcomeSurface.tsx +52 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +509 -559
- package/src/components/diff-sheet-layout.test.ts +125 -55
- package/src/components/diff-sheet-layout.ts +3 -103
- package/src/components/editor-grid.test.ts +86 -0
- package/src/components/editor-grid.ts +138 -0
- package/src/components/fade-title.test.ts +49 -0
- package/src/components/fade-title.ts +61 -0
- package/src/components/file-tree.test.ts +15 -0
- package/src/components/file-tree.ts +59 -4
- package/src/components/pages/Workspace.stories.tsx +2 -2
- package/src/components/primitives/IconButton.tsx +21 -2
- package/src/components/primitives/Tree.tsx +13 -4
- package/src/components/primitives/icons.ts +27 -4
- package/src/components/session-tree.test.ts +20 -0
- package/src/components/session-tree.ts +16 -2
- package/src/components/stories-app.tsx +2 -2
- package/src/config.test.ts +20 -49
- package/src/config.ts +26 -28
- package/src/diff-fold.test.ts +78 -0
- package/src/diff-fold.ts +126 -0
- package/src/diff-syntax.test.ts +16 -0
- package/src/diff-syntax.ts +6 -3
- package/src/discussions.ts +79 -0
- package/src/ghostty-terminal.test.ts +2 -1
- package/src/ghostty-terminal.ts +27 -4
- package/src/intent-dispatch.test.ts +31 -11
- package/src/intent-dispatch.ts +93 -48
- package/src/key-bindings.test.ts +1 -1
- package/src/keymap.test.ts +3 -25
- package/src/keymap.ts +17 -18
- package/src/mark-runs.ts +120 -0
- package/src/pty.ts +10 -2
- package/src/run.ts +17 -7
- package/src/serve.test.ts +4 -8
- package/src/session-controller.curate.test.ts +75 -0
- package/src/session-controller.share.test.ts +1 -0
- package/src/session-controller.tree.test.ts +26 -0
- package/src/session-controller.ts +153 -29
- package/src/slash-palette.ts +83 -0
- package/src/split-diff.test.ts +110 -0
- package/src/split-diff.ts +149 -0
- package/src/test-support.test.ts +15 -3
- package/src/test-support.ts +66 -15
- package/src/thread-chords.test.ts +25 -9
- package/src/thread-chords.ts +33 -8
- package/src/use-annotation-surface.tsx +837 -0
- package/src/use-changes-workbench.ts +151 -0
- package/src/use-frame-measure.ts +38 -0
- package/src/use-settings-dialog.tsx +14 -24
- package/src/use-terminal-virtualizer.ts +233 -0
- package/src/view-diff.ts +114 -0
- package/src/components/ReviewPanel.stories.tsx +0 -100
- package/src/components/ReviewPanel.tsx +0 -103
- package/src/components/ReviewRail.stories.tsx +0 -232
- package/src/components/ReviewRail.tsx +0 -327
- package/src/review-panel.test.ts +0 -113
- package/src/review-panel.ts +0 -83
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fits a thread title onto one line, fading the last few characters toward the
|
|
3
|
+
* background instead of cutting hard or wrapping - a gradient mask on the right.
|
|
4
|
+
* Returns colored segments the caller renders as spans.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface TitleSegment {
|
|
8
|
+
text: string;
|
|
9
|
+
fg: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function channel(hex: string, start: number): number {
|
|
13
|
+
return Number.parseInt(hex.slice(start, start + 2), 16);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Blend two #rrggbb colors; t=0 is `from`, t=1 is `to`. */
|
|
17
|
+
export function mixHex(from: string, to: string, t: number): string {
|
|
18
|
+
const clamp = Math.max(0, Math.min(1, t));
|
|
19
|
+
const parts = [1, 3, 5].map((start) => {
|
|
20
|
+
const value = Math.round(
|
|
21
|
+
channel(from, start) + (channel(to, start) - channel(from, start)) * clamp,
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
return value.toString(16).padStart(2, "0");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
return `#${parts.join("")}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The tail length that fades out; kept short so most of the title stays legible. */
|
|
31
|
+
const FADE_WIDTH = 5;
|
|
32
|
+
|
|
33
|
+
export function fadeTitle(
|
|
34
|
+
title: string,
|
|
35
|
+
maxWidth: number,
|
|
36
|
+
baseColor: string,
|
|
37
|
+
fadeColor: string,
|
|
38
|
+
): TitleSegment[] {
|
|
39
|
+
const oneLine = title.replace(/\n/g, " ");
|
|
40
|
+
|
|
41
|
+
if (maxWidth <= 0) return [];
|
|
42
|
+
if (oneLine.length <= maxWidth) return [{ text: oneLine, fg: baseColor }];
|
|
43
|
+
|
|
44
|
+
const clipped = oneLine.slice(0, maxWidth);
|
|
45
|
+
const fadeLength = Math.min(FADE_WIDTH, maxWidth);
|
|
46
|
+
const head = clipped.slice(0, maxWidth - fadeLength);
|
|
47
|
+
const segments: TitleSegment[] = head.length > 0 ? [{ text: head, fg: baseColor }] : [];
|
|
48
|
+
|
|
49
|
+
for (let index = 0; index < fadeLength; index++) {
|
|
50
|
+
// the last character reaches the background fully, so the tail masks out
|
|
51
|
+
// rather than leaving a legible word fragment
|
|
52
|
+
const progress = (index + 1) / fadeLength;
|
|
53
|
+
|
|
54
|
+
segments.push({
|
|
55
|
+
text: clipped[maxWidth - fadeLength + index]!,
|
|
56
|
+
fg: mixHex(baseColor, fadeColor, progress),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return segments;
|
|
61
|
+
}
|
|
@@ -42,4 +42,19 @@ describe("buildFileTree", () => {
|
|
|
42
42
|
test("returns an empty tree for no changes", () => {
|
|
43
43
|
expect(buildFileTree([])).toEqual([]);
|
|
44
44
|
});
|
|
45
|
+
|
|
46
|
+
test("badges a leaf with its comment count, and leaves an uncommented file bare", () => {
|
|
47
|
+
// Act
|
|
48
|
+
const tree = buildFileTree(
|
|
49
|
+
[file("a.ts"), file("b.ts")],
|
|
50
|
+
new Map([
|
|
51
|
+
["a.ts", 3],
|
|
52
|
+
["b.ts", 0],
|
|
53
|
+
]),
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
// Assert - a.ts shows the dot-and-count, b.ts (0) shows nothing
|
|
57
|
+
expect(tree[0]!.badge).toBe("● 3");
|
|
58
|
+
expect(tree[1]!.badge).toBeUndefined();
|
|
59
|
+
});
|
|
45
60
|
});
|
|
@@ -16,11 +16,18 @@ interface MutableTreeNode {
|
|
|
16
16
|
id: string;
|
|
17
17
|
label: string;
|
|
18
18
|
status?: GitStatus;
|
|
19
|
+
badge?: string;
|
|
19
20
|
children?: Map<string, MutableTreeNode>;
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
function freeze(node: MutableTreeNode): TreeNode {
|
|
23
|
-
if (node.children === undefined)
|
|
24
|
+
if (node.children === undefined) {
|
|
25
|
+
const leaf: TreeNode = { id: node.id, label: node.label, status: node.status };
|
|
26
|
+
|
|
27
|
+
if (node.badge !== undefined) leaf.badge = node.badge;
|
|
28
|
+
|
|
29
|
+
return leaf;
|
|
30
|
+
}
|
|
24
31
|
|
|
25
32
|
return {
|
|
26
33
|
id: node.id,
|
|
@@ -29,8 +36,24 @@ function freeze(node: MutableTreeNode): TreeNode {
|
|
|
29
36
|
};
|
|
30
37
|
}
|
|
31
38
|
|
|
32
|
-
/**
|
|
33
|
-
|
|
39
|
+
/** A file's comment count as a leaf badge, or undefined when it carries none. */
|
|
40
|
+
function commentBadge(
|
|
41
|
+
counts: ReadonlyMap<string, number> | undefined,
|
|
42
|
+
path: string,
|
|
43
|
+
): string | undefined {
|
|
44
|
+
const count = counts?.get(path);
|
|
45
|
+
|
|
46
|
+
return count ? `● ${count}` : undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Group changed files into a directory tree; leaves are keyed by their full path. A leaf whose
|
|
51
|
+
* file carries comments shows a dot-and-count badge, so feedback is visible even with its tab closed.
|
|
52
|
+
*/
|
|
53
|
+
export function buildFileTree(
|
|
54
|
+
files: readonly DiffFileContents[],
|
|
55
|
+
commentCounts?: ReadonlyMap<string, number>,
|
|
56
|
+
): TreeNode[] {
|
|
34
57
|
const roots = new Map<string, MutableTreeNode>();
|
|
35
58
|
|
|
36
59
|
for (const file of files) {
|
|
@@ -45,7 +68,39 @@ export function buildFileTree(files: readonly DiffFileContents[]): TreeNode[] {
|
|
|
45
68
|
|
|
46
69
|
if (node === undefined) {
|
|
47
70
|
node = isLeaf
|
|
48
|
-
? {
|
|
71
|
+
? {
|
|
72
|
+
id: file.path,
|
|
73
|
+
label: segment,
|
|
74
|
+
status: gitStatusOf(file.status),
|
|
75
|
+
badge: commentBadge(commentCounts, file.path),
|
|
76
|
+
}
|
|
77
|
+
: { id: prefix, label: segment, children: new Map() };
|
|
78
|
+
level.set(segment, node);
|
|
79
|
+
}
|
|
80
|
+
if (!isLeaf) level = node.children!;
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return [...roots.values()].map(freeze);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Group plain repo-relative paths (git ls-files) into a directory tree; leaves keep their full path. */
|
|
88
|
+
export function buildPathTree(paths: readonly string[]): TreeNode[] {
|
|
89
|
+
const roots = new Map<string, MutableTreeNode>();
|
|
90
|
+
|
|
91
|
+
for (const path of paths) {
|
|
92
|
+
const segments = path.split("/").filter((segment) => segment.length > 0);
|
|
93
|
+
let level = roots;
|
|
94
|
+
let prefix = "";
|
|
95
|
+
|
|
96
|
+
segments.forEach((segment, index) => {
|
|
97
|
+
prefix = prefix.length > 0 ? `${prefix}/${segment}` : segment;
|
|
98
|
+
const isLeaf = index === segments.length - 1;
|
|
99
|
+
let node = level.get(segment);
|
|
100
|
+
|
|
101
|
+
if (node === undefined) {
|
|
102
|
+
node = isLeaf
|
|
103
|
+
? { id: path, label: segment }
|
|
49
104
|
: { id: prefix, label: segment, children: new Map() };
|
|
50
105
|
level.set(segment, node);
|
|
51
106
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { DARK } from "../../theme";
|
|
3
3
|
import type { Story, StoryMeta } from "../story";
|
|
4
|
-
import {
|
|
4
|
+
import { PaneGrid } from "../PaneGrid";
|
|
5
5
|
import { Tree } from "../primitives/Tree";
|
|
6
6
|
import type { TreeNode } from "../primitives/tree-model";
|
|
7
7
|
|
|
@@ -168,7 +168,7 @@ function Footer(): React.ReactNode {
|
|
|
168
168
|
|
|
169
169
|
export const ThreePane: Story = {
|
|
170
170
|
render: () => (
|
|
171
|
-
<
|
|
171
|
+
<PaneGrid
|
|
172
172
|
sidebar={<Sidebar />}
|
|
173
173
|
main={<Main />}
|
|
174
174
|
mainHeader={<Header />}
|
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
// center keeps it on the same row as sibling text or icons.
|
|
4
4
|
|
|
5
5
|
import React from "react";
|
|
6
|
+
import type { MouseEvent as TerminalMouseEvent } from "@opentui/core";
|
|
6
7
|
import { DARK, type Theme } from "../../theme";
|
|
8
|
+
import { useTooltip } from "../Tooltip";
|
|
7
9
|
|
|
8
10
|
export interface IconButtonProps {
|
|
9
11
|
glyph: string;
|
|
@@ -14,6 +16,8 @@ export interface IconButtonProps {
|
|
|
14
16
|
disabled?: boolean;
|
|
15
17
|
/** Override the resolved foreground (wins over active/disabled colouring). */
|
|
16
18
|
color?: string;
|
|
19
|
+
/** Label shown on hover, surfaced at the screen root so a tiny header cell cannot clip it. */
|
|
20
|
+
tip?: string;
|
|
17
21
|
marginLeft?: number;
|
|
18
22
|
marginRight?: number;
|
|
19
23
|
theme?: Theme;
|
|
@@ -25,17 +29,32 @@ export function IconButton({
|
|
|
25
29
|
active,
|
|
26
30
|
disabled,
|
|
27
31
|
color,
|
|
32
|
+
tip,
|
|
28
33
|
marginLeft,
|
|
29
34
|
marginRight,
|
|
30
35
|
theme,
|
|
31
36
|
}: IconButtonProps): React.ReactNode {
|
|
32
37
|
const tokens = theme ?? DARK;
|
|
33
38
|
const resolved = color ?? (disabled ? tokens.textDim : active ? tokens.accent : tokens.textMuted);
|
|
39
|
+
const { showTooltip, hideTooltip } = useTooltip();
|
|
40
|
+
// a button press is its own action - never let it bubble to a clickable row behind it
|
|
41
|
+
const handleMouseUp = (event: TerminalMouseEvent): void => {
|
|
42
|
+
event.stopPropagation();
|
|
43
|
+
hideTooltip();
|
|
44
|
+
onPress?.();
|
|
45
|
+
};
|
|
34
46
|
|
|
35
47
|
return (
|
|
36
48
|
<box
|
|
37
|
-
onMouseUp={disabled ? undefined :
|
|
38
|
-
|
|
49
|
+
onMouseUp={disabled ? undefined : handleMouseUp}
|
|
50
|
+
onMouseOver={tip ? (event) => showTooltip(tip, event.x, event.y) : undefined}
|
|
51
|
+
onMouseOut={tip ? hideTooltip : undefined}
|
|
52
|
+
style={{
|
|
53
|
+
flexShrink: 0,
|
|
54
|
+
alignSelf: "center",
|
|
55
|
+
marginLeft,
|
|
56
|
+
marginRight,
|
|
57
|
+
}}
|
|
39
58
|
>
|
|
40
59
|
<text fg={resolved}>{glyph}</text>
|
|
41
60
|
</box>
|
|
@@ -11,6 +11,8 @@ export interface TreeProps {
|
|
|
11
11
|
selectedId?: string;
|
|
12
12
|
flattenEmptyDirectories?: boolean;
|
|
13
13
|
showStatus?: boolean;
|
|
14
|
+
/** Drop the folder/file glyph prefix, for a plain text tree (e.g. the settings nav). */
|
|
15
|
+
hideIcons?: boolean;
|
|
14
16
|
indentWidth?: number;
|
|
15
17
|
icons?: TreeIcons;
|
|
16
18
|
onSelect?: (id: string) => void;
|
|
@@ -38,6 +40,7 @@ export function Tree({
|
|
|
38
40
|
selectedId,
|
|
39
41
|
flattenEmptyDirectories,
|
|
40
42
|
showStatus,
|
|
43
|
+
hideIcons,
|
|
41
44
|
indentWidth = 2,
|
|
42
45
|
icons = NERD_TREE_ICONS,
|
|
43
46
|
onSelect,
|
|
@@ -72,10 +75,16 @@ export function Tree({
|
|
|
72
75
|
}}
|
|
73
76
|
onMouseUp={() => (row.isFolder ? onToggle?.(row.id) : onSelect?.(row.id))}
|
|
74
77
|
>
|
|
75
|
-
|
|
76
|
-
{row.
|
|
77
|
-
|
|
78
|
-
|
|
78
|
+
{hideIcons ? null : (
|
|
79
|
+
<text fg={row.isFolder ? tokens.blue : tokens.textDim}>
|
|
80
|
+
{row.icon ?? rowGlyph(row.isFolder, row.expanded, icons)}{" "}
|
|
81
|
+
</text>
|
|
82
|
+
)}
|
|
83
|
+
<box style={{ flexShrink: 1, minWidth: 0 }}>
|
|
84
|
+
<text fg={selected ? tokens.accent : labelColor} truncate>
|
|
85
|
+
{row.label}
|
|
86
|
+
</text>
|
|
87
|
+
</box>
|
|
79
88
|
<box style={{ flexGrow: 1 }} />
|
|
80
89
|
{row.badge !== undefined ? <text fg={tokens.textDim}>{row.badge}</text> : null}
|
|
81
90
|
{status ? <text fg={toneColor(status.tone, tokens)}> {status.letter}</text> : null}
|
|
@@ -1,21 +1,44 @@
|
|
|
1
1
|
// Nerd Font glyphs (Private Use Area / codicons) plus the plus-minus diff mark; render as icons under a Nerd Font, tofu without one.
|
|
2
2
|
|
|
3
3
|
export const NERD = {
|
|
4
|
-
folderClosed: "
|
|
5
|
-
folderOpen: "
|
|
6
|
-
file: "
|
|
4
|
+
folderClosed: "",
|
|
5
|
+
folderOpen: "",
|
|
6
|
+
file: "",
|
|
7
7
|
chevronRight: "",
|
|
8
8
|
chevronDown: "",
|
|
9
9
|
settings: "",
|
|
10
10
|
search: "",
|
|
11
11
|
expand: "",
|
|
12
|
-
diff: "
|
|
12
|
+
diff: "",
|
|
13
|
+
copy: "",
|
|
14
|
+
unfold: "",
|
|
15
|
+
kebab: "⋮",
|
|
16
|
+
pin: "⚑",
|
|
17
|
+
close: "✕",
|
|
13
18
|
listTree: "",
|
|
14
19
|
sidebarLeft: "",
|
|
15
20
|
sidebarLeftOff: "",
|
|
16
21
|
sidebarRight: "",
|
|
17
22
|
sidebarRightOff: "",
|
|
18
23
|
submit: "",
|
|
24
|
+
zoom: "⛶",
|
|
25
|
+
split: "",
|
|
26
|
+
} as const;
|
|
27
|
+
|
|
28
|
+
// The thin rule under each pane header: an overline sits at the top edge of its cell so it hugs the
|
|
29
|
+
// header text (unlike the default light-horizontal, which floats mid-cell); painted in divider gray upstream.
|
|
30
|
+
export const HEADER_UNDERLINE_CHARS = {
|
|
31
|
+
topLeft: "\u250c",
|
|
32
|
+
topRight: "\u2510",
|
|
33
|
+
bottomLeft: "\u2514",
|
|
34
|
+
bottomRight: "\u2518",
|
|
35
|
+
horizontal: "\u203e",
|
|
36
|
+
vertical: "\u2502",
|
|
37
|
+
topT: "\u252c",
|
|
38
|
+
bottomT: "\u2534",
|
|
39
|
+
leftT: "\u251c",
|
|
40
|
+
rightT: "\u2524",
|
|
41
|
+
cross: "\u253c",
|
|
19
42
|
} as const;
|
|
20
43
|
|
|
21
44
|
export interface TreeIcons {
|
|
@@ -111,4 +111,24 @@ describe("groupInbox", () => {
|
|
|
111
111
|
expect(rows.some((row) => row.kind === "section" && row.label === "Projects")).toBe(false);
|
|
112
112
|
expect(rows.some((row) => row.kind === "section" && row.label === "Threads")).toBe(true);
|
|
113
113
|
});
|
|
114
|
+
|
|
115
|
+
test("a pinned thread lifts into a Pinned section at the top, out of its normal group", () => {
|
|
116
|
+
// Arrange - one repo-bound thread, one standalone; pin the repo-bound one
|
|
117
|
+
const sessions = [session("a", "Pinned one", "root-1"), session("b", "Loose two")];
|
|
118
|
+
|
|
119
|
+
// Act
|
|
120
|
+
const { rows, ordered } = groupInbox(sessions, new Set(["a"]));
|
|
121
|
+
|
|
122
|
+
// Assert - Pinned is the first section and holds thread a; a is gone from Projects
|
|
123
|
+
const sections = rows
|
|
124
|
+
.filter((row) => row.kind === "section")
|
|
125
|
+
.map((row) => (row.kind === "section" ? row.label : ""));
|
|
126
|
+
|
|
127
|
+
expect(sections[0]).toBe("Pinned");
|
|
128
|
+
expect(sections).not.toContain("Projects"); // its only member was pinned away
|
|
129
|
+
expect(ordered[0]!.id).toBe("a"); // the cursor walks pinned threads first
|
|
130
|
+
const pinnedThreadIds = rows.filter((row) => row.kind === "thread").map((row) => row.id);
|
|
131
|
+
|
|
132
|
+
expect(pinnedThreadIds).toEqual(["a", "b"]);
|
|
133
|
+
});
|
|
114
134
|
});
|
|
@@ -50,12 +50,21 @@ interface ProjectGroup {
|
|
|
50
50
|
sessions: ReviewSession[];
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
/** Group pending sessions into Projects (by root commit) then standalone Threads. */
|
|
54
|
-
export function groupInbox(
|
|
53
|
+
/** Group pending sessions into Pinned, then Projects (by root commit), then standalone Threads. */
|
|
54
|
+
export function groupInbox(
|
|
55
|
+
sessions: readonly ReviewSession[],
|
|
56
|
+
pinnedIds?: ReadonlySet<string>,
|
|
57
|
+
): GroupedInbox {
|
|
58
|
+
const pinned: ReviewSession[] = [];
|
|
55
59
|
const projects = new Map<string, ProjectGroup>();
|
|
56
60
|
const standalone: ReviewSession[] = [];
|
|
57
61
|
|
|
58
62
|
for (const session of sessions) {
|
|
63
|
+
if (pinnedIds?.has(session.id) === true) {
|
|
64
|
+
pinned.push(session);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
59
68
|
const key = session.workspace.rootCommit;
|
|
60
69
|
if (key === undefined) {
|
|
61
70
|
standalone.push(session);
|
|
@@ -74,6 +83,11 @@ export function groupInbox(sessions: readonly ReviewSession[]): GroupedInbox {
|
|
|
74
83
|
ordered.push(session);
|
|
75
84
|
};
|
|
76
85
|
|
|
86
|
+
if (pinned.length > 0) {
|
|
87
|
+
rows.push({ kind: "section", id: "section:pinned", label: "Pinned" });
|
|
88
|
+
for (const session of pinned) pushThread(session);
|
|
89
|
+
}
|
|
90
|
+
|
|
77
91
|
const projectEntries = [...projects.entries()].sort((a, b) => a[1].name.localeCompare(b[1].name));
|
|
78
92
|
if (projectEntries.length > 0) {
|
|
79
93
|
rows.push({ kind: "section", id: "section:projects", label: "Projects" });
|
|
@@ -11,7 +11,7 @@ import { createRoot, useKeyboard } from "@opentui/react";
|
|
|
11
11
|
import { DARK } from "../theme";
|
|
12
12
|
import { ThemeProvider } from "./theme-context";
|
|
13
13
|
import { buildStoryTree, loadStories, type LoadedStory } from "./story";
|
|
14
|
-
import {
|
|
14
|
+
import { PaneGrid } from "./PaneGrid";
|
|
15
15
|
import { ShellHeader } from "./ShellHeader";
|
|
16
16
|
import { Tree } from "./primitives/Tree";
|
|
17
17
|
import { NERD } from "./primitives/icons";
|
|
@@ -103,7 +103,7 @@ function StoriesApp({ stories, onExit }: StoriesAppProps): React.ReactNode {
|
|
|
103
103
|
|
|
104
104
|
return (
|
|
105
105
|
<ThemeProvider theme={DARK}>
|
|
106
|
-
<
|
|
106
|
+
<PaneGrid
|
|
107
107
|
theme={DARK}
|
|
108
108
|
mainHeader={
|
|
109
109
|
<ShellHeader
|
package/src/config.test.ts
CHANGED
|
@@ -8,14 +8,12 @@ import {
|
|
|
8
8
|
actionFor,
|
|
9
9
|
loadConfig,
|
|
10
10
|
persistAuthorName,
|
|
11
|
-
persistReviewState,
|
|
12
11
|
persistActions,
|
|
13
|
-
|
|
12
|
+
persistPins,
|
|
14
13
|
persistTheme,
|
|
15
14
|
quickActionBody,
|
|
16
15
|
resolveQuickAction,
|
|
17
16
|
} from "./config";
|
|
18
|
-
import { REVIEW_DEFAULT_WIDTH, REVIEW_MAX_WIDTH } from "./review-panel";
|
|
19
17
|
import { DARK } from "./theme";
|
|
20
18
|
import { themeForName } from "./theme-presets";
|
|
21
19
|
|
|
@@ -128,48 +126,19 @@ describe("loadConfig", () => {
|
|
|
128
126
|
}
|
|
129
127
|
});
|
|
130
128
|
|
|
131
|
-
test("[ui]
|
|
132
|
-
// Act
|
|
133
|
-
const config = loadConfig({ userConfigPath: "/nonexistent/config.toml" });
|
|
134
|
-
|
|
135
|
-
// Assert
|
|
136
|
-
expect(config.ui.reviewState).toBe("expanded");
|
|
137
|
-
expect(config.ui.reviewWidth).toBe(REVIEW_DEFAULT_WIDTH);
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
test("[ui] parses review_width (clamped) and review_state", () => {
|
|
141
|
-
// Arrange
|
|
142
|
-
const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg6-"));
|
|
143
|
-
const path = join(dir, "config.toml");
|
|
144
|
-
|
|
145
|
-
writeFileSync(path, `[ui]\nreview_width = 999\nreview_state = "compact"\n`);
|
|
146
|
-
|
|
147
|
-
try {
|
|
148
|
-
// Act
|
|
149
|
-
const config = loadConfig({ userConfigPath: path });
|
|
150
|
-
|
|
151
|
-
// Assert
|
|
152
|
-
expect(config.ui.reviewWidth).toBe(REVIEW_MAX_WIDTH); // out-of-range width clamps
|
|
153
|
-
expect(config.ui.reviewState).toBe("compact");
|
|
154
|
-
} finally {
|
|
155
|
-
rmSync(dir, { recursive: true, force: true });
|
|
156
|
-
}
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
test("[ui] ignores an unknown review_state and a non-numeric width", () => {
|
|
129
|
+
test("[ui] diff_view defaults to unified and parses split", () => {
|
|
160
130
|
// Arrange
|
|
161
|
-
const dir = mkdtempSync(join(tmpdir(), "cueloop-
|
|
131
|
+
const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-diff-"));
|
|
162
132
|
const path = join(dir, "config.toml");
|
|
163
133
|
|
|
164
|
-
writeFileSync(path, `[ui]\
|
|
134
|
+
writeFileSync(path, `[ui]\ndiff_view = "split"\n`);
|
|
165
135
|
|
|
166
136
|
try {
|
|
167
|
-
// Act
|
|
168
|
-
const config = loadConfig({ userConfigPath: path });
|
|
169
|
-
|
|
170
137
|
// Assert
|
|
171
|
-
expect(config.ui.
|
|
172
|
-
|
|
138
|
+
expect(loadConfig({ userConfigPath: "/nonexistent/config.toml" }).ui.diffView).toBe(
|
|
139
|
+
"unified",
|
|
140
|
+
);
|
|
141
|
+
expect(loadConfig({ userConfigPath: path }).ui.diffView).toBe("split");
|
|
173
142
|
} finally {
|
|
174
143
|
rmSync(dir, { recursive: true, force: true });
|
|
175
144
|
}
|
|
@@ -288,28 +257,30 @@ describe("loadConfig", () => {
|
|
|
288
257
|
}
|
|
289
258
|
});
|
|
290
259
|
|
|
291
|
-
test("
|
|
260
|
+
test("persistPins round-trips the pinned-thread ids through the config file", () => {
|
|
292
261
|
// Arrange
|
|
293
|
-
const dir = mkdtempSync(join(tmpdir(), "cueloop-
|
|
262
|
+
const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-pins-"));
|
|
294
263
|
const path = join(dir, "config.toml");
|
|
295
264
|
|
|
296
265
|
try {
|
|
297
266
|
// Act
|
|
298
|
-
|
|
299
|
-
persistReviewState("hidden", path);
|
|
267
|
+
persistPins(["s_one", "s_two"], path);
|
|
300
268
|
|
|
301
269
|
// Assert
|
|
302
|
-
|
|
270
|
+
expect(loadConfig({ userConfigPath: path }).ui.pins).toEqual(["s_one", "s_two"]);
|
|
303
271
|
|
|
304
|
-
|
|
305
|
-
|
|
272
|
+
// unpinning rewrites the list in place, including down to empty
|
|
273
|
+
// Act
|
|
274
|
+
persistPins(["s_two"], path);
|
|
275
|
+
|
|
276
|
+
// Assert
|
|
277
|
+
expect(loadConfig({ userConfigPath: path }).ui.pins).toEqual(["s_two"]);
|
|
306
278
|
|
|
307
|
-
// a second write replaces the key in place rather than appending a duplicate
|
|
308
279
|
// Act
|
|
309
|
-
|
|
280
|
+
persistPins([], path);
|
|
310
281
|
|
|
311
282
|
// Assert
|
|
312
|
-
expect(loadConfig({ userConfigPath: path }).ui.
|
|
283
|
+
expect(loadConfig({ userConfigPath: path }).ui.pins).toEqual([]);
|
|
313
284
|
} finally {
|
|
314
285
|
rmSync(dir, { recursive: true, force: true });
|
|
315
286
|
}
|
package/src/config.ts
CHANGED
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
* Layered TOML config: built-in defaults → user config → trusted repo
|
|
3
3
|
* config → env. Sections: [keys] action = "combo" (every action rebindable),
|
|
4
4
|
* [theme] per-token overrides, [ui] auto_close + editor + theme (a named
|
|
5
|
-
* preset) +
|
|
6
|
-
* [integrations.obsidian] notes-vault export.
|
|
5
|
+
* preset) + pins, [integrations.obsidian] notes-vault export.
|
|
7
6
|
*/
|
|
8
7
|
|
|
9
8
|
import { existsSync, readFileSync } from "node:fs";
|
|
@@ -15,7 +14,6 @@ import { dirname } from "node:path";
|
|
|
15
14
|
import * as v from "valibot";
|
|
16
15
|
import { DARK, type Theme } from "./theme";
|
|
17
16
|
import { DEFAULT_THEME_NAME, isThemeName, themeForName, type ThemeName } from "./theme-presets";
|
|
18
|
-
import { REVIEW_DEFAULT_WIDTH, clampWidth, type ReviewPanelMode } from "./review-panel";
|
|
19
17
|
|
|
20
18
|
export interface KeymapConfig {
|
|
21
19
|
[action: string]: string | string[];
|
|
@@ -28,6 +26,9 @@ export interface IntegrationsConfig {
|
|
|
28
26
|
/** Post-submit behavior: "off" prompts, 0 closes instantly, N counts down. */
|
|
29
27
|
export type AutoClose = "off" | number;
|
|
30
28
|
|
|
29
|
+
/** How the Changes diff renders: one inline column, or old|new side by side (only when zoomed). */
|
|
30
|
+
export type DiffViewMode = "unified" | "split";
|
|
31
|
+
|
|
31
32
|
/** One marker-popover quick action: a preset comment body, plus optional extra lines. */
|
|
32
33
|
export interface QuickAction {
|
|
33
34
|
prompt: string;
|
|
@@ -93,17 +94,15 @@ export interface CueloopConfig {
|
|
|
93
94
|
theme: Theme;
|
|
94
95
|
/** The `[theme]` per-token overrides alone, so a live theme switch can re-compose them onto a new preset. */
|
|
95
96
|
themeOverrides: Partial<Theme>;
|
|
96
|
-
/**
|
|
97
|
-
* ui.reviewState / ui.reviewWidth are CLIENT VIEW STATE: the review panel's
|
|
98
|
-
* collapse mode and expanded-rail width, persisted so they survive restarts.
|
|
99
|
-
*/
|
|
100
97
|
ui: {
|
|
101
98
|
autoClose: AutoClose;
|
|
102
99
|
editor?: string;
|
|
103
|
-
reviewState: ReviewPanelMode;
|
|
104
|
-
reviewWidth: number;
|
|
105
100
|
/** The selected theme preset name; its tokens are the base for `theme`, before any `[theme]` overrides. */
|
|
106
101
|
theme: ThemeName;
|
|
102
|
+
/** How the Changes diff renders when zoomed: one inline column or old|new side by side. */
|
|
103
|
+
diffView: DiffViewMode;
|
|
104
|
+
/** Session ids the user has pinned to the top of the sidebar; client-local view state. */
|
|
105
|
+
pins: string[];
|
|
107
106
|
};
|
|
108
107
|
/** Planner-local author renames: identity id → display name ([authors] table). */
|
|
109
108
|
authors: Record<string, string>;
|
|
@@ -123,6 +122,9 @@ export const DEFAULT_KEYS: CueloopConfig["keys"] = {
|
|
|
123
122
|
cut: ["x"],
|
|
124
123
|
reject_hunk: ["X"],
|
|
125
124
|
restore_curation: ["u"],
|
|
125
|
+
collapse_file: ["right"],
|
|
126
|
+
expand_file: ["left"],
|
|
127
|
+
split_diff: ["s"],
|
|
126
128
|
edit: ["e"],
|
|
127
129
|
next_annotation: ["n"],
|
|
128
130
|
prev_annotation: ["p"],
|
|
@@ -132,9 +134,6 @@ export const DEFAULT_KEYS: CueloopConfig["keys"] = {
|
|
|
132
134
|
share: ["S"],
|
|
133
135
|
quit: ["q"],
|
|
134
136
|
walk: ["w"],
|
|
135
|
-
review_cycle: ["b"],
|
|
136
|
-
review_wider: ["]"],
|
|
137
|
-
review_narrower: ["["],
|
|
138
137
|
};
|
|
139
138
|
|
|
140
139
|
const ConfigDocumentSchema = v.object({
|
|
@@ -165,9 +164,9 @@ const UiSchema = v.object({
|
|
|
165
164
|
undefined,
|
|
166
165
|
),
|
|
167
166
|
editor: v.fallback(v.optional(v.string()), undefined),
|
|
168
|
-
review_width: v.fallback(v.optional(v.pipe(v.number(), v.finite())), undefined),
|
|
169
|
-
review_state: v.fallback(v.optional(v.picklist(["expanded", "compact", "hidden"])), undefined),
|
|
170
167
|
theme: v.fallback(v.optional(v.string()), undefined),
|
|
168
|
+
diff_view: v.fallback(v.optional(v.picklist(["unified", "split"])), undefined),
|
|
169
|
+
pins: v.fallback(v.optional(v.array(v.string())), undefined),
|
|
171
170
|
});
|
|
172
171
|
const ObsidianSchema = v.object({
|
|
173
172
|
vault: v.fallback(v.optional(v.string()), undefined),
|
|
@@ -252,9 +251,8 @@ function layer(
|
|
|
252
251
|
if (ui.success) {
|
|
253
252
|
if (ui.output.auto_close !== undefined) out.ui.autoClose = ui.output.auto_close;
|
|
254
253
|
if (ui.output.editor?.trim()) out.ui.editor = ui.output.editor.trim();
|
|
255
|
-
if (ui.output.
|
|
256
|
-
|
|
257
|
-
if (ui.output.review_state !== undefined) out.ui.reviewState = ui.output.review_state;
|
|
254
|
+
if (ui.output.diff_view !== undefined) out.ui.diffView = ui.output.diff_view;
|
|
255
|
+
if (ui.output.pins !== undefined) out.ui.pins = ui.output.pins;
|
|
258
256
|
}
|
|
259
257
|
if (integrations.success && integrations.output.obsidian) {
|
|
260
258
|
mergeObsidian(out.integrations.obsidian, integrations.output.obsidian);
|
|
@@ -272,9 +270,9 @@ export function loadConfig(
|
|
|
272
270
|
themeOverrides: {},
|
|
273
271
|
ui: {
|
|
274
272
|
autoClose: "off",
|
|
275
|
-
reviewState: "expanded",
|
|
276
|
-
reviewWidth: REVIEW_DEFAULT_WIDTH,
|
|
277
273
|
theme: DEFAULT_THEME_NAME,
|
|
274
|
+
diffView: "unified",
|
|
275
|
+
pins: [],
|
|
278
276
|
},
|
|
279
277
|
authors: {},
|
|
280
278
|
actions: [...DEFAULT_QUICK_ACTIONS],
|
|
@@ -403,19 +401,19 @@ export function persistAutoClose(value: AutoClose, userConfigPath?: string): voi
|
|
|
403
401
|
persistUiSetting("auto_close", value === "off" ? '"off"' : String(value), userConfigPath);
|
|
404
402
|
}
|
|
405
403
|
|
|
406
|
-
/** Persist the
|
|
407
|
-
export function
|
|
408
|
-
persistUiSetting("
|
|
404
|
+
/** Persist the selected theme preset (`[ui] theme`) into the user config. */
|
|
405
|
+
export function persistTheme(name: ThemeName, userConfigPath?: string): void {
|
|
406
|
+
persistUiSetting("theme", `"${name}"`, userConfigPath);
|
|
409
407
|
}
|
|
410
408
|
|
|
411
|
-
/** Persist the
|
|
412
|
-
export function
|
|
413
|
-
persistUiSetting("
|
|
409
|
+
/** Persist the diff-view choice (`[ui] diff_view`) into the user config. */
|
|
410
|
+
export function persistDiffView(mode: DiffViewMode, userConfigPath?: string): void {
|
|
411
|
+
persistUiSetting("diff_view", `"${mode}"`, userConfigPath);
|
|
414
412
|
}
|
|
415
413
|
|
|
416
|
-
/** Persist the
|
|
417
|
-
export function
|
|
418
|
-
persistUiSetting("
|
|
414
|
+
/** Persist the pinned-thread ids (`[ui] pins`) into the user config. */
|
|
415
|
+
export function persistPins(ids: readonly string[], userConfigPath?: string): void {
|
|
416
|
+
persistUiSetting("pins", `[${ids.map(tomlString).join(", ")}]`, userConfigPath);
|
|
419
417
|
}
|
|
420
418
|
|
|
421
419
|
function escapeRegExp(text: string): string {
|